From cd3fbfe7caaea42a438fea484eafcd1d85d16f17 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 26 Jun 2026 20:47:31 +0200 Subject: [PATCH 1/2] Integrate Razorpay checkout payments --- Cargo.lock | 3 + apps/payments/Cargo.toml | 3 + apps/payments/src/main.rs | 343 ++++++++++++++++++++++---------------- 3 files changed, 206 insertions(+), 143 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbd9586..90ac713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2679,9 +2679,12 @@ dependencies = [ "axum", "chrono", "contracts", + "hex", + "hmac 0.12.1", "reqwest", "serde", "serde_json", + "sha2 0.10.9", "sqlx", "tokio", "tracing", diff --git a/apps/payments/Cargo.toml b/apps/payments/Cargo.toml index 86cf510..29a9314 100644 --- a/apps/payments/Cargo.toml +++ b/apps/payments/Cargo.toml @@ -16,3 +16,6 @@ contracts = { path = "../../crates/contracts" } sqlx.workspace = true uuid.workspace = true chrono.workspace = true +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index b74d9cf..93285c4 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -1,33 +1,37 @@ use axum::{ - extract::State, + 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; -use sqlx::postgres::PgPool; -use sqlx::FromRow; pub mod ai_credits; pub mod packages; +type HmacSha256 = Hmac; + #[derive(Clone)] pub struct AppState { - beeceptor_url: String, client: reqwest::Client, pool: PgPool, + razorpay_key_id: String, + razorpay_key_secret: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Deserialize)] struct CreateOrderRequest { amount: u64, currency: Option, + receipt: Option, package_id: Option, - user_id: Option, } #[derive(Debug, Serialize)] @@ -38,10 +42,13 @@ struct CreateOrderResponse { status: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Deserialize)] struct VerifyPaymentRequest { - order_id: String, - payment_id: String, + #[serde(alias = "razorpay_order_id")] + order_id: Option, + #[serde(alias = "razorpay_payment_id")] + payment_id: Option, + #[serde(alias = "razorpay_signature")] signature: Option, } @@ -64,15 +71,42 @@ struct PaymentStatusResponse { #[derive(Debug, FromRow)] struct PricingPackageRow { tracecoins_amount: i32, + price_inr: i32, } #[derive(Debug, FromRow)] -#[allow(dead_code)] struct PaymentRow { id: Uuid, user_id: Uuid, - package_id: Option, 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( @@ -80,57 +114,82 @@ async fn create_order( State(state): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - tracing::info!("Creating payment order: amount={}", payload.amount); + 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"))?; - 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()))?; + 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 FROM pricing_packages WHERE id = $1 AND is_active = true", + "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| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - let package = package.ok_or((StatusCode::BAD_REQUEST, "Invalid or inactive package".to_string()))?; - let tracecoins_credited = package.tracecoins_amount; + let package = package.ok_or_else(|| { + error_response(StatusCode::BAD_REQUEST, "Invalid or inactive package") + })?; - let resp = state - .client - .post(&state.beeceptor_url) - .header("Content-Type", "application/json") - .json(&serde_json::json!({ - "amount": payload.amount, - "currency": payload.currency.as_deref().unwrap_or("INR"), - "package_id": package_id_str, - "user_id": auth.user_id.to_string(), - })) - .send() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Beeceptor error: {}", e)))?; - - let status = resp.status(); - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)))?; - - if !status.is_success() { - return Err(( + if payload.amount != package.price_inr as u64 { + return Err(error_response( StatusCode::BAD_REQUEST, - body.get("message") - .and_then(|m| m.as_str()) - .unwrap_or("Order creation failed") - .to_string(), + "Requested amount does not match package price", )); } - let order_id = body - .get("order_id") - .and_then(|v| v.as_str()) - .unwrap_or("mock_order_123") - .to_string(); + 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#" @@ -140,18 +199,18 @@ async fn create_order( ) .bind(auth.user_id) .bind(package_id) - .bind(&order_id) - .bind(payload.amount as i64) - .bind(tracecoins_credited) + .bind(&razorpay_order.id) + .bind(razorpay_order.amount as i64) + .bind(package.tracecoins_amount) .execute(&state.pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; Ok(Json(CreateOrderResponse { - order_id, - amount: payload.amount, - currency: payload.currency.unwrap_or("INR".to_string()), - status: "created".to_string(), + order_id: razorpay_order.id, + amount: razorpay_order.amount, + currency: razorpay_order.currency, + status: razorpay_order.status, })) } @@ -160,53 +219,52 @@ async fn verify_payment( State(state): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - tracing::info!("Verifying payment: order_id={}", payload.order_id); + 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 verify_url = format!("{}/verify", state.beeceptor_url.trim_end_matches('/')); - let resp = state - .client - .post(&verify_url) - .header("Content-Type", "application/json") - .json(&payload) - .send() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Beeceptor error: {}", e)))?; + let provided_signature = hex::decode(signature) + .map_err(|_| error_response(StatusCode::BAD_REQUEST, "Invalid signature format"))?; - let status = resp.status(); - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)))?; - - if !status.is_success() { - return Err(( - StatusCode::BAD_REQUEST, - body.get("message") - .and_then(|m| m.as_str()) - .unwrap_or("Verification failed") - .to_string(), - )); - } + 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, package_id, tracecoins_credited + SELECT id, user_id, tracecoins_credited, amount_inr, status, razorpay_payment_id FROM payments WHERE razorpay_order_id = $1 AND status = 'PENDING' "#, ) - .bind(&payload.order_id) + .bind(&order_id) .fetch_optional(&state.pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; let payment = match payment { - Some(p) => p, - None => return Err((StatusCode::NOT_FOUND, "Payment not found or already processed".to_string())), + 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((StatusCode::FORBIDDEN, "Payment does not belong to user".to_string())); + return Err(error_response( + StatusCode::FORBIDDEN, + "Payment does not belong to user", + )); } let tracecoins = payment.tracecoins_credited.unwrap_or(0); @@ -220,11 +278,11 @@ async fn verify_payment( WHERE id = $2 "#, ) - .bind(&payload.payment_id) + .bind(&payment_id) .bind(payment.id) .execute(&state.pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; sqlx::query( r#" @@ -238,16 +296,16 @@ async fn verify_payment( .bind(tracecoins as i64) .execute(&state.pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + .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" + "SELECT id FROM tracecoin_wallets WHERE user_id = $1", ) .bind(payment.user_id) .fetch_optional(&state.pool) .await { - sqlx::query( + let _ = sqlx::query( r#" INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id) VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3) @@ -257,8 +315,7 @@ async fn verify_payment( .bind(tracecoins as i64) .bind(payment.id) .execute(&state.pool) - .await - .ok(); + .await; } let _ = sqlx::query( @@ -269,67 +326,68 @@ async fn verify_payment( ) .bind(payment.user_id) .bind("Tracecoins Purchased Successfully") - .bind(format!("Your {} Tracecoin package has been credited to your wallet.", tracecoins)) + .bind(format!( + "Your {} Tracecoin package has been credited to your wallet.", + tracecoins + )) .bind("PAYMENT") .bind(payment.id) .execute(&state.pool) - .await - .ok(); + .await; Ok(Json(VerifyPaymentResponse { verified: true, - payment_id: payload.payment_id, + payment_id, status: "success".to_string(), message: "Payment verified successfully".to_string(), })) } async fn get_payment_status( + auth: AuthUser, State(state): State, - axum::extract::Path(payment_id): axum::extract::Path, + Path(payment_id): Path, ) -> Result, (StatusCode, String)> { - tracing::info!("Getting payment status: payment_id={}", payment_id); + 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 status_url = format!("{}/{}", state.beeceptor_url.trim_end_matches('/'), payment_id); - let resp = state - .client - .get(&status_url) - .send() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Beeceptor 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(), + })) + } + }; - let status = resp.status(); - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)))?; - - if !status.is_success() { - 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", + )); } - let amount = body.get("amount").and_then(|v| v.as_u64()).unwrap_or(0); - let currency = body - .get("currency") - .and_then(|v| v.as_str()) - .unwrap_or("INR") - .to_string(); - let status_str = body - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - Ok(Json(PaymentStatusResponse { - payment_id, - status: status_str, - amount, - currency, + 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(), })) } @@ -342,19 +400,18 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .init(); - let beeceptor_url = std::env::var("BEECEPTOR_URL") - .expect("BEECEPTOR_URL must be set"); - - let db_url = std::env::var("DATABASE_URL") - .expect("DATABASE_URL must be set"); + 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 { - beeceptor_url, 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() From 508242727996f205121b6759170a78fcc2851b5d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 26 Jun 2026 21:07:36 +0200 Subject: [PATCH 2/2] Wire admin Razorpay gateway config --- apps/payments/src/ai_credits.rs | 141 +++++++----- apps/payments/src/main.rs | 113 +++++++++- apps/users/src/handlers/mod.rs | 1 + apps/users/src/handlers/payment_gateway.rs | 241 +++++++++++++++++++++ apps/users/src/main.rs | 1 + 5 files changed, 431 insertions(+), 66 deletions(-) create mode 100644 apps/users/src/handlers/payment_gateway.rs diff --git a/apps/payments/src/ai_credits.rs b/apps/payments/src/ai_credits.rs index b2e633a..e372b54 100644 --- a/apps/payments/src/ai_credits.rs +++ b/apps/payments/src/ai_credits.rs @@ -7,6 +7,7 @@ use axum::{ Json, Router, }; use contracts::auth_middleware::AuthUser; +use hmac::Mac; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; @@ -70,13 +71,13 @@ async fn create_ai_credit_order( Json(payload): Json, ) -> impl IntoResponse { let package = match sqlx::query_as::<_, AiCreditPackageRow>( - "SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = true" + "SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = true", ) .bind(payload.package_id) .fetch_optional(&state.pool) .await { - Ok(Some(p)) => p, + Ok(Some(package)) => package, Ok(None) => { return ( StatusCode::BAD_REQUEST, @@ -94,26 +95,35 @@ async fn create_ai_credit_order( } }; - let resp = state + let gateway_config = match crate::resolve_razorpay_config(&state).await { + Ok(config) => config, + Err((status, message)) => { + return (status, Json(serde_json::json!({ "error": message }))).into_response(); + } + }; + + let resp = match state .client - .post(&state.beeceptor_url) - .header("Content-Type", "application/json") + .post(format!("{}/v1/orders", gateway_config.base_url.trim_end_matches('/'))) + .basic_auth(&gateway_config.key_id, Some(&gateway_config.key_secret)) .json(&serde_json::json!({ - "amount": package.price_inr * 100, + "amount": package.price_inr, "currency": "INR", - "package_id": package.id.to_string(), - "user_id": auth.user_id.to_string(), - "package_type": "AI_CREDITS" + "receipt": format!("ai_{}", Uuid::new_v4().simple()), + "notes": { + "package_id": package.id.to_string(), + "user_id": auth.user_id.to_string(), + "package_type": "AI_CREDITS" + } })) .send() - .await; - - let resp = match resp { - Ok(r) => r, + .await + { + Ok(resp) => resp, Err(e) => { - tracing::error!("Beeceptor error creating AI credit order: {}", e); + tracing::error!("Razorpay error creating AI credit order: {}", e); return ( - StatusCode::BAD_GATEWAY, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Payment gateway error" })), ) .into_response(); @@ -122,9 +132,9 @@ async fn create_ai_credit_order( let status = resp.status(); let body: serde_json::Value = match resp.json().await { - Ok(b) => b, + Ok(body) => body, Err(e) => { - tracing::error!("Failed to parse Beeceptor response: {}", e); + tracing::error!("Failed to parse Razorpay order response: {}", e); return ( StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Invalid payment gateway response" })), @@ -133,9 +143,17 @@ async fn create_ai_credit_order( } }; + if status == StatusCode::UNAUTHORIZED { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "Razorpay authentication failed" })), + ) + .into_response(); + } + if !status.is_success() { return ( - StatusCode::BAD_REQUEST, + StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": body.get("message").and_then(|m| m.as_str()).unwrap_or("Order creation failed") })), @@ -144,9 +162,10 @@ async fn create_ai_credit_order( } let order_id = body - .get("order_id") + .get("id") + .or_else(|| body.get("order_id")) .and_then(|v| v.as_str()) - .unwrap_or("mock_ai_credit_order") + .unwrap_or_default() .to_string(); if let Err(e) = sqlx::query( @@ -169,10 +188,10 @@ async fn create_ai_credit_order( StatusCode::OK, Json(serde_json::json!({ "order_id": order_id, - "amount": package.price_inr * 100, + "amount": package.price_inr, "currency": "INR", "credits": package.credits, - "status": "created" + "status": body.get("status").and_then(|v| v.as_str()).unwrap_or("created"), })), ) .into_response() @@ -183,52 +202,57 @@ async fn verify_ai_credit_order( State(state): State, Json(payload): Json, ) -> impl IntoResponse { - let verify_url = format!("{}/verify", state.beeceptor_url.trim_end_matches('/')); - let resp = match state - .client - .post(&verify_url) - .header("Content-Type", "application/json") - .json(&payload) - .send() - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!("Beeceptor verify error: {}", e); + let gateway_config = match crate::resolve_razorpay_config(&state).await { + Ok(config) => config, + Err((status, message)) => { + return (status, Json(serde_json::json!({ "error": message }))).into_response(); + } + }; + + let signature = match payload.signature.clone() { + Some(signature) => signature, + None => { return ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({ "error": "Payment gateway error" })), + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "razorpay_signature is required" })), ) .into_response(); } }; - let status = resp.status(); - let body: serde_json::Value = match resp.json().await { - Ok(b) => b, - Err(e) => { - tracing::error!("Failed to parse verify response: {}", e); + let provided_signature = match hex::decode(signature) { + Ok(sig) => sig, + Err(_) => { return ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({ "error": "Invalid payment gateway response" })), + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid signature format" })), ) .into_response(); } }; - if !status.is_success() { + let mut mac = match hmac::Hmac::::new_from_slice(gateway_config.key_secret.as_bytes()) { + Ok(mac) => mac, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Unable to initialize signature verifier" })), + ) + .into_response(); + } + }; + mac.update(format!("{}|{}", payload.order_id, payload.payment_id).as_bytes()); + if mac.verify_slice(&provided_signature).is_err() { return ( StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": body.get("message").and_then(|m| m.as_str()).unwrap_or("Verification failed") - })), + Json(serde_json::json!({ "error": "Signature mismatch" })), ) .into_response(); } let payment = match sqlx::query_as::<_, crate::PaymentRow>( r#" - SELECT id, user_id, package_id, tracecoins_credited + SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, razorpay_payment_id FROM payments WHERE razorpay_order_id = $1 AND status = 'PENDING' "#, @@ -237,7 +261,7 @@ async fn verify_ai_credit_order( .fetch_optional(&state.pool) .await { - Ok(Some(p)) => p, + Ok(Some(payment)) => payment, Ok(None) => { return ( StatusCode::NOT_FOUND, @@ -264,13 +288,13 @@ async fn verify_ai_credit_order( } let package = match sqlx::query_as::<_, AiCreditPackageRow>( - "SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = true" + "SELECT id, name, description, credits, price_inr FROM ai_credit_packages WHERE id = $1 AND is_active = true", ) .bind(payment.package_id.unwrap_or_default()) .fetch_optional(&state.pool) .await { - Ok(Some(p)) => p, + Ok(Some(package)) => package, _ => { return ( StatusCode::BAD_REQUEST, @@ -297,12 +321,15 @@ async fn verify_ai_credit_order( tracing::error!("Failed to update payment status: {}", e); } - // Credit the user via the users service admin endpoint. - let users_service_url = std::env::var("USERS_SERVICE_URL") - .unwrap_or_else(|_| "http://nxtgauge-rust-users:9101".to_string()); - let credit_url = format!("{}/api/admin/ai/users/{}/credits", users_service_url.trim_end_matches('/'), auth.user_id); + let users_service_url = + std::env::var("USERS_SERVICE_URL").unwrap_or_else(|_| "http://nxtgauge-rust-users:9101".to_string()); + let credit_url = format!( + "{}/api/admin/ai/users/{}/credits", + users_service_url.trim_end_matches('/'), + auth.user_id + ); let admin_token = match std::env::var("AI_CREDIT_ADMIN_TOKEN") { - Ok(t) => t, + Ok(token) => token, Err(_) => { tracing::error!("AI_CREDIT_ADMIN_TOKEN not set; cannot credit AI credits automatically"); return ( @@ -326,7 +353,7 @@ async fn verify_ai_credit_order( .send() .await { - Ok(r) => r, + Ok(resp) => resp, Err(e) => { tracing::error!("Failed to call users service to credit AI credits: {}", e); return ( diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index 93285c4..acde3a2 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -22,8 +22,8 @@ type HmacSha256 = Hmac; pub struct AppState { client: reqwest::Client, pool: PgPool, - razorpay_key_id: String, - razorpay_key_secret: String, + fallback_razorpay_key_id: String, + fallback_razorpay_key_secret: String, } #[derive(Debug, Deserialize)] @@ -78,6 +78,7 @@ struct PricingPackageRow { struct PaymentRow { id: Uuid, user_id: Uuid, + package_id: Option, tracecoins_credited: Option, amount_inr: i32, status: String, @@ -100,6 +101,33 @@ struct RazorpayOrderResponse { status: String, } +#[derive(Debug, Deserialize)] +struct StoredGatewayConfig { + mode: Option, + base_url: Option, + callback_url: Option, + webhook_url: Option, + merchant_id: Option, + api_key: Option, + secret_key: Option, +} + +#[derive(Debug, FromRow)] +struct PaymentGatewayConfigRow { + display_name: Option, + config_json: Option, + is_active: bool, +} + +#[derive(Debug, Clone)] +struct RazorpayConfig { + key_id: String, + key_secret: String, + base_url: String, + mode: String, + enabled: bool, +} + fn error_response(status: StatusCode, message: impl Into) -> (StatusCode, String) { (status, message.into()) } @@ -109,11 +137,77 @@ fn build_receipt(receipt: Option) -> String { candidate.chars().take(40).collect() } +pub(crate) async fn resolve_razorpay_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 = 'RAZORPAY' + 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_id = state.fallback_razorpay_key_id.clone(); + let mut key_secret = state.fallback_razorpay_key_secret.clone(); + let mut base_url = "https://api.razorpay.com".to_string(); + 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) { + if let Some(value) = stored.api_key.filter(|v| !v.trim().is_empty()) { + key_id = value; + } + if let Some(value) = stored.secret_key.filter(|v| !v.trim().is_empty()) { + key_secret = value; + } + if let Some(value) = stored.base_url.filter(|v| !v.trim().is_empty()) { + base_url = value; + } + if let Some(value) = stored.mode.filter(|v| !v.trim().is_empty()) { + mode = value; + } + } + } + } + + if !enabled { + return Err(error_response( + StatusCode::SERVICE_UNAVAILABLE, + "Razorpay gateway is disabled", + )); + } + + if key_id.trim().is_empty() || key_secret.trim().is_empty() { + return Err(error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Razorpay credentials are not configured", + )); + } + + Ok(RazorpayConfig { + key_id, + key_secret, + base_url, + mode, + enabled, + }) +} + async fn create_order( auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { + let gateway_config = resolve_razorpay_config(&state).await?; let package_id_str = payload .package_id .as_ref() @@ -160,8 +254,8 @@ async fn create_order( let resp = state .client - .post("https://api.razorpay.com/v1/orders") - .basic_auth(&state.razorpay_key_id, Some(&state.razorpay_key_secret)) + .post(format!("{}/v1/orders", gateway_config.base_url.trim_end_matches('/'))) + .basic_auth(&gateway_config.key_id, Some(&gateway_config.key_secret)) .json(&razorpay_request) .send() .await @@ -219,6 +313,7 @@ async fn verify_payment( State(state): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { + let gateway_config = resolve_razorpay_config(&state).await?; let order_id = payload .order_id .ok_or_else(|| error_response(StatusCode::BAD_REQUEST, "order_id is required"))?; @@ -232,7 +327,7 @@ async fn verify_payment( 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()) + let mut mac = HmacSha256::new_from_slice(gateway_config.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) @@ -240,7 +335,7 @@ async fn verify_payment( let payment = sqlx::query_as::<_, PaymentRow>( r#" - SELECT id, user_id, tracecoins_credited, amount_inr, status, razorpay_payment_id + SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, razorpay_payment_id FROM payments WHERE razorpay_order_id = $1 AND status = 'PENDING' "#, @@ -350,7 +445,7 @@ async fn get_payment_status( ) -> Result, (StatusCode, String)> { let payment = sqlx::query_as::<_, PaymentRow>( r#" - SELECT id, user_id, tracecoins_credited, amount_inr, status, razorpay_payment_id + SELECT id, user_id, package_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 @@ -408,9 +503,9 @@ async fn main() { let state = AppState { client: reqwest::Client::new(), pool, - razorpay_key_id: std::env::var("RAZORPAY_KEY_ID") + fallback_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") + fallback_razorpay_key_secret: std::env::var("RAZORPAY_KEY_SECRET") .expect("RAZORPAY_KEY_SECRET must be set"), }; diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index dc282c0..251e406 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -16,6 +16,7 @@ pub mod modules; pub mod notifications; pub mod onboarding; pub mod permissions; +pub mod payment_gateway; pub mod pricing; pub mod reviews; pub mod roles; diff --git a/apps/users/src/handlers/payment_gateway.rs b/apps/users/src/handlers/payment_gateway.rs new file mode 100644 index 0000000..2e31b9d --- /dev/null +++ b/apps/users/src/handlers/payment_gateway.rs @@ -0,0 +1,241 @@ +use crate::AppState; +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, patch, post, put}, + Json, Router, +}; +use contracts::auth_middleware::{require_admin, AuthUser}; +use serde::{Deserialize, Serialize}; + +const RAZORPAY_GATEWAY_KEY: &str = "RAZORPAY"; + +pub fn router() -> Router { + Router::new() + .route("/", get(get_payment_gateway_config)) + .route("/", post(upsert_payment_gateway_config)) + .route("/", put(upsert_payment_gateway_config)) + .route("/", patch(upsert_payment_gateway_config)) +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct PaymentGatewayConfigPayload { + provider: String, + mode: String, + enabled: bool, + base_url: String, + callback_url: String, + webhook_url: String, + merchant_id: String, + api_key: String, + secret_key: String, +} + +#[derive(Debug, sqlx::FromRow)] +struct PaymentGatewayRow { + display_name: Option, + config_json: Option, + is_active: bool, +} + +impl Default for PaymentGatewayConfigPayload { + fn default() -> Self { + Self { + provider: "Razorpay".to_string(), + mode: "sandbox".to_string(), + enabled: true, + base_url: "https://api.razorpay.com".to_string(), + callback_url: "".to_string(), + webhook_url: "".to_string(), + merchant_id: "".to_string(), + api_key: "".to_string(), + secret_key: "".to_string(), + } + } +} + +fn normalize_payload(payload: PaymentGatewayConfigPayload) -> PaymentGatewayConfigPayload { + PaymentGatewayConfigPayload { + provider: if payload.provider.trim().is_empty() { + "Razorpay".to_string() + } else { + payload.provider.trim().to_string() + }, + mode: if payload.mode.eq_ignore_ascii_case("live") { + "live".to_string() + } else { + "sandbox".to_string() + }, + enabled: payload.enabled, + base_url: if payload.base_url.trim().is_empty() { + "https://api.razorpay.com".to_string() + } else { + payload.base_url.trim().to_string() + }, + callback_url: payload.callback_url.trim().to_string(), + webhook_url: payload.webhook_url.trim().to_string(), + merchant_id: payload.merchant_id.trim().to_string(), + api_key: payload.api_key.trim().to_string(), + secret_key: payload.secret_key.trim().to_string(), + } +} + +fn row_to_payload(row: PaymentGatewayRow) -> PaymentGatewayConfigPayload { + let mut payload = PaymentGatewayConfigPayload::default(); + payload.provider = row + .display_name + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "Razorpay".to_string()); + payload.enabled = row.is_active; + + if let Some(config) = row.config_json { + payload.mode = config + .get("mode") + .and_then(|v| v.as_str()) + .unwrap_or("sandbox") + .to_string(); + payload.base_url = config + .get("base_url") + .and_then(|v| v.as_str()) + .unwrap_or("https://api.razorpay.com") + .to_string(); + payload.callback_url = config + .get("callback_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + payload.webhook_url = config + .get("webhook_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + payload.merchant_id = config + .get("merchant_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + payload.api_key = config + .get("api_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + payload.secret_key = config + .get("secret_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + } + + payload +} + +async fn get_payment_gateway_config( + auth: AuthUser, + State(state): State, +) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let row = sqlx::query_as::<_, PaymentGatewayRow>( + r#" + SELECT display_name, config_json, is_active + FROM payment_gateway_configs + WHERE gateway_key = $1 + ORDER BY is_active DESC, created_at DESC + LIMIT 1 + "#, + ) + .bind(RAZORPAY_GATEWAY_KEY) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let payload = match row { + Some(row) => row_to_payload(row), + None => { + let mut defaults = PaymentGatewayConfigPayload::default(); + defaults.api_key = std::env::var("RAZORPAY_KEY_ID").unwrap_or_default(); + defaults.secret_key = std::env::var("RAZORPAY_KEY_SECRET").unwrap_or_default(); + defaults + } + }; + + Ok((StatusCode::OK, Json(serde_json::json!({ "config": payload })))) +} + +async fn upsert_payment_gateway_config( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let payload = normalize_payload(payload); + + let config_json = serde_json::json!({ + "mode": payload.mode, + "base_url": payload.base_url, + "callback_url": payload.callback_url, + "webhook_url": payload.webhook_url, + "merchant_id": payload.merchant_id, + "api_key": payload.api_key, + "secret_key": payload.secret_key, + }); + + let existing_id = sqlx::query_scalar::<_, uuid::Uuid>( + r#" + SELECT id + FROM payment_gateway_configs + WHERE gateway_key = $1 + ORDER BY created_at DESC + LIMIT 1 + "#, + ) + .bind(RAZORPAY_GATEWAY_KEY) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let row = if let Some(id) = existing_id { + sqlx::query_as::<_, PaymentGatewayRow>( + r#" + UPDATE payment_gateway_configs + SET display_name = $2, + config_json = $3, + is_active = $4 + WHERE id = $1 + RETURNING display_name, config_json, is_active + "#, + ) + .bind(id) + .bind(&payload.provider) + .bind(config_json) + .bind(payload.enabled) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + } else { + sqlx::query_as::<_, PaymentGatewayRow>( + r#" + INSERT INTO payment_gateway_configs (gateway_key, display_name, config_json, is_active) + VALUES ($1, $2, $3, $4) + RETURNING display_name, config_json, is_active + "#, + ) + .bind(RAZORPAY_GATEWAY_KEY) + .bind(&payload.provider) + .bind(config_json) + .bind(payload.enabled) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + }; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Payment gateway configuration saved successfully.", + "config": row_to_payload(row), + })), + )) +} diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 44b4c43..a5c041c 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -103,6 +103,7 @@ async fn main() { // ── Coupons & Discounts (admin) ─────────────────────────────────── .nest("/api/admin/coupons", handlers::coupons::coupons_router()) .nest("/api/admin/discounts", handlers::coupons::discounts_router()) + .nest("/api/admin/payment-gateway-config", handlers::payment_gateway::router()) // ── Tracecoin Packages (public) ─────────────────────────────────── .nest("/api/packages", handlers::pricing::public_packages_router()) // ── Tracecoin Packages & Reports (admin) ──────────────────────────