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:
parent
119c39e184
commit
63fd3f5135
6 changed files with 238 additions and 5 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -2232,6 +2232,19 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "invoice"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"thiserror",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
|
|
@ -2873,6 +2886,7 @@ dependencies = [
|
||||||
"contracts",
|
"contracts",
|
||||||
"db",
|
"db",
|
||||||
"hex",
|
"hex",
|
||||||
|
"invoice",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
contracts = { path = "../../crates/contracts" }
|
contracts = { path = "../../crates/contracts" }
|
||||||
db = { path = "../../crates/db" }
|
db = { path = "../../crates/db" }
|
||||||
|
invoice = { path = "../../crates/invoice" }
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,82 @@ fn error_response(status: StatusCode, message: impl Into<String>) -> (StatusCode
|
||||||
(status, message.into())
|
(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 {
|
pub(crate) fn build_txnid() -> String {
|
||||||
format!("tc{}", Uuid::new_v4().simple())
|
format!("tc{}", Uuid::new_v4().simple())
|
||||||
.chars()
|
.chars()
|
||||||
|
|
@ -498,6 +574,8 @@ async fn verify_payment(
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generate_purchase_invoice(&state.pool, &payment, &payload).await;
|
||||||
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
|
|
@ -598,6 +720,8 @@ async fn main() {
|
||||||
.route("/api/payments/create-order", post(create_order))
|
.route("/api/payments/create-order", post(create_order))
|
||||||
.route("/api/payments/verify", post(verify_payment))
|
.route("/api/payments/verify", post(verify_payment))
|
||||||
.route("/api/payments/{id}/status", get(get_payment_status))
|
.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/packages", packages::router())
|
||||||
.nest("/api/ai-credits", ai_credits::router())
|
.nest("/api/ai-credits", ai_credits::router())
|
||||||
.nest("/api/admin/ai-credits/packages", ai_credits::admin_router())
|
.nest("/api/admin/ai-credits/packages", ai_credits::admin_router())
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
90
crates/db/migrations/20260721040000_create_invoices.up.sql
Normal file
90
crates/db/migrations/20260721040000_create_invoices.up.sql
Normal 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;
|
||||||
|
|
@ -258,7 +258,7 @@ impl InvoiceService {
|
||||||
.bind(tax.sgst_amount as i32)
|
.bind(tax.sgst_amount as i32)
|
||||||
.bind(tax.igst_rate)
|
.bind(tax.igst_rate)
|
||||||
.bind(tax.igst_amount as i32)
|
.bind(tax.igst_amount as i32)
|
||||||
.bind(totals.total as i64)
|
.bind(totals.total as i32)
|
||||||
.bind(new.inter_state)
|
.bind(new.inter_state)
|
||||||
.bind(&new.seller.name)
|
.bind(&new.seller.name)
|
||||||
.bind(&new.seller.address)
|
.bind(&new.seller.address)
|
||||||
|
|
@ -294,11 +294,11 @@ impl InvoiceService {
|
||||||
.bind(&line.description)
|
.bind(&line.description)
|
||||||
.bind(line.hsn_sac_code.as_deref())
|
.bind(line.hsn_sac_code.as_deref())
|
||||||
.bind(line.quantity)
|
.bind(line.quantity)
|
||||||
.bind(line.unit_price_paise as i64)
|
.bind(line.unit_price_paise as i32)
|
||||||
.bind(line.subtotal_paise() as i64)
|
.bind(line.subtotal_paise() as i32)
|
||||||
.bind(line.tax_rate_percent)
|
.bind(line.tax_rate_percent)
|
||||||
.bind(line.tax_paise() as i64)
|
.bind(line.tax_paise() as i32)
|
||||||
.bind(line.total_paise() as i64)
|
.bind(line.total_paise() as i32)
|
||||||
.bind(line.metadata.as_ref())
|
.bind(line.metadata.as_ref())
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue