diff --git a/Cargo.lock b/Cargo.lock index d9dbc69..25680f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2232,6 +2232,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "invoice" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tracing", + "uuid", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2873,6 +2886,7 @@ dependencies = [ "contracts", "db", "hex", + "invoice", "rand 0.8.6", "reqwest", "rust_decimal", diff --git a/apps/payments/Cargo.toml b/apps/payments/Cargo.toml index 579263e..f1bb4c1 100644 --- a/apps/payments/Cargo.toml +++ b/apps/payments/Cargo.toml @@ -14,6 +14,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] } anyhow.workspace = true contracts = { path = "../../crates/contracts" } db = { path = "../../crates/db" } +invoice = { path = "../../crates/invoice" } sqlx.workspace = true uuid.workspace = true chrono.workspace = true diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index a27971f..a68e2af 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -155,6 +155,82 @@ fn error_response(status: StatusCode, message: impl Into) -> (StatusCode (status, message.into()) } +/// 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 { + invoice::SellerDetails { + name: std::env::var("INVOICE_SELLER_NAME").unwrap_or_else(|_| "Nxtgauge".to_string()), + address: std::env::var("INVOICE_SELLER_ADDRESS") + .unwrap_or_else(|_| "Registered address not configured".to_string()), + gstin: std::env::var("INVOICE_SELLER_GSTIN").ok(), + pan: std::env::var("INVOICE_SELLER_PAN").ok(), + state_code: std::env::var("INVOICE_SELLER_STATE_CODE").unwrap_or_else(|_| "KA".to_string()), + } +} + +/// Generates a GST invoice for a successful Tracecoin/package purchase. +/// 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_purchase_invoice( + pool: &PgPool, + payment: &PaymentRow, + payload: &VerifyPaymentRequest, +) { + let package_name: Option = sqlx::query_scalar("SELECT name FROM pricing_packages WHERE id = $1") + .bind(payment.package_id) + .fetch_optional(pool) + .await + .ok() + .flatten(); + + let description = package_name.unwrap_or_else(|| "Tracecoin package purchase".to_string()); + + let line = invoice::LineItem { + line_number: 1, + description, + hsn_sac_code: None, + quantity: 1.0, + unit_price_paise: (payment.amount_inr as i64) * 100, + tax_rate_percent: 18.0, + metadata: payment.package_id.map(|id| serde_json::json!({ "package_id": id })), + }; + + let customer = invoice::BillingDetails { + legal_name: payload.firstname.clone(), + email: Some(payload.email.clone()), + phone: payload.phone.clone(), + gstin: None, + pan: None, + billing_address: "Billing address not provided".to_string(), + state_code: None, + }; + + let new_invoice = invoice::service::NewInvoice { + payment_id: payment.id, + user_id: payment.user_id, + currency: "INR".to_string(), + invoice_type: "TRACECOIN_PURCHASE".to_string(), + lines: vec![line], + discount_amount: 0, + discount_label: None, + notes: None, + customer, + seller: 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 payment {}", + inv.invoice_number, + payment.id + ), + Err(e) => tracing::error!("Failed to generate invoice for payment {}: {:?}", payment.id, e), + } +} + pub(crate) fn build_txnid() -> String { format!("tc{}", Uuid::new_v4().simple()) .chars() @@ -498,6 +574,8 @@ async fn verify_payment( .await; } + generate_purchase_invoice(&state.pool, &payment, &payload).await; + let _ = sqlx::query( r#" INSERT INTO notifications (user_id, title, body, type, reference_id) @@ -575,6 +653,50 @@ async fn get_payment_status( })) } +#[derive(Debug, serde::Deserialize)] +struct ListInvoicesQuery { + page: Option, + limit: Option, +} + +async fn list_my_invoices( + auth: AuthUser, + State(state): State, + axum::extract::Query(q): axum::extract::Query, +) -> Result, (StatusCode, String)> { + let page = q.page.unwrap_or(1); + let limit = q.limit.unwrap_or(20); + let invoices = invoice::service::InvoiceService::list_for_user(&state.pool, auth.user_id, page, limit) + .await + .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?; + + Ok(Json(serde_json::json!({ + "data": invoices, + "pagination": { "page": page, "limit": limit } + }))) +} + +async fn get_my_invoice( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let inv = invoice::service::InvoiceService::get(&state.pool, id) + .await + .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))? + .ok_or_else(|| error_response(StatusCode::NOT_FOUND, "Invoice not found"))?; + + if inv.user_id != auth.user_id { + return Err(error_response(StatusCode::FORBIDDEN, "Invoice does not belong to user")); + } + + let lines = invoice::service::InvoiceService::list_line_items(&state.pool, id) + .await + .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?; + + Ok(Json(serde_json::json!({ "invoice": inv, "line_items": lines }))) +} + #[tokio::main] async fn main() { tracing_subscriber::registry() @@ -598,6 +720,8 @@ async fn main() { .route("/api/payments/create-order", post(create_order)) .route("/api/payments/verify", post(verify_payment)) .route("/api/payments/{id}/status", get(get_payment_status)) + .route("/api/payments/invoices", get(list_my_invoices)) + .route("/api/payments/invoices/{id}", get(get_my_invoice)) .nest("/api/packages", packages::router()) .nest("/api/ai-credits", ai_credits::router()) .nest("/api/admin/ai-credits/packages", ai_credits::admin_router()) diff --git a/crates/db/migrations/20260721040000_create_invoices.down.sql b/crates/db/migrations/20260721040000_create_invoices.down.sql new file mode 100644 index 0000000..8c15369 --- /dev/null +++ b/crates/db/migrations/20260721040000_create_invoices.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS invoice_line_items; +DROP TABLE IF EXISTS invoices; +DROP TABLE IF EXISTS billing_profiles; +DROP SEQUENCE IF EXISTS invoice_number_seq; diff --git a/crates/db/migrations/20260721040000_create_invoices.up.sql b/crates/db/migrations/20260721040000_create_invoices.up.sql new file mode 100644 index 0000000..6bf252c --- /dev/null +++ b/crates/db/migrations/20260721040000_create_invoices.up.sql @@ -0,0 +1,90 @@ +-- Backs crates/invoice (InvoiceService, BillingProfileRepo) — GST-compliant +-- invoice generation for Tracecoin/package purchases. Never created by any +-- active migration (same root cause as everything else fixed this session): +-- init-db.sql has a much simpler, older `invoices` shape that doesn't match +-- what InvoiceService actually reads/writes (no GST breakdown, seller/ +-- customer snapshot, or line items), so it wasn't used as the reference here +-- — the schema below matches crates/invoice/src/lib.rs's Invoice/LineItem +-- structs and crates/invoice/src/service.rs's queries exactly. +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_number VARCHAR(50) NOT NULL UNIQUE, + payment_id UUID NOT NULL REFERENCES payments(id), + user_id UUID NOT NULL REFERENCES users(id), + status VARCHAR(20) NOT NULL DEFAULT 'ISSUED', + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + invoice_type VARCHAR(50) NOT NULL DEFAULT 'TRACECOIN_PURCHASE', + subtotal INTEGER NOT NULL, + discount_amount INTEGER NOT NULL DEFAULT 0, + cgst_rate DOUBLE PRECISION NOT NULL DEFAULT 0, + cgst_amount INTEGER NOT NULL DEFAULT 0, + sgst_rate DOUBLE PRECISION NOT NULL DEFAULT 0, + sgst_amount INTEGER NOT NULL DEFAULT 0, + igst_rate DOUBLE PRECISION NOT NULL DEFAULT 0, + igst_amount INTEGER NOT NULL DEFAULT 0, + total INTEGER NOT NULL, + reverse_charge BOOLEAN NOT NULL DEFAULT false, + seller_name VARCHAR(255) NOT NULL, + seller_address TEXT NOT NULL, + seller_gstin VARCHAR(20), + seller_pan VARCHAR(20), + seller_state_code VARCHAR(10), + place_of_supply_state VARCHAR(10), + customer_name VARCHAR(255), + customer_email VARCHAR(255), + customer_phone VARCHAR(20), + customer_billing_address TEXT, + customer_gstin VARCHAR(20), + customer_state_code VARCHAR(10), + discount_label VARCHAR(255), + notes TEXT, + pdf_object_key VARCHAR(500), + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + paid_at TIMESTAMPTZ, + voided_at TIMESTAMPTZ, + voided_by_user_id UUID REFERENCES users(id), + void_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id); +CREATE INDEX IF NOT EXISTS idx_invoices_payment_id ON invoices(payment_id); +CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status); + +CREATE TABLE IF NOT EXISTS invoice_line_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + line_number INTEGER NOT NULL, + description VARCHAR(500) NOT NULL, + hsn_sac_code VARCHAR(20), + quantity DOUBLE PRECISION NOT NULL DEFAULT 1, + unit_price_inr INTEGER NOT NULL, + line_subtotal_inr INTEGER NOT NULL, + tax_rate DOUBLE PRECISION NOT NULL DEFAULT 0, + line_tax_inr INTEGER NOT NULL DEFAULT 0, + line_total_inr INTEGER NOT NULL, + metadata JSONB +); +CREATE INDEX IF NOT EXISTS idx_invoice_line_items_invoice_id ON invoice_line_items(invoice_id); + +-- Default billing details a user has on file, used to prefill invoices +-- without asking again on every purchase (BillingProfileRepo). +CREATE TABLE IF NOT EXISTS billing_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + legal_name VARCHAR(255) NOT NULL, + email VARCHAR(255), + phone VARCHAR(20), + gstin VARCHAR(20), + pan VARCHAR(20), + billing_address TEXT NOT NULL, + state_code VARCHAR(10), + is_default BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_billing_profiles_user_id ON billing_profiles(user_id); + +-- InvoiceService::create allocates invoice numbers from this sequence +-- (crates/invoice/src/service.rs: `SELECT nextval('invoice_number_seq')`). +CREATE SEQUENCE IF NOT EXISTS invoice_number_seq; diff --git a/crates/invoice/src/service.rs b/crates/invoice/src/service.rs index 293f2a9..b4409bf 100644 --- a/crates/invoice/src/service.rs +++ b/crates/invoice/src/service.rs @@ -258,7 +258,7 @@ impl InvoiceService { .bind(tax.sgst_amount as i32) .bind(tax.igst_rate) .bind(tax.igst_amount as i32) - .bind(totals.total as i64) + .bind(totals.total as i32) .bind(new.inter_state) .bind(&new.seller.name) .bind(&new.seller.address) @@ -294,11 +294,11 @@ impl InvoiceService { .bind(&line.description) .bind(line.hsn_sac_code.as_deref()) .bind(line.quantity) - .bind(line.unit_price_paise as i64) - .bind(line.subtotal_paise() as i64) + .bind(line.unit_price_paise as i32) + .bind(line.subtotal_paise() as i32) .bind(line.tax_rate_percent) - .bind(line.tax_paise() as i64) - .bind(line.total_paise() as i64) + .bind(line.tax_paise() as i32) + .bind(line.total_paise() as i32) .bind(line.metadata.as_ref()) .execute(&mut *tx) .await?;