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
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.
762 lines
24 KiB
Rust
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();
|
|
}
|