diff --git a/apps/payments/Cargo.toml b/apps/payments/Cargo.toml index 0c6c9a2..86cf510 100644 --- a/apps/payments/Cargo.toml +++ b/apps/payments/Cargo.toml @@ -12,3 +12,7 @@ tracing.workspace = true tracing-subscriber.workspace = true reqwest = { version = "0.12", features = ["json", "stream"] } anyhow.workspace = true +contracts = { path = "../../crates/contracts" } +sqlx.workspace = true +uuid.workspace = true +chrono.workspace = true diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index b49eeb6..05eacad 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -4,14 +4,18 @@ use axum::{ routing::{get, post}, Json, Router, }; +use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use uuid::Uuid; +use sqlx::postgres::PgPool; #[derive(Clone)] struct AppState { beeceptor_url: String, client: reqwest::Client, + pool: PgPool, } #[derive(Debug, Serialize, Deserialize)] @@ -54,11 +58,29 @@ struct PaymentStatusResponse { } async fn create_order( + auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { tracing::info!("Creating payment order: amount={}", payload.amount); + // Validate 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()))?; + + // Fetch package to get tracecoins amount + let package = sqlx::query!( + "SELECT tracecoins_amount FROM pricing_packages WHERE id = $1 AND is_active = true", + package_id + ) + .fetch_optional(&state.pool) + .await + .map_err(|e| (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; + + // Call Beeceptor to create order let resp = state .client .post(&state.beeceptor_url) @@ -66,8 +88,8 @@ async fn create_order( .json(&serde_json::json!({ "amount": payload.amount, "currency": payload.currency.as_deref().unwrap_or("INR"), - "package_id": payload.package_id, - "user_id": payload.user_id, + "package_id": package_id_str, + "user_id": auth.user_id.to_string(), })) .send() .await @@ -95,6 +117,19 @@ async fn create_order( .unwrap_or("mock_order_123") .to_string(); + // Insert payment record + sqlx::query!( + "INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status) VALUES ($1, $2, $3, $4, $5, 'PENDING')", + auth.user_id, + package_id, + order_id, + payload.amount as i64, + tracecoins_credited + ) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + Ok(Json(CreateOrderResponse { order_id, amount: payload.amount, @@ -104,13 +139,14 @@ async fn create_order( } async fn verify_payment( + auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { tracing::info!("Verifying payment: order_id={}", payload.order_id); + // Verify with Beeceptor let verify_url = format!("{}/verify", state.beeceptor_url.trim_end_matches('/')); - let resp = state .client .post(&verify_url) @@ -136,6 +172,68 @@ async fn verify_payment( )); } + // Find pending payment by razorpay_order_id + let payment = sqlx::query!( + "SELECT id, user_id, tracecoins_credited FROM payments WHERE razorpay_order_id = $1 AND status = 'PENDING'", + payload.order_id + ) + .fetch_optional(&state.pool) + .await + .map_err(|e| (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())), + }; + + // Ensure the authenticated user matches the payment user + if payment.user_id != auth.user_id { + return Err((StatusCode::FORBIDDEN, "Payment does not belong to user".to_string())); + } + + // Update payment status to SUCCESS + sqlx::query!( + "UPDATE payments SET status = 'SUCCESS', verified_at = NOW(), razorpay_payment_id = $1 WHERE id = $2", + payload.payment_id, + payment.id + ) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + // Credit wallet (increase balance) + sqlx::query!( + "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", + payment.user_id, + payment.tracecoins_credited + ) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + // Get wallet id for ledger + match sqlx::query_scalar!( + "SELECT id FROM tracecoin_wallets WHERE user_id = $1", + payment.user_id + ) + .fetch_optional(&state.pool) + .await + { + Ok(Some(wallet_id)) => { + sqlx::query!( + "INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id) VALUES ($1, 'CREDIT', $2, $3, $4)", + wallet_id, + payment.tracecoins_credited as i64, + "PURCHASE", + payment.id + ) + .execute(&state.pool) + .await + .ok(); + } + _ => {} + } + Ok(Json(VerifyPaymentResponse { verified: true, payment_id: payload.payment_id, @@ -151,7 +249,6 @@ async fn get_payment_status( tracing::info!("Getting payment status: payment_id={}", payment_id); let status_url = format!("{}/{}", state.beeceptor_url.trim_end_matches('/'), payment_id); - let resp = state .client .get(&status_url) @@ -206,9 +303,16 @@ async fn main() { let beeceptor_url = std::env::var("BEECEPTOR_URL") .unwrap_or_else(|_| "https://nxtgauge.free.beeceptor.com".to_string()); + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/nxtgauge".to_string()); + let pool = PgPool::connect(&db_url) + .await + .expect("Failed to connect to database"); + let state = AppState { beeceptor_url, client: reqwest::Client::new(), + pool, }; let app = Router::new() diff --git a/apps/users/src/handlers/coupons.rs b/apps/users/src/handlers/coupons.rs index cd17bea..87b9609 100644 --- a/apps/users/src/handlers/coupons.rs +++ b/apps/users/src/handlers/coupons.rs @@ -6,6 +6,7 @@ use axum::{ routing::{delete, get, patch, post}, Json, Router, }; +use chrono::{DateTime, Utc}; use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -16,6 +17,7 @@ pub fn coupons_router() -> Router { Router::new() .route("/", get(list_coupons).post(create_coupon)) .route("/{id}", patch(update_coupon).delete(delete_coupon)) + .route("/validate", post(validate_coupon)) } pub fn discounts_router() -> Router { @@ -430,11 +432,161 @@ async fn update_discount( .execute(&state.pool) .await; - match result { - Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), - Err(e) => { - tracing::error!("Failed to update discount {id}: {e}"); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to update discount" }))).into_response() +#[derive(Deserialize)] +struct ValidateCouponPayload { + coupon_code: String, + role_key: String, + package_price_inr: i32, +} + +#[derive(Serialize)] +struct ValidateCouponResponse { + valid: bool, + discount_type: Option, + discount_value: Option, + final_price_inr: i32, + message: String, +} + +async fn validate_coupon( + _auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> Result { + let code = payload.coupon_code.trim().to_uppercase(); + + // Fetch coupon + let coupon = sqlx::query!( + r#" + SELECT id, code, title, discount_type, discount_value, min_order_amount, + max_uses, role_keys, valid_until, is_active + FROM coupons + WHERE code = $1 + "#, + code + ) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let coupon = match coupon { + Some(c) => c, + None => { + return Ok(( + StatusCode::OK, + Json(ValidateCouponResponse { + valid: false, + discount_type: None, + discount_value: None, + final_price_inr: payload.package_price_inr, + message: "Coupon not found".to_string(), + }), + )); + } + }; + + // Check active + if !coupon.is_active { + return Ok(( + StatusCode::OK, + Json(ValidateCouponResponse { + valid: false, + discount_type: None, + discount_value: None, + final_price_inr: payload.package_price_inr, + message: "Coupon is inactive".to_string(), + }), + )); + } + + // Check expiry + if let Some(valid_until) = coupon.valid_until { + if valid_until < chrono::Utc::now() { + return Ok(( + StatusCode::OK, + Json(ValidateCouponResponse { + valid: false, + discount_type: None, + discount_value: None, + final_price_inr: payload.package_price_inr, + message: "Coupon has expired".to_string(), + }), + )); } } + + // Check role restriction + if !coupon.role_keys.is_empty() && !coupon.role_keys.iter().any(|r| r.eq_ignore_ascii_case(&payload.role_key)) { + return Ok(( + StatusCode::OK, + Json(ValidateCouponResponse { + valid: false, + discount_type: None, + discount_value: None, + final_price_inr: payload.package_price_inr, + message: "Coupon not valid for your role".to_string(), + }), + )); + } + + // Check minimum order amount + if payload.package_price_inr < coupon.min_order_amount { + return Ok(( + StatusCode::OK, + Json(ValidateCouponResponse { + valid: false, + discount_type: None, + discount_value: None, + final_price_inr: payload.package_price_inr, + message: format!("Minimum order amount ₹{} required", coupon.min_order_amount / 100), + }), + )); + } + + // Check usage limit if set + if let Some(max_uses) = coupon.max_uses { + let count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM coupon_uses WHERE coupon_id = $1", + coupon.id + ) + .fetch_one(&state.pool) + .await + .unwrap_or(Some(0)) + .unwrap_or(0); + if count >= max_uses { + return Ok(( + StatusCode::OK, + Json(ValidateCouponResponse { + valid: false, + discount_type: None, + discount_value: None, + final_price_inr: payload.package_price_inr, + message: "Coupon usage limit reached".to_string(), + }), + )); + } + } + + // Compute final price + let final_price = match coupon.discount_type.as_str() { + "PERCENT" => { + let discount = ((payload.package_price_inr as f64) * (coupon.discount_value as f64) / 100.0).round() as i32; + (payload.package_price_inr - discount).max(0) + } + "FIXED" => (payload.package_price_inr - coupon.discount_value).max(0), + _ => payload.package_price_inr, + }; + + Ok(( + StatusCode::OK, + Json(ValidateCouponResponse { + valid: true, + discount_type: Some(coupon.discount_type), + discount_value: Some(coupon.discount_value), + final_price_inr: final_price, + message: "Coupon applied".to_string(), + }), + )) +} + } } diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index affa46f..17f15d7 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -7,6 +7,7 @@ use axum::{ Json, Router, }; use contracts::auth_middleware::{require_admin, AuthUser}; +use db::models::role::RoleRepository; use db::models::verification::{VerificationRepository}; use serde::Deserialize; use uuid::Uuid; @@ -134,15 +135,54 @@ async fn trigger_activation( .execute(&state.pool) .await?; - // Send Email - if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await { - let display = role_key_to_display(&role_key); - let _ = state.mail.send_approval_approved_email( - &user.email, - user.full_name.as_deref().unwrap_or_default(), - &display - ).await; - } + // Send Email + if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await { + let display = role_key_to_display(&role_key); + let _ = state.mail.send_approval_approved_email( + &user.email, + user.full_name.as_deref().unwrap_or_default(), + &display + ).await; + } + + // Assign role to user in user_roles + if let Ok(Some(role)) = RoleRepository::get_by_key(&state.pool, &role_key).await { + sqlx::query!( + "INSERT INTO user_roles (user_id, role_id, status, approved_at) VALUES ($1, $2, 'APPROVED', NOW()) ON CONFLICT (user_id, role_id) DO UPDATE SET status = 'APPROVED', approved_at = NOW()", + user_id, + role.id + ).execute(&state.pool).await.ok(); + } + + // Credit 250 promotional tracecoins + sqlx::query!( + "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", + user_id, + 250 + ).execute(&state.pool).await.ok(); + + // Get wallet id for ledger entry + if let Ok(Some(wallet_id)) = sqlx::query_scalar!( + "SELECT id FROM tracecoin_wallets WHERE user_id = $1", + user_id + ).fetch_optional(&state.pool).await.ok().flatten() { + sqlx::query!( + "INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id) VALUES ($1, 'CREDIT', $2, $3, $4)", + wallet_id, + 250, + "PROMOTIONAL_SIGNUP_BONUS", + user_id + ).execute(&state.pool).await.ok(); + } + + // Create notification for user + sqlx::query!( + "INSERT INTO notifications (user_id, title, body, type) VALUES ($1, $2, $3, $4)", + user_id, + "Profile Approved", + format!("Your {} profile has been approved.", role_key_to_display(&role_key)), + "APPROVAL" + ).execute(&state.pool).await.ok(); } Ok(()) @@ -274,7 +314,18 @@ async fn add_notes( ) .await { - Ok(v) => (StatusCode::OK, Json(v)).into_response(), + Ok(v) => { + // Notify user that admin added notes/requested documents + sqlx::query!( + "INSERT INTO notifications (user_id, title, body, type, reference_id) VALUES ($1, $2, $3, $4, $5)", + v.user_id, + "Action Required", + format!("Admin requested: {}", notes), + "DOCUMENT_REQUEST", + v.id + ).execute(&state.pool).await.ok(); + (StatusCode::OK, Json(v)).into_response() + } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } }