use axum::{ extract::{Path, State}, http::StatusCode, routing::{get, post}, Json, Router, }; use contracts::auth_middleware::AuthUser; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha2::Sha256; 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; type HmacSha256 = Hmac; #[derive(Clone)] pub struct AppState { client: reqwest::Client, pool: PgPool, razorpay_key_id: String, razorpay_key_secret: String, } #[derive(Debug, Deserialize)] struct CreateOrderRequest { amount: u64, currency: Option, receipt: Option, package_id: Option, } #[derive(Debug, Serialize)] struct CreateOrderResponse { order_id: String, amount: u64, currency: String, status: String, } #[derive(Debug, Deserialize)] struct VerifyPaymentRequest { #[serde(alias = "razorpay_order_id")] order_id: Option, #[serde(alias = "razorpay_payment_id")] payment_id: Option, #[serde(alias = "razorpay_signature")] signature: Option, } #[derive(Debug, Serialize)] struct VerifyPaymentResponse { verified: bool, payment_id: String, status: String, message: String, } #[derive(Debug, Serialize)] struct PaymentStatusResponse { payment_id: String, status: String, amount: u64, currency: String, } #[derive(Debug, FromRow)] struct PricingPackageRow { tracecoins_amount: i32, price_inr: i32, } #[derive(Debug, FromRow)] struct PaymentRow { id: Uuid, user_id: Uuid, tracecoins_credited: Option, amount_inr: i32, status: String, razorpay_payment_id: Option, } #[derive(Debug, Serialize)] struct RazorpayOrderRequest { amount: u64, currency: String, receipt: String, notes: serde_json::Value, } #[derive(Debug, Deserialize)] struct RazorpayOrderResponse { id: String, amount: u64, currency: String, status: String, } fn error_response(status: StatusCode, message: impl Into) -> (StatusCode, String) { (status, message.into()) } fn build_receipt(receipt: Option) -> String { let candidate = receipt.unwrap_or_else(|| format!("tc_{}", Uuid::new_v4().simple())); candidate.chars().take(40).collect() } 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_else(|| error_response(StatusCode::BAD_REQUEST, "package_id is required"))?; let package_id = Uuid::parse_str(package_id_str) .map_err(|_| error_response(StatusCode::BAD_REQUEST, "Invalid package id"))?; if payload.amount < 100 { return Err(error_response( StatusCode::BAD_REQUEST, "Amount must be at least 100 paise", )); } let currency = payload.currency.unwrap_or_else(|| "INR".to_string()); let package = sqlx::query_as::<_, PricingPackageRow>( "SELECT tracecoins_amount, price_inr 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") })?; if payload.amount != package.price_inr as u64 { return Err(error_response( StatusCode::BAD_REQUEST, "Requested amount does not match package price", )); } let razorpay_request = RazorpayOrderRequest { amount: payload.amount, currency: currency.clone(), receipt: build_receipt(payload.receipt), notes: serde_json::json!({ "package_id": package_id_str, "user_id": auth.user_id.to_string(), }), }; let resp = state .client .post("https://api.razorpay.com/v1/orders") .basic_auth(&state.razorpay_key_id, Some(&state.razorpay_key_secret)) .json(&razorpay_request) .send() .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("Razorpay request failed: {e}")))?; let status = resp.status(); if status == StatusCode::UNAUTHORIZED { return Err(error_response( StatusCode::UNAUTHORIZED, "Razorpay authentication failed", )); } if !status.is_success() { let body = resp .text() .await .unwrap_or_else(|_| "Unable to read Razorpay error response".to_string()); return Err(error_response( StatusCode::INTERNAL_SERVER_ERROR, format!("Razorpay order creation failed: {body}"), )); } let razorpay_order = resp .json::() .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid Razorpay response: {e}")))?; sqlx::query( r#" INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status) VALUES ($1, $2, $3, $4, $5, 'PENDING') "#, ) .bind(auth.user_id) .bind(package_id) .bind(&razorpay_order.id) .bind(razorpay_order.amount as i64) .bind(package.tracecoins_amount) .execute(&state.pool) .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; Ok(Json(CreateOrderResponse { order_id: razorpay_order.id, amount: razorpay_order.amount, currency: razorpay_order.currency, status: razorpay_order.status, })) } async fn verify_payment( auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { let order_id = payload .order_id .ok_or_else(|| error_response(StatusCode::BAD_REQUEST, "order_id is required"))?; let payment_id = payload .payment_id .ok_or_else(|| error_response(StatusCode::BAD_REQUEST, "payment_id is required"))?; let signature = payload .signature .ok_or_else(|| error_response(StatusCode::BAD_REQUEST, "razorpay_signature is required"))?; let provided_signature = hex::decode(signature) .map_err(|_| error_response(StatusCode::BAD_REQUEST, "Invalid signature format"))?; let mut mac = HmacSha256::new_from_slice(state.razorpay_key_secret.as_bytes()) .map_err(|_| error_response(StatusCode::INTERNAL_SERVER_ERROR, "Unable to initialize signature verifier"))?; mac.update(format!("{order_id}|{payment_id}").as_bytes()); mac.verify_slice(&provided_signature) .map_err(|_| error_response(StatusCode::BAD_REQUEST, "Signature mismatch"))?; let payment = sqlx::query_as::<_, PaymentRow>( r#" SELECT id, user_id, tracecoins_credited, amount_inr, status, razorpay_payment_id FROM payments WHERE razorpay_order_id = $1 AND status = 'PENDING' "#, ) .bind(&order_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 Err(error_response( StatusCode::NOT_FOUND, "Payment not found or already processed", )) } }; if payment.user_id != auth.user_id { return Err(error_response( StatusCode::FORBIDDEN, "Payment does not belong to user", )); } let tracecoins = payment.tracecoins_credited.unwrap_or(0); sqlx::query( r#" UPDATE payments SET status = 'SUCCESS', razorpay_payment_id = $1, verified_at = NOW() WHERE id = $2 "#, ) .bind(&payment_id) .bind(payment.id) .execute(&state.pool) .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; sqlx::query( 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 "#, ) .bind(payment.user_id) .bind(tracecoins as i64) .execute(&state.pool) .await .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; if let Ok(Some(wallet_id)) = sqlx::query_scalar::<_, Uuid>( "SELECT id FROM tracecoin_wallets WHERE user_id = $1", ) .bind(payment.user_id) .fetch_optional(&state.pool) .await { let _ = 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(&state.pool) .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, 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, user_id, tracecoins_credited, amount_inr, status, razorpay_payment_id FROM payments WHERE razorpay_payment_id = $1 OR razorpay_order_id = $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, 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 .razorpay_payment_id .unwrap_or(payment_id), status: payment.status.to_lowercase(), amount: payment.amount_inr as u64, currency: "INR".to_string(), })) } #[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 { client: reqwest::Client::new(), pool, razorpay_key_id: std::env::var("RAZORPAY_KEY_ID") .expect("RAZORPAY_KEY_ID must be set"), razorpay_key_secret: std::env::var("RAZORPAY_KEY_SECRET") .expect("RAZORPAY_KEY_SECRET must be set"), }; 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)) .nest("/api/packages", packages::router()) .nest("/api/ai-credits", ai_credits::ai_credits_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(); }