feat(invoices): generate GST invoices for AI credit purchases
All checks were successful
build-and-release / build (developers) (push) Successful in 1m41s
build-and-release / build (catering-services) (push) Successful in 1m58s
build-and-release / build (companies) (push) Successful in 2m0s
build-and-release / build (cron) (push) Successful in 2m8s
build-and-release / build (customers) (push) Successful in 2m42s
build-and-release / build (gateway) (push) Successful in 1m2s
build-and-release / build (fitness-trainers) (push) Successful in 1m34s
build-and-release / build (jobs) (push) Successful in 44s
build-and-release / build (employees) (push) Successful in 1m50s
build-and-release / build (graphic-designers) (push) Successful in 2m41s
build-and-release / build (makeup-artists) (push) Successful in 1m49s
build-and-release / build (job-seekers) (push) Successful in 3m2s
build-and-release / build (photographers) (push) Successful in 2m40s
build-and-release / build (payments) (push) Successful in 2m50s
build-and-release / build (social-media-managers) (push) Successful in 2m55s
backend-integration-tests / ai-credits (push) Successful in 52s
build-and-release / build (ugc-content-creators) (push) Successful in 2m37s
build-and-release / build (tutors) (push) Successful in 2m56s
build-and-release / build (video-editors) (push) Successful in 2m41s
build-and-release / build (users) (push) Successful in 4m44s
All checks were successful
build-and-release / build (developers) (push) Successful in 1m41s
build-and-release / build (catering-services) (push) Successful in 1m58s
build-and-release / build (companies) (push) Successful in 2m0s
build-and-release / build (cron) (push) Successful in 2m8s
build-and-release / build (customers) (push) Successful in 2m42s
build-and-release / build (gateway) (push) Successful in 1m2s
build-and-release / build (fitness-trainers) (push) Successful in 1m34s
build-and-release / build (jobs) (push) Successful in 44s
build-and-release / build (employees) (push) Successful in 1m50s
build-and-release / build (graphic-designers) (push) Successful in 2m41s
build-and-release / build (makeup-artists) (push) Successful in 1m49s
build-and-release / build (job-seekers) (push) Successful in 3m2s
build-and-release / build (photographers) (push) Successful in 2m40s
build-and-release / build (payments) (push) Successful in 2m50s
build-and-release / build (social-media-managers) (push) Successful in 2m55s
backend-integration-tests / ai-credits (push) Successful in 52s
build-and-release / build (ugc-content-creators) (push) Successful in 2m37s
build-and-release / build (tutors) (push) Successful in 2m56s
build-and-release / build (video-editors) (push) Successful in 2m41s
build-and-release / build (users) (push) Successful in 4m44s
AI credit purchases (money -> credits via PayU) never generated an invoice, even though the exact same infrastructure already works for TraceCoin purchases in main.rs's generate_purchase_invoice. Spending credits (try_reserve_credits/capture) correctly does NOT get an invoice - only real-money purchases do, matching existing TraceCoin behavior. - invoices.payment_id had a hard FK to payments(id) only, which blocks using it for ai_credit_orders(id) rows. Postgres has no polymorphic FK; dropped the constraint (invoice_type already says which table payment_id points into) via a new migration rather than editing the original invoices migration. - Added generate_ai_credit_invoice in ai_credits.rs, called from verify_order after a successful PayU payment - mirrors main.rs's pattern exactly (same non-blocking failure handling, same seller details via the now pub(crate) seller_details(), invoice_type 'AI_CREDIT_PURCHASE'). - Caught a real bug while writing this: order.amount_inr is the POST-discount final price, but NewInvoice.discount_amount is subtracted again inside compute_totals (subtotal - discount) - using amount_inr directly as unit_price_paise would have double-subtracted the discount. Reconstructed the pre-discount price (amount_inr + discount_applied) for the line item instead. Applied the FK-drop migration to nxtgauge_test and prod; verified the constraint is gone and user_id's FK is untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
65262e842c
commit
c2fb7d61f7
4 changed files with 101 additions and 2 deletions
|
|
@ -413,7 +413,7 @@ async fn verify_order(
|
|||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||
|
||||
// Track coupon redemption if coupon was used
|
||||
if let Some(coupon_code) = order.coupon_code {
|
||||
if let Some(ref coupon_code) = order.coupon_code {
|
||||
let coupon_id: Option<Uuid> = sqlx::query_scalar("SELECT id FROM ai_coupons WHERE code = $1")
|
||||
.bind(&coupon_code)
|
||||
.fetch_optional(&state.pool)
|
||||
|
|
@ -461,12 +461,88 @@ async fn verify_order(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||
|
||||
generate_ai_credit_invoice(&state.pool, &order, &payload).await;
|
||||
|
||||
Ok(Json(VerifyAiOrderResponse {
|
||||
verified: true,
|
||||
credits_added: order.credits,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Generates a GST invoice for a successful AI credit purchase, mirroring
|
||||
/// main.rs's generate_purchase_invoice for TraceCoin packages. Never blocks
|
||||
/// the payment response - invoice generation failures are logged, not
|
||||
/// surfaced to the buyer, since the payment itself already succeeded and
|
||||
/// the wallet was already credited by the time this runs.
|
||||
async fn generate_ai_credit_invoice(pool: &PgPool, order: &AiCreditOrderRow, payload: &VerifyAiOrderRequest) {
|
||||
let package_name: Option<String> = sqlx::query_scalar("SELECT name FROM ai_credit_packages WHERE id = $1")
|
||||
.bind(order.package_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let phone: Option<String> = sqlx::query_scalar("SELECT phone FROM users WHERE id = $1")
|
||||
.bind(order.user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let description = package_name.unwrap_or_else(|| format!("{} AI credits", order.credits));
|
||||
|
||||
// order.amount_inr is the FINAL price already charged (post-discount -
|
||||
// create_order inserts final_price, the post-coupon amount). compute_totals
|
||||
// does `subtotal - discount` itself, so the line item's unit_price_paise
|
||||
// must be the pre-discount price or the discount gets subtracted twice.
|
||||
let discount_paise = order.discount_applied.unwrap_or(0) as i64;
|
||||
let pre_discount_price_paise = order.amount_inr as i64 + discount_paise;
|
||||
|
||||
let line = invoice::LineItem {
|
||||
line_number: 1,
|
||||
description,
|
||||
hsn_sac_code: None,
|
||||
quantity: 1.0,
|
||||
unit_price_paise: pre_discount_price_paise,
|
||||
tax_rate_percent: 18.0,
|
||||
metadata: Some(serde_json::json!({ "package_id": order.package_id, "credits": order.credits })),
|
||||
};
|
||||
|
||||
let customer = invoice::BillingDetails {
|
||||
legal_name: payload.firstname.clone(),
|
||||
email: Some(payload.email.clone()),
|
||||
phone,
|
||||
gstin: None,
|
||||
pan: None,
|
||||
billing_address: "Billing address not provided".to_string(),
|
||||
state_code: None,
|
||||
};
|
||||
|
||||
let new_invoice = invoice::service::NewInvoice {
|
||||
payment_id: order.id,
|
||||
user_id: order.user_id,
|
||||
currency: "INR".to_string(),
|
||||
invoice_type: "AI_CREDIT_PURCHASE".to_string(),
|
||||
lines: vec![line],
|
||||
discount_amount: discount_paise,
|
||||
discount_label: order.coupon_code.clone(),
|
||||
notes: None,
|
||||
customer,
|
||||
seller: crate::seller_details(),
|
||||
inter_state: false,
|
||||
pdf_object_key: None,
|
||||
};
|
||||
|
||||
match invoice::service::InvoiceService::create(pool, new_invoice).await {
|
||||
Ok(inv) => tracing::info!(
|
||||
"Generated invoice {} for AI credit order {}",
|
||||
inv.invoice_number,
|
||||
order.id
|
||||
),
|
||||
Err(e) => tracing::error!("Failed to generate invoice for AI credit order {}: {:?}", order.id, e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin package management ────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
|
|
|
|||
|
|
@ -157,7 +157,8 @@ fn error_response(status: StatusCode, message: impl Into<String>) -> (StatusCode
|
|||
|
||||
/// Nxtgauge's own seller identity for GST invoicing — configurable via env
|
||||
/// since GSTIN/PAN/registered address are business details, not code.
|
||||
fn seller_details() -> invoice::SellerDetails {
|
||||
/// pub(crate) so ai_credits.rs's own invoice generation can reuse it.
|
||||
pub(crate) fn seller_details() -> invoice::SellerDetails {
|
||||
invoice::SellerDetails {
|
||||
name: std::env::var("INVOICE_SELLER_NAME").unwrap_or_else(|_| "Nxtgauge".to_string()),
|
||||
address: std::env::var("INVOICE_SELLER_ADDRESS")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
BEGIN;
|
||||
|
||||
-- Only safe to restore if every existing row's payment_id is still a valid
|
||||
-- payments(id) - true as long as no AI_CREDIT_PURCHASE invoices exist yet.
|
||||
ALTER TABLE invoices ADD CONSTRAINT invoices_payment_id_fkey FOREIGN KEY (payment_id) REFERENCES payments(id);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
-- invoices.payment_id was hard-FK'd to payments(id) only, which is fine
|
||||
-- while TRACECOIN_PURCHASE is the only invoice_type in existence, but blocks
|
||||
-- adding AI_CREDIT_PURCHASE invoices (ai_credits.rs's verify_order) since
|
||||
-- those reference ai_credit_orders(id), a completely separate table.
|
||||
-- Postgres has no native polymorphic FK, so drop the constraint and rely on
|
||||
-- invoice_type to say which table payment_id actually points into -- the
|
||||
-- same pattern crates/invoice/src/service.rs's NewInvoice.payment_id was
|
||||
-- already written generically (just a Uuid, not tied to one table) to
|
||||
-- support.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_payment_id_fkey;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue