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

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:
Ashwin Kumar Sivakumar 2026-08-14 03:17:17 +05:30
parent 65262e842c
commit c2fb7d61f7
4 changed files with 101 additions and 2 deletions

View file

@ -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)]

View file

@ -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")

View file

@ -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;

View file

@ -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;