From 508242727996f205121b6759170a78fcc2851b5d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 26 Jun 2026 21:07:36 +0200 Subject: [PATCH] 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) ──────────────────────────