nxtgauge-backend-rust/apps/payments/src/main.rs
Ashwin Kumar Sivakumar 3e701f2fe6
Some checks failed
build-and-release / build (ugc-content-creators) (push) Waiting to run
build-and-release / build (users) (push) Waiting to run
build-and-release / build (video-editors) (push) Waiting to run
build-and-release / build (catering-services) (push) Successful in 1m47s
build-and-release / build (companies) (push) Successful in 2m0s
build-and-release / build (cron) (push) Successful in 2m12s
build-and-release / build (customers) (push) Successful in 2m42s
build-and-release / build (gateway) (push) Successful in 1m0s
build-and-release / build (developers) (push) Successful in 1m27s
build-and-release / build (fitness-trainers) (push) Successful in 1m54s
build-and-release / build (employees) (push) Successful in 1m59s
build-and-release / build (job-seekers) (push) Successful in 1m57s
build-and-release / build (makeup-artists) (push) Successful in 1m39s
build-and-release / build (graphic-designers) (push) Has been cancelled
build-and-release / build (payments) (push) Has been cancelled
build-and-release / build (jobs) (push) Has been cancelled
build-and-release / build (social-media-managers) (push) Has been cancelled
build-and-release / build (tutors) (push) Has been cancelled
build-and-release / build (photographers) (push) Has been cancelled
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

762 lines
24 KiB
Rust

// 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<u64>,
#[allow(dead_code)]
currency: Option<String>,
#[allow(dead_code)]
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,
}
#[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,
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)]
package_id: Option<Uuid>,
tracecoins_credited: Option<i32>,
amount_inr: i32,
status: String,
payu_mihpayid: Option<String>,
}
#[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)]
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,
}
fn error_response(status: StatusCode, message: impl Into<String>) -> (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<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,
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)> {
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::<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;
}
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<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
.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<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()));
}
// 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<AppState>,
Path(payment_id): Path<String>,
) -> Result<Json<PaymentStatusResponse>, (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<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();
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();
}