// retrigger-build-marker-3 #![allow(dead_code)] use axum::{ extract::{Path, State}, http::StatusCode, routing::{get, post}, Json, Router, }; use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha512}; 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, } #[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, #[allow(dead_code)] currency: Option, #[allow(dead_code)] receipt: Option, package_id: Option, } #[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, } #[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, #[serde(default)] udf1: Option, #[serde(default)] udf2: Option, } #[derive(Debug, Serialize)] struct VerifyPaymentResponse { verified: bool, payment_id: String, #[serde(skip_serializing_if = "Option::is_none")] reference_number: Option, status: String, message: String, } #[derive(Debug, Serialize)] struct PaymentStatusResponse { payment_id: String, #[serde(skip_serializing_if = "Option::is_none")] reference_number: Option, status: String, amount: u64, currency: String, } #[derive(Debug, FromRow)] struct PricingPackageRow { name: String, tracecoins_amount: i32, price_inr: i32, } #[derive(Debug, FromRow)] struct UserContactRow { email: String, full_name: Option, phone: Option, } #[derive(Debug, FromRow)] struct PaymentRow { id: Uuid, reference_number: String, user_id: Uuid, #[allow(dead_code)] package_id: Option, tracecoins_credited: Option, amount_inr: i32, status: String, payu_mihpayid: Option, } #[derive(Debug, Deserialize)] struct StoredGatewayConfig { mode: Option, base_url: Option, merchant_id: Option, api_key: Option, secret_key: Option, } #[derive(Debug, FromRow)] struct PaymentGatewayConfigRow { #[allow(dead_code)] display_name: Option, config_json: Option, is_active: bool, } #[derive(Debug, Clone)] pub(crate) struct PayuConfig { pub(crate) key: String, pub(crate) salt: String, pub(crate) base_url: String, } fn error_response(status: StatusCode, message: impl Into) -> (StatusCode, String) { (status, message.into()) } /// Nxtgauge's own seller identity for GST invoicing — configurable via env /// since GSTIN/PAN/registered address are business details, not code. fn seller_details() -> invoice::SellerDetails { invoice::SellerDetails { name: std::env::var("INVOICE_SELLER_NAME").unwrap_or_else(|_| "Nxtgauge".to_string()), address: std::env::var("INVOICE_SELLER_ADDRESS") .unwrap_or_else(|_| "Registered address not configured".to_string()), gstin: std::env::var("INVOICE_SELLER_GSTIN").ok(), pan: std::env::var("INVOICE_SELLER_PAN").ok(), state_code: std::env::var("INVOICE_SELLER_STATE_CODE").unwrap_or_else(|_| "KA".to_string()), } } /// Generates a GST invoice for a successful Tracecoin/package purchase. /// Never blocks the payment response — invoice generation failures are /// logged, not surfaced to the buyer, since the payment itself already /// succeeded and the wallet was already credited by the time this runs. async fn generate_purchase_invoice( pool: &PgPool, payment: &PaymentRow, payload: &VerifyPaymentRequest, ) { let package_name: Option = sqlx::query_scalar("SELECT name FROM pricing_packages WHERE id = $1") .bind(payment.package_id) .fetch_optional(pool) .await .ok() .flatten(); let description = package_name.unwrap_or_else(|| "Tracecoin package purchase".to_string()); let line = invoice::LineItem { line_number: 1, description, hsn_sac_code: None, quantity: 1.0, // 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, 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 { let row = sqlx::query_as::<_, PaymentGatewayConfigRow>( r#" SELECT display_name, config_json, is_active FROM payment_gateway_configs WHERE gateway_key = 'PAYU' 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(); 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::(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; } if let Some(value) = stored.secret_key.filter(|v| !v.trim().is_empty()) { salt = value; } 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; } } } } if mode.eq_ignore_ascii_case("live") && base_url == "https://test.payu.in" { base_url = "https://secure.payu.in".to_string(); } if !enabled { return Err(error_response( StatusCode::SERVICE_UNAVAILABLE, "PayU gateway is disabled", )); } if key.trim().is_empty() || salt.trim().is_empty() { return Err(error_response( StatusCode::INTERNAL_SERVER_ERROR, "PayU credentials are not configured", )); } 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) } async fn create_order( auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result, (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 .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; 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)); 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 .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, Json(payload): Json, ) -> Result, (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())); } // 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' FOR UPDATE "#, ) .bind(&payload.txnid) .fetch_optional(&mut *tx) .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; let payment = match payment { Some(payment) => payment, None => { let _ = tx.rollback().await; return Err(error_response( StatusCode::NOT_FOUND, "Payment not found or already processed", )); } }; if payment.user_id != auth.user_id { let _ = tx.rollback().await; 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) .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) .execute(&mut *tx) .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; 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 balance = tracecoin_wallets.balance + excluded.balance, updated_at = NOW() RETURNING id "#, ) .bind(payment.user_id) .bind(tracecoins as i64) .fetch_one(&mut *tx) .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; sqlx::query( r#" INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id) VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3) "#, ) .bind(wallet_id) .bind(tracecoins as i64) .bind(payment.id) .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}")))?; generate_purchase_invoice(&state.pool, &payment, &payload).await; let _ = sqlx::query( r#" INSERT INTO notifications (user_id, title, body, type, reference_id) VALUES ($1, $2, $3, $4, $5) "#, ) .bind(payment.user_id) .bind("Tracecoins Purchased Successfully") .bind(format!( "Your {} Tracecoin package has been credited to your wallet.", tracecoins )) .bind("PAYMENT") .bind(payment.id) .execute(&state.pool) .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( auth: AuthUser, State(state): State, Path(payment_id): Path, ) -> Result, (StatusCode, String)> { 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_mihpayid = $1 OR payu_txnid = $1 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}")))?; let payment = match payment { Some(payment) => payment, None => { return Ok(Json(PaymentStatusResponse { payment_id, reference_number: None, status: "not_found".to_string(), amount: 0, currency: "INR".to_string(), })) } }; if payment.user_id != auth.user_id { return Err(error_response( StatusCode::FORBIDDEN, "Payment does not belong to user", )); } Ok(Json(PaymentStatusResponse { payment_id: payment .payu_mihpayid .clone() .unwrap_or(payment_id), reference_number: Some(payment.reference_number.clone()), status: payment.status.to_lowercase(), amount: payment.amount_inr as u64, currency: "INR".to_string(), })) } #[derive(Debug, serde::Deserialize)] struct ListInvoicesQuery { page: Option, limit: Option, } async fn list_my_invoices( auth: AuthUser, State(state): State, axum::extract::Query(q): axum::extract::Query, ) -> Result, (StatusCode, String)> { let page = q.page.unwrap_or(1); let limit = q.limit.unwrap_or(20); let invoices = invoice::service::InvoiceService::list_for_user(&state.pool, auth.user_id, page, limit) .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?; Ok(Json(serde_json::json!({ "data": invoices, "pagination": { "page": page, "limit": limit } }))) } async fn get_my_invoice( auth: AuthUser, State(state): State, Path(id): Path, ) -> Result, (StatusCode, String)> { let inv = invoice::service::InvoiceService::get(&state.pool, id) .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))? .ok_or_else(|| error_response(StatusCode::NOT_FOUND, "Invoice not found"))?; if inv.user_id != auth.user_id { return Err(error_response(StatusCode::FORBIDDEN, "Invoice does not belong to user")); } let lines = invoice::service::InvoiceService::list_line_items(&state.pool, id) .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?; Ok(Json(serde_json::json!({ "invoice": inv, "line_items": lines }))) } #[tokio::main] async fn main() { tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), )) .with(tracing_subscriber::fmt::layer()) .init(); 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)) .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(); }