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()