nxtgauge-backend-rust/apps/payments/src/main.rs

763 lines
24 KiB
Rust
Raw Normal View History

// retrigger-build-marker-3
#![allow(dead_code)]
use axum::{
2026-06-26 20:47:31 +02:00
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha512};
2026-06-26 20:47:31 +02:00
use sqlx::{postgres::PgPool, FromRow};
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uuid::Uuid;
pub mod ai_credits;
pub mod packages;
pub mod payu;
#[derive(Clone)]
pub struct AppState {
pool: PgPool,
payu: payu::PayuConfig,
}
2026-06-26 20:47:31 +02:00
#[derive(Debug, Deserialize)]
struct CreateOrderRequest {
// Client-supplied amount/currency are accepted for backward
// compatibility but NOT trusted for pricing -- the real charge is
// always derived server-side from pricing_packages.price_inr (see
// create_order). Trusting a client-supplied amount for a real payment
// gateway would let a client pay any amount it likes for a package.
#[allow(dead_code)]
amount: Option<u64>,
#[allow(dead_code)]
currency: Option<String>,
#[allow(dead_code)]
2026-06-26 20:47:31 +02:00
receipt: Option<String>,
package_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct CreateOrderResponse {
key: String,
txnid: String,
amount: String,
productinfo: String,
firstname: String,
email: String,
phone: String,
surl: String,
furl: String,
hash: String,
payu_base_url: String,
udf1: String,
udf2: String,
// Kept for callers still reading the pre-PayU response shape.
order_id: String,
currency: String,
status: String,
}
2026-06-26 20:47:31 +02:00
#[derive(Debug, Deserialize)]
struct VerifyPaymentRequest {
txnid: String,
mihpayid: String,
status: String,
hash: String,
amount: String,
productinfo: String,
firstname: String,
email: String,
#[serde(default)]
phone: Option<String>,
#[serde(default)]
udf1: Option<String>,
#[serde(default)]
udf2: Option<String>,
}
#[derive(Debug, Serialize)]
struct VerifyPaymentResponse {
verified: bool,
payment_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
reference_number: Option<String>,
status: String,
message: String,
}
#[derive(Debug, Serialize)]
struct PaymentStatusResponse {
payment_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
reference_number: Option<String>,
status: String,
amount: u64,
currency: String,
}
#[derive(Debug, FromRow)]
struct PricingPackageRow {
name: String,
tracecoins_amount: i32,
2026-06-26 20:47:31 +02:00
price_inr: i32,
}
#[derive(Debug, FromRow)]
struct UserContactRow {
email: String,
full_name: Option<String>,
phone: Option<String>,
}
#[derive(Debug, FromRow)]
struct PaymentRow {
id: Uuid,
reference_number: String,
user_id: Uuid,
#[allow(dead_code)]
2026-06-26 21:07:36 +02:00
package_id: Option<Uuid>,
tracecoins_credited: Option<i32>,
2026-06-26 20:47:31 +02:00
amount_inr: i32,
status: String,
payu_mihpayid: Option<String>,
2026-06-26 20:47:31 +02:00
}
2026-06-26 21:07:36 +02:00
#[derive(Debug, Deserialize)]
struct StoredGatewayConfig {
mode: Option<String>,
base_url: Option<String>,
merchant_id: Option<String>,
api_key: Option<String>,
secret_key: Option<String>,
}
#[derive(Debug, FromRow)]
struct PaymentGatewayConfigRow {
#[allow(dead_code)]
2026-06-26 21:07:36 +02:00
display_name: Option<String>,
config_json: Option<serde_json::Value>,
is_active: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct PayuConfig {
pub(crate) key: String,
pub(crate) salt: String,
pub(crate) base_url: String,
2026-06-26 21:07:36 +02:00
}
2026-06-26 20:47:31 +02:00
fn error_response(status: StatusCode, message: impl Into<String>) -> (StatusCode, String) {
(status, message.into())
}
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.
2026-07-21 03:02:47 +05:30
/// 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,
// payments.amount_inr is already paise (copied straight from
// pricing_packages.price_inr, itself paise despite the name — see
// payu::paise_to_rupee_string dividing by 100), not rupees.
unit_price_paise: payment.amount_inr as i64,
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.
2026-07-21 03:02:47 +05:30
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()
.take(40)
.collect()
}
/// Resolves the PayU merchant key/salt from the admin-configured gateway settings,
/// falling back to the deployment's PAYU_MERCHANT_KEY / PAYU_SALT env vars.
pub(crate) async fn resolve_payu_config(state: &AppState) -> Result<PayuConfig, (StatusCode, String)> {
2026-06-26 21:07:36 +02:00
let row = sqlx::query_as::<_, PaymentGatewayConfigRow>(
r#"
SELECT display_name, config_json, is_active
FROM payment_gateway_configs
WHERE gateway_key = 'PAYU'
2026-06-26 21:07:36 +02:00
ORDER BY is_active DESC, created_at DESC
LIMIT 1
"#,
)
.fetch_optional(&state.pool)
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let mut key = state.payu.merchant_key.clone();
let mut salt = state.payu.merchant_salt.clone();
let mut base_url = state.payu.base_url.clone();
2026-06-26 21:07:36 +02:00
let mut mode = "sandbox".to_string();
let mut enabled = true;
if let Some(row) = row {
enabled = row.is_active;
if let Some(config_json) = row.config_json {
if let Ok(stored) = serde_json::from_value::<StoredGatewayConfig>(config_json) {
// merchant_id / api_key hold the PayU merchant key; secret_key holds the PayU salt.
if let Some(value) = stored
.api_key
.filter(|v| !v.trim().is_empty())
.or_else(|| stored.merchant_id.filter(|v| !v.trim().is_empty()))
{
key = value;
2026-06-26 21:07:36 +02:00
}
if let Some(value) = stored.secret_key.filter(|v| !v.trim().is_empty()) {
salt = value;
2026-06-26 21:07:36 +02:00
}
if let Some(value) = stored.mode.filter(|v| !v.trim().is_empty()) {
mode = value;
}
if let Some(value) = stored.base_url.filter(|v| !v.trim().is_empty()) {
base_url = value;
}
2026-06-26 21:07:36 +02:00
}
}
}
if mode.eq_ignore_ascii_case("live") && base_url == "https://test.payu.in" {
base_url = "https://secure.payu.in".to_string();
}
2026-06-26 21:07:36 +02:00
if !enabled {
return Err(error_response(
StatusCode::SERVICE_UNAVAILABLE,
"PayU gateway is disabled",
2026-06-26 21:07:36 +02:00
));
}
if key.trim().is_empty() || salt.trim().is_empty() {
2026-06-26 21:07:36 +02:00
return Err(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"PayU credentials are not configured",
2026-06-26 21:07:36 +02:00
));
}
Ok(PayuConfig { key, salt, base_url })
}
fn sha512_hex(raw: &str) -> String {
let digest = Sha512::digest(raw.as_bytes());
hex::encode(digest)
}
/// PayU's documented request hash: sha512(key|txnid|amount|productinfo|firstname|email|udf1..udf5|||||SALT)
#[allow(clippy::too_many_arguments)]
pub(crate) fn payu_request_hash(
key: &str,
txnid: &str,
amount: &str,
productinfo: &str,
firstname: &str,
email: &str,
udf1: &str,
udf2: &str,
udf3: &str,
udf4: &str,
udf5: &str,
salt: &str,
) -> String {
let raw = format!(
"{key}|{txnid}|{amount}|{productinfo}|{firstname}|{email}|{udf1}|{udf2}|{udf3}|{udf4}|{udf5}||||||{salt}"
);
sha512_hex(&raw)
}
/// PayU's documented response hash: sha512(SALT|status|||||udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key)
#[allow(clippy::too_many_arguments)]
pub(crate) fn payu_response_hash(
salt: &str,
status: &str,
udf1: &str,
udf2: &str,
udf3: &str,
udf4: &str,
udf5: &str,
email: &str,
firstname: &str,
productinfo: &str,
amount: &str,
txnid: &str,
key: &str,
) -> String {
let raw = format!(
"{salt}|{status}||||||{udf5}|{udf4}|{udf3}|{udf2}|{udf1}|{email}|{firstname}|{productinfo}|{amount}|{txnid}|{key}"
);
sha512_hex(&raw)
2026-06-26 21:07:36 +02:00
}
async fn create_order(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateOrderRequest>,
) -> Result<Json<CreateOrderResponse>, (StatusCode, String)> {
let package_id_str = payload.package_id.as_ref().ok_or((StatusCode::BAD_REQUEST, "package_id is required".to_string()))?;
let package_id = Uuid::parse_str(package_id_str).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid package id".to_string()))?;
let package = sqlx::query_as::<_, PricingPackageRow>(
"SELECT tracecoins_amount, price_inr, name FROM pricing_packages WHERE id = $1 AND is_active = true",
)
.bind(package_id)
.fetch_optional(&state.pool)
.await
2026-06-26 20:47:31 +02:00
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
2026-06-26 20:47:31 +02:00
let package = package.ok_or_else(|| {
error_response(StatusCode::BAD_REQUEST, "Invalid or inactive package")
})?;
let tracecoins_credited = package.tracecoins_amount;
tracing::info!("Creating PayU order for package {} (₹{})", package_id, payu::paise_to_rupee_string(package.price_inr));
2026-06-26 20:47:31 +02:00
let contact = sqlx::query_as::<_, UserContactRow>(
"SELECT email, full_name, phone FROM users WHERE id = $1",
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let firstname = contact
.full_name
.as_deref()
.and_then(|n| n.split_whitespace().next())
.unwrap_or("Customer")
.to_string();
let phone = contact.phone.unwrap_or_default();
let txnid = payu::generate_txnid();
// Price is derived from pricing_packages.price_inr (server-side truth),
// never from the client-supplied `payload.amount` -- see CreateOrderRequest.
let amount_str = payu::paise_to_rupee_string(package.price_inr);
let productinfo = package.name.clone();
let udf1 = package_id_str.clone();
let udf2 = String::new();
let hash = payu::request_hash(
&state.payu,
&txnid,
&amount_str,
&productinfo,
&firstname,
&contact.email,
&udf1,
&udf2,
);
sqlx::query(
r#"
INSERT INTO payments (user_id, package_id, payu_txnid, amount_inr, tracecoins_credited, status)
VALUES ($1, $2, $3, $4, $5, 'PENDING')
"#,
)
.bind(auth.user_id)
.bind(package_id)
.bind(&txnid)
.bind(package.price_inr)
.bind(tracecoins_credited)
.execute(&state.pool)
.await
2026-06-26 20:47:31 +02:00
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(CreateOrderResponse {
key: state.payu.merchant_key.clone(),
txnid: txnid.clone(),
amount: amount_str,
productinfo,
firstname,
email: contact.email,
phone,
surl: state.payu.surl.clone(),
furl: state.payu.furl.clone(),
hash,
payu_base_url: state.payu.base_url.clone(),
udf1,
udf2,
order_id: txnid,
currency: "INR".to_string(),
status: "created".to_string(),
}))
}
async fn verify_payment(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<VerifyPaymentRequest>,
) -> Result<Json<VerifyPaymentResponse>, (StatusCode, String)> {
tracing::info!("Verifying PayU payment: txnid={}", payload.txnid);
if !payu::verify_response_hash(
&state.payu,
&payload.status,
&payload.txnid,
&payload.amount,
&payload.productinfo,
&payload.firstname,
&payload.email,
payload.udf1.as_deref().unwrap_or(""),
payload.udf2.as_deref().unwrap_or(""),
&payload.hash,
) {
return Err((StatusCode::BAD_REQUEST, "Payment hash verification failed".to_string()));
}
if !payload.status.eq_ignore_ascii_case("success") {
return Err((StatusCode::BAD_REQUEST, "Payment was not successful".to_string()));
}
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
// The whole claim-and-credit sequence runs in one transaction with the
// payments row locked via SELECT ... FOR UPDATE. Without this, two
// concurrent calls with the same (replayed) valid PayU success payload
// could both observe status = 'PENDING' before either commits its
// UPDATE, and both would credit the wallet — a double-credit exploit a
// user fully controls, since this endpoint is called directly by the
// client after PayU redirects back, not by a server-to-server webhook.
let mut tx = state
.pool
.begin()
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let payment = sqlx::query_as::<_, PaymentRow>(
r#"
SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
FROM payments
WHERE payu_txnid = $1 AND status = 'PENDING'
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
FOR UPDATE
"#,
)
.bind(&payload.txnid)
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
.fetch_optional(&mut *tx)
.await
2026-06-26 20:47:31 +02:00
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let payment = match payment {
2026-06-26 20:47:31 +02:00
Some(payment) => payment,
None => {
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
let _ = tx.rollback().await;
2026-06-26 20:47:31 +02:00
return Err(error_response(
StatusCode::NOT_FOUND,
"Payment not found or already processed",
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
));
2026-06-26 20:47:31 +02:00
}
};
if payment.user_id != auth.user_id {
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
let _ = tx.rollback().await;
2026-06-26 20:47:31 +02:00
return Err(error_response(
StatusCode::FORBIDDEN,
"Payment does not belong to user",
));
}
if !payload.status.eq_ignore_ascii_case("success") {
sqlx::query("UPDATE payments SET status = 'FAILED', payu_mihpayid = $1 WHERE id = $2")
.bind(&payload.mihpayid)
.bind(payment.id)
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
.execute(&mut *tx)
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
tx.commit()
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
return Ok(Json(VerifyPaymentResponse {
verified: false,
payment_id: payload.mihpayid,
reference_number: Some(payment.reference_number.clone()),
status: "failed".to_string(),
message: "PayU reported a non-success status.".to_string(),
}));
}
let tracecoins = payment.tracecoins_credited.unwrap_or(0);
sqlx::query(
r#"
UPDATE payments SET
status = 'SUCCESS',
payu_mihpayid = $1,
verified_at = NOW()
WHERE id = $2
"#,
)
.bind(&payload.mihpayid)
.bind(payment.id)
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
.execute(&mut *tx)
.await
2026-06-26 20:47:31 +02:00
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
let wallet_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
VALUES ($1, $2, 0)
ON CONFLICT (user_id) DO UPDATE SET
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
balance = tracecoin_wallets.balance + excluded.balance,
updated_at = NOW()
RETURNING id
"#,
)
.bind(payment.user_id)
.bind(tracecoins as i64)
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
.fetch_one(&mut *tx)
.await
2026-06-26 20:47:31 +02:00
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
"#,
)
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
.bind(wallet_id)
.bind(tracecoins as i64)
.bind(payment.id)
.execute(&mut *tx)
.await
fix: close two real money/spend races (Tracecoin double-credit, unbounded AI overspend) Asked to review Tracecoin and AI implementation safety. Found and fixed two exploitable TOCTOU races, plus a data-integrity bug: 1. apps/payments/src/main.rs::verify_payment — the PayU success callback is called directly by the client (not a server-to-server webhook), so a user fully controls how many times they replay a valid success payload. The payment "is it still PENDING" check and the "mark SUCCESS + credit wallet" write were separate, non-transactional queries — concurrent replays could both pass the check before either commits, double- (or N-times-) crediting the wallet for one real payment. Now wrapped in a single transaction with `SELECT ... FOR UPDATE` on the payments row, so a second concurrent call blocks until the first commits, then correctly sees the row is no longer PENDING (Postgres re-evaluates the WHERE clause via EvalPlanQual after the lock is granted). 2. crates/db/src/models/ai/repository.rs — UserAiSubscriptionRepository had the exact same shape of bug: apps/users/src/ai/credits.rs:: charge_feature read the subscription, checked daily-limit and credit balance, THEN issued two separate unconditional `UPDATE ... SET x = x + $1` statements with no WHERE guard on the balance. N concurrent requests from one user all pass the check before any deduction lands, running up unlimited LLM API spend (this endpoint is called before/around real LiteLLM calls, so the cost is real). Added UserAiSubscriptionRepository::try_charge — a single conditional UPDATE that checks the daily limit and credit balance and deducts atomically, returning None (mapped to the existing error types) if either check fails. 3. apps/cron/src/tasks/auto_apply.rs — daily_actions_used was being incremented twice per auto-applied job (once in the credit-deduct UPDATE, once more in a second, redundant UPDATE right after) — silently halving job seekers' effective daily auto-apply limit. Removed the redundant second UPDATE. Also added non-negative CHECK constraints directly to the live database (tracecoin_wallets.balance/reserved, user_ai_subscriptions.daily_actions_used/monthly_credits_used/ purchased_credits_used) as defense in depth — belt-and-suspenders in case a future code path reintroduces a similar bug.
2026-07-21 05:51:23 +05:30
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
tx.commit()
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
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.
2026-07-21 03:02:47 +05:30
generate_purchase_invoice(&state.pool, &payment, &payload).await;
let _ = sqlx::query(
r#"
2026-06-09 22:52:30 +02:00
INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(payment.user_id)
.bind("Tracecoins Purchased Successfully")
2026-06-26 20:47:31 +02:00
.bind(format!(
"Your {} Tracecoin package has been credited to your wallet.",
tracecoins
))
.bind("PAYMENT")
.bind(payment.id)
.execute(&state.pool)
2026-06-26 20:47:31 +02:00
.await;
Ok(Json(VerifyPaymentResponse {
verified: true,
payment_id: payload.mihpayid,
reference_number: Some(payment.reference_number.clone()),
status: "success".to_string(),
message: "Payment verified successfully".to_string(),
}))
}
async fn get_payment_status(
2026-06-26 20:47:31 +02:00
auth: AuthUser,
State(state): State<AppState>,
2026-06-26 20:47:31 +02:00
Path(payment_id): Path<String>,
) -> Result<Json<PaymentStatusResponse>, (StatusCode, String)> {
2026-06-26 20:47:31 +02:00
let payment = sqlx::query_as::<_, PaymentRow>(
r#"
SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
2026-06-26 20:47:31 +02:00
FROM payments
WHERE payu_mihpayid = $1 OR payu_txnid = $1
2026-06-26 20:47:31 +02:00
ORDER BY created_at DESC
LIMIT 1
"#,
)
.bind(&payment_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
2026-06-26 20:47:31 +02:00
let payment = match payment {
Some(payment) => payment,
None => {
return Ok(Json(PaymentStatusResponse {
payment_id,
reference_number: None,
2026-06-26 20:47:31 +02:00
status: "not_found".to_string(),
amount: 0,
currency: "INR".to_string(),
}))
}
};
2026-06-26 20:47:31 +02:00
if payment.user_id != auth.user_id {
return Err(error_response(
StatusCode::FORBIDDEN,
"Payment does not belong to user",
));
}
Ok(Json(PaymentStatusResponse {
2026-06-26 20:47:31 +02:00
payment_id: payment
.payu_mihpayid
.clone()
2026-06-26 20:47:31 +02:00
.unwrap_or(payment_id),
reference_number: Some(payment.reference_number.clone()),
2026-06-26 20:47:31 +02:00
status: payment.status.to_lowercase(),
amount: payment.amount_inr as u64,
currency: "INR".to_string(),
}))
}
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.
2026-07-21 03:02:47 +05:30
#[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()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
2026-06-26 20:47:31 +02:00
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPool::connect(&db_url)
.await
.expect("Failed to connect to database");
let state = AppState {
pool,
payu: payu::PayuConfig::from_env(),
};
let app = Router::new()
.route("/api/payments/create-order", post(create_order))
.route("/api/payments/verify", post(verify_payment))
.route("/api/payments/{id}/status", get(get_payment_status))
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.
2026-07-21 03:02:47 +05:30
.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())
.with_state(state);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "9116".to_string())
.parse()
.expect("PORT must be a valid u16");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Payments service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}