feat: generate a GST invoice automatically after a successful Tracecoin/PayU purchase

crates/invoice (InvoiceService, GST computation, HTML rendering) was
fully built but never wired to anything and never had its tables —
invoices/invoice_line_items/billing_profiles/invoice_number_seq never
existed in any active migration (same root cause as everything else
this session: only in scripts/init-db.sql, which the real db-migrate
job never runs). Created them, matching InvoiceService's actual
columns exactly rather than init-db.sql's older, simpler invoices
shape.

Fixed a real bug in InvoiceService::create while at it: four money
fields (total, and three line-item amounts) were bound as i64 against
columns/read-models that are i32 everywhere else — would have failed
every insert with a Postgres type mismatch the first time this code
ever actually ran against a real table.

Wired invoice generation into apps/payments' PayU verify_payment
handler (the actual success callback) — right after the wallet is
credited, a one-line-item GST invoice is generated from the purchased
package and PayU's billing fields (firstname/email/phone), using a
new INVOICE_SELLER_* env-configurable seller identity. Generation
failures are logged, not surfaced to the buyer, since the payment and
wallet credit have already succeeded by that point.

Also added the missing user-facing endpoints to fetch what got
generated: GET /api/payments/invoices (list) and
GET /api/payments/invoices/{id} (detail + line items) — previously
only admin-side invoice viewing existed.

NOTE: the frontend (nxtgauge-frontend-solid, a separate repo) has an
existing invoice-viewing page at src/routes/dashboard/wallet/invoices/
but it calls /wallet/me/invoices (no /api/ prefix) via a different,
apparently-dead API helper (api.get, not apiFetch) that every other
live page avoids — same dead-code pattern as the earlier apps/leads
discovery. The live purchase flow (CreditsPage.tsx) has no invoice UI
at all yet. Not fixed here since it's out of this repo's scope this
session — flagging for a frontend pass.
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-21 03:02:47 +05:30
parent 119c39e184
commit 63fd3f5135
6 changed files with 238 additions and 5 deletions

14
Cargo.lock generated
View file

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

View file

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

View file

@ -155,6 +155,82 @@ fn error_response(status: StatusCode, message: impl Into<String>) -> (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<String> = 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<i64>,
limit: Option<i64>,
}
async fn list_my_invoices(
auth: AuthUser,
State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<ListInvoicesQuery>,
) -> Result<Json<serde_json::Value>, (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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (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())

View file

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

View file

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

View file

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