diff --git a/apps/payments/src/ai_credits.rs b/apps/payments/src/ai_credits.rs index ea4d188..78cb2e8 100644 --- a/apps/payments/src/ai_credits.rs +++ b/apps/payments/src/ai_credits.rs @@ -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 = 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 = 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 = 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)] diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index 870a494..26b0594 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -157,7 +157,8 @@ fn error_response(status: StatusCode, message: impl Into) -> (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") diff --git a/crates/db/migrations/20260814030000_invoices_payment_id_polymorphic.down.sql b/crates/db/migrations/20260814030000_invoices_payment_id_polymorphic.down.sql new file mode 100644 index 0000000..89cceca --- /dev/null +++ b/crates/db/migrations/20260814030000_invoices_payment_id_polymorphic.down.sql @@ -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; diff --git a/crates/db/migrations/20260814030000_invoices_payment_id_polymorphic.up.sql b/crates/db/migrations/20260814030000_invoices_payment_id_polymorphic.up.sql new file mode 100644 index 0000000..9ccaa07 --- /dev/null +++ b/crates/db/migrations/20260814030000_invoices_payment_id_polymorphic.up.sql @@ -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;