use crate::domain::error::{AppError, AppResult}; use crate::domain::models::{ AddBankAccountRequest, InitiatePayoutRequest, MerchantBankAccount, NotificationLog, Payout, PayoutSummary, }; use crate::infrastructure::db::DbPool; use uuid::Uuid; // ======================================================== // ADVANCED OOPS CONCEPTS: STRATEGY & FACTORY FEE SCHEDULES // ======================================================== /// Strategy trait for calculating payouts transaction fees. pub trait PayoutFeeStrategy: Send + Sync { fn calculate_fee(&self) -> f64; } pub struct ImpsPayoutFeeStrategy; impl PayoutFeeStrategy for ImpsPayoutFeeStrategy { fn calculate_fee(&self) -> f64 { 5.00 } } pub struct RtgsPayoutFeeStrategy; impl PayoutFeeStrategy for RtgsPayoutFeeStrategy { fn calculate_fee(&self) -> f64 { 25.00 } } pub struct UpiPayoutFeeStrategy; impl PayoutFeeStrategy for UpiPayoutFeeStrategy { fn calculate_fee(&self) -> f64 { 0.00 } } pub struct NeftPayoutFeeStrategy; impl PayoutFeeStrategy for NeftPayoutFeeStrategy { fn calculate_fee(&self) -> f64 { 2.50 } } /// Factory pattern to retrieve the registered payout fee strategy dynamically. pub struct PayoutStrategyFactory; impl PayoutStrategyFactory { pub fn get_strategy(mode: &str) -> Box { match mode { "IMPS" => Box::new(ImpsPayoutFeeStrategy), "RTGS" => Box::new(RtgsPayoutFeeStrategy), "UPI" => Box::new(UpiPayoutFeeStrategy), _ => Box::new(NeftPayoutFeeStrategy), } } } // ======================================================== // PAYOUT SERVICE IMPLEMENTATION // ======================================================== pub struct PayoutService { pool: DbPool, } impl PayoutService { pub fn new(pool: DbPool) -> Self { Self { pool } } // ─── Bank Accounts ──────────────────────────────────────────────────────── pub async fn add_bank_account( &self, merchant_id: &str, req: AddBankAccountRequest, ) -> AppResult { // Validate IFSC format (11 chars: 4 alpha + 0 + 6 alphanumeric) if req.ifsc_code.len() != 11 { return Err(AppError::Validation( "IFSC code must be exactly 11 characters".into(), )); } let id = Uuid::new_v4().to_string(); let account = sqlx::query_as::<_, MerchantBankAccount>( r#"INSERT INTO merchant_bank_accounts (id, merchant_id, account_holder, account_number, ifsc_code, bank_name) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (merchant_id, account_number) DO UPDATE SET bank_name = EXCLUDED.bank_name RETURNING *"#, ) .bind(&id) .bind(merchant_id) .bind(&req.account_holder) .bind(&req.account_number) .bind(req.ifsc_code.to_uppercase()) .bind(&req.bank_name) .fetch_one(&self.pool) .await .map_err(AppError::Database)?; tracing::info!(merchant_id, "Bank account added: {}", &req.account_holder); Ok(account) } pub async fn list_bank_accounts( &self, merchant_id: &str, ) -> AppResult> { sqlx::query_as::<_, MerchantBankAccount>( "SELECT * FROM merchant_bank_accounts WHERE merchant_id = $1 ORDER BY is_primary DESC, created_at DESC", ) .bind(merchant_id) .fetch_all(&self.pool) .await .map_err(AppError::Database) } // ─── Payouts ────────────────────────────────────────────────────────────── pub async fn initiate_payout( &self, merchant_id: &str, req: InitiatePayoutRequest, ) -> AppResult { if req.amount_inr < 100.0 { return Err(AppError::Validation("Minimum payout amount is ₹100".into())); } // Verify bank account ownership let account_exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM merchant_bank_accounts WHERE id = $1 AND merchant_id = $2)", ) .bind(&req.bank_account_id) .bind(merchant_id) .fetch_one(&self.pool) .await .map_err(AppError::Database)?; if !account_exists { return Err(AppError::NotFound("Bank account not found".into())); } let mode = req.mode.as_deref().unwrap_or("NEFT").to_uppercase(); let strategy = PayoutStrategyFactory::get_strategy(&mode); let fee = strategy.calculate_fee(); // ─── Sovereign Ledger Balance Gating ───────────────────────────────────── // Guard: The merchant can only withdraw settled funds that are not already // committed in a prior pending or in-flight payout. // // available_balance = Σ(net_payout_inr for COMPLETED settlements) // - Σ(amount_inr + fee_inr for PENDING/PROCESSING/SUCCESS payouts) let settled_sum = sqlx::query_scalar::<_, Option>( "SELECT COALESCE(SUM(net_payout_inr), 0.0) FROM settlements WHERE merchant_id = $1 AND status = 'COMPLETED'" ) .bind(merchant_id) .fetch_one(&self.pool) .await .map_err(AppError::Database)? .unwrap_or(0.0); let active_payouts_sum = sqlx::query_scalar::<_, Option>( "SELECT COALESCE(SUM(amount_inr + fee_inr), 0.0) FROM payouts WHERE merchant_id = $1 AND status IN ('PENDING', 'PROCESSING', 'SUCCESS')" ) .bind(merchant_id) .fetch_one(&self.pool) .await .map_err(AppError::Database)? .unwrap_or(0.0); let available_balance = settled_sum - active_payouts_sum; let total_debit = req.amount_inr + fee; if total_debit > available_balance { tracing::warn!( merchant_id, requested = req.amount_inr, fee, available = available_balance, settled = settled_sum, committed = active_payouts_sum, "Payout rejected: insufficient settled balance" ); return Err(AppError::Validation(format!( "Sovereign Ledger Error: Insufficient settled funds. \ Requested ₹{:.2} + fee ₹{:.2} = ₹{:.2}, but available settled balance is ₹{:.2}. \ Please wait for pending settlements to clear.", req.amount_inr, fee, total_debit, available_balance ))); } tracing::info!( merchant_id, available_balance, total_debit, "Ledger balance check passed — proceeding with payout" ); // ───────────────────────────────────────────────────────────────────────── let id = Uuid::new_v4().to_string(); let payout = sqlx::query_as::<_, Payout>( r#"INSERT INTO payouts (id, merchant_id, bank_account_id, amount_inr, fee_inr, mode, notes) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *"#, ) .bind(&id) .bind(merchant_id) .bind(&req.bank_account_id) .bind(req.amount_inr) .bind(fee) .bind(&mode) .bind(&req.notes) .fetch_one(&self.pool) .await .map_err(AppError::Database)?; tracing::info!( merchant_id, payout_id = %id, "Payout initiated: ₹{:.2} via {}", req.amount_inr, mode ); Ok(payout) } pub async fn list_payouts(&self, merchant_id: &str, limit: i64) -> AppResult> { sqlx::query_as::<_, Payout>( "SELECT * FROM payouts WHERE merchant_id = $1 ORDER BY initiated_at DESC LIMIT $2", ) .bind(merchant_id) .bind(limit) .fetch_all(&self.pool) .await .map_err(AppError::Database) } pub async fn get_payout_summary(&self, merchant_id: &str) -> AppResult { use sqlx::Row; let row = sqlx::query( r#"SELECT COALESCE(SUM(net_inr) FILTER (WHERE status = 'SUCCESS'), 0.0) as total_paid, COUNT(*) FILTER (WHERE status = 'PENDING') as pending_count, COUNT(*) FILTER (WHERE status = 'SUCCESS') as success_count, COUNT(*) FILTER (WHERE status = 'FAILED') as failed_count, MAX(processed_at) FILTER (WHERE status = 'SUCCESS') as last_payout_at FROM payouts WHERE merchant_id = $1"#, ) .bind(merchant_id) .fetch_one(&self.pool) .await .map_err(AppError::Database)?; Ok(PayoutSummary { total_paid_inr: row.get::, _>("total_paid").unwrap_or(0.0), pending_count: row.get("pending_count"), success_count: row.get("success_count"), failed_count: row.get("failed_count"), last_payout_at: row.get("last_payout_at"), }) } /// Mark a payout as SUCCESS with UTR reference (called when bank confirms payment). pub async fn confirm_payout( &self, merchant_id: &str, payout_id: &str, utr_number: &str, ) -> AppResult { let payout = sqlx::query_as::<_, Payout>( r#"UPDATE payouts SET status = 'SUCCESS', utr_number = $3, processed_at = NOW() WHERE id = $1 AND merchant_id = $2 AND status = 'PENDING' RETURNING *"#, ) .bind(payout_id) .bind(merchant_id) .bind(utr_number) .fetch_optional(&self.pool) .await .map_err(AppError::Database)? .ok_or_else(|| AppError::NotFound("Payout not found or already processed".into()))?; tracing::info!(merchant_id, payout_id, utr = utr_number, "Payout confirmed"); Ok(payout) } } // ======================================================== // ADVANCED OOPS CONCEPTS: POLYMORPHIC TEMPLATE STRATEGIES // ======================================================== /// Polymorphic template strategy for rendering notification subjects and bodies. pub trait NotificationTemplate: Send + Sync { fn event_type(&self) -> &'static str; fn subject(&self) -> String; fn html_body(&self) -> String; } pub struct OrderPlacedTemplate { pub merchant_name: String, pub buyer_name: String, pub transaction_id: String, pub amount_inr: f64, } impl NotificationTemplate for OrderPlacedTemplate { fn event_type(&self) -> &'static str { "ORDER_PLACED" } fn subject(&self) -> String { format!( "Order Confirmed – ₹{:.2} | {}", self.amount_inr, self.merchant_name ) } fn html_body(&self) -> String { let frontend_url = std::env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:5173".to_string()); format!( r#"

Order Confirmed ✓

Hi {buyer_name}, your order of ₹{amount_inr:.2} from {merchant_name} has been placed successfully.

Transaction ID: {transaction_id}


Powered by Rtix Secure Payments

"#, buyer_name = self.buyer_name, amount_inr = self.amount_inr, merchant_name = self.merchant_name, transaction_id = self.transaction_id, frontend_url = frontend_url, ) } } pub struct PayoutInitiatedTemplate { pub merchant_name: String, pub amount_inr: f64, pub mode: String, pub payout_id: String, } impl NotificationTemplate for PayoutInitiatedTemplate { fn event_type(&self) -> &'static str { "PAYOUT_INITIATED" } fn subject(&self) -> String { format!( "Payout Initiated – ₹{:.2} via {}", self.amount_inr, self.mode ) } fn html_body(&self) -> String { format!( r#"

Payout Initiated 🏦

Hi {merchant_name}, your payout of ₹{amount_inr:.2} via {mode} has been initiated.

Payout ID: {payout_id}

Funds will be credited within 1–2 business hours (IMPS) or next business day (NEFT).


Powered by Rtix Secure Payments

"#, merchant_name = self.merchant_name, amount_inr = self.amount_inr, mode = self.mode, payout_id = self.payout_id, ) } } pub struct PayoutSuccessTemplate { pub merchant_name: String, pub amount_inr: f64, pub utr_number: String, } impl NotificationTemplate for PayoutSuccessTemplate { fn event_type(&self) -> &'static str { "PAYOUT_SUCCESS" } fn subject(&self) -> String { format!("₹{:.2} Credited to Your Account | Rtix", self.amount_inr) } fn html_body(&self) -> String { format!( r#"

Payment Successful ✓

Hi {merchant_name}, ₹{amount_inr:.2} has been credited to your bank account.

UTR: {utr_number}


Powered by Rtix Secure Payments

"#, merchant_name = self.merchant_name, amount_inr = self.amount_inr, utr_number = self.utr_number, ) } } pub struct SubscriptionRenewalTemplate { pub subscriber_name: String, pub plan_name: String, pub amount_inr: f64, pub next_billing_date: String, } impl NotificationTemplate for SubscriptionRenewalTemplate { fn event_type(&self) -> &'static str { "SUBSCRIPTION_RENEWAL" } fn subject(&self) -> String { format!( "Your {} subscription renews on {}", self.plan_name, self.next_billing_date ) } fn html_body(&self) -> String { format!( r#"

Subscription Renewal Reminder

Hi {subscriber_name}, your {plan_name} subscription of ₹{amount_inr:.2} will renew on {next_billing_date}.


Powered by Rtix Secure Payments

"#, subscriber_name = self.subscriber_name, plan_name = self.plan_name, amount_inr = self.amount_inr, next_billing_date = self.next_billing_date, ) } } pub struct SubscriptionCancelledTemplate { pub subscriber_name: String, pub plan_name: String, } impl NotificationTemplate for SubscriptionCancelledTemplate { fn event_type(&self) -> &'static str { "SUBSCRIPTION_CANCELLED" } fn subject(&self) -> String { format!("Your {} subscription has been cancelled", self.plan_name) } fn html_body(&self) -> String { format!( r#"

Subscription Cancelled

Hi {subscriber_name}, your {plan_name} subscription has been cancelled. You will retain access until the end of the current billing period.


Powered by Rtix Secure Payments

"#, subscriber_name = self.subscriber_name, plan_name = self.plan_name, ) } } // ─── Legacy Wrapper Enum for Backwards-Compatibility ────────────────────── /// Transactional email templates (Legacy Enum wrapper, maintained for compatibility) pub enum NotificationEvent<'a> { OrderPlaced { merchant_name: &'a str, buyer_name: &'a str, transaction_id: &'a str, amount_inr: f64, }, PayoutInitiated { merchant_name: &'a str, amount_inr: f64, mode: &'a str, payout_id: &'a str, }, PayoutSuccess { merchant_name: &'a str, amount_inr: f64, utr_number: &'a str, }, SubscriptionRenewal { subscriber_name: &'a str, plan_name: &'a str, amount_inr: f64, next_billing_date: &'a str, }, SubscriptionCancelled { subscriber_name: &'a str, plan_name: &'a str, }, } impl<'a> NotificationEvent<'a> { /// OOPS Adapter: converts the legacy procedural enum into a dynamic template object. pub fn into_template(self) -> Box { match self { NotificationEvent::OrderPlaced { merchant_name, buyer_name, transaction_id, amount_inr, } => Box::new(OrderPlacedTemplate { merchant_name: merchant_name.to_string(), buyer_name: buyer_name.to_string(), transaction_id: transaction_id.to_string(), amount_inr, }), NotificationEvent::PayoutInitiated { merchant_name, amount_inr, mode, payout_id, } => Box::new(PayoutInitiatedTemplate { merchant_name: merchant_name.to_string(), amount_inr, mode: mode.to_string(), payout_id: payout_id.to_string(), }), NotificationEvent::PayoutSuccess { merchant_name, amount_inr, utr_number, } => Box::new(PayoutSuccessTemplate { merchant_name: merchant_name.to_string(), amount_inr, utr_number: utr_number.to_string(), }), NotificationEvent::SubscriptionRenewal { subscriber_name, plan_name, amount_inr, next_billing_date, } => Box::new(SubscriptionRenewalTemplate { subscriber_name: subscriber_name.to_string(), plan_name: plan_name.to_string(), amount_inr, next_billing_date: next_billing_date.to_string(), }), NotificationEvent::SubscriptionCancelled { subscriber_name, plan_name, } => Box::new(SubscriptionCancelledTemplate { subscriber_name: subscriber_name.to_string(), plan_name: plan_name.to_string(), }), } } } // ─── Notification Service ───────────────────────────────────────────────────── pub struct NotificationService { pool: DbPool, resend_api_key: Option, sendgrid_api_key: Option, sendgrid_endpoint: String, from_email: String, } impl NotificationService { pub fn new(pool: DbPool) -> Self { let resend_api_key = std::env::var("RESEND_API_KEY").ok(); let sendgrid_api_key = std::env::var("SENDGRID_API_KEY") .or_else(|_| std::env::var("EMAIL_API_KEY")) .ok(); let sendgrid_endpoint = std::env::var("EMAIL_ENDPOINT") .unwrap_or_else(|_| "https://api.sendgrid.com/v3/mail/send".to_string()); let from_email = std::env::var("NOTIFICATION_FROM_EMAIL") .unwrap_or_else(|_| "notifications@rtix.in".to_string()); if resend_api_key.is_none() && sendgrid_api_key.is_none() { tracing::warn!("Neither RESEND_API_KEY nor SENDGRID_API_KEY/EMAIL_API_KEY set — email notifications will be logged only."); } Self { pool, resend_api_key, sendgrid_api_key, sendgrid_endpoint, from_email, } } /// Dynamically send a notification rendered from any OOPS `NotificationTemplate`. pub async fn send( &self, recipient_email: &str, merchant_id: Option<&str>, template: &dyn NotificationTemplate, ) -> AppResult<()> { let subject = template.subject(); let html_body = template.html_body(); // Attempt to send via SendGrid or Resend API let provider_id = if let Some(api_key) = &self.sendgrid_api_key { self.send_via_sendgrid( api_key, &self.sendgrid_endpoint, recipient_email, &subject, &html_body, ) .await .ok() } else if let Some(api_key) = &self.resend_api_key { self.send_via_resend(api_key, recipient_email, &subject, &html_body) .await .ok() } else { tracing::info!( recipient = recipient_email, subject = %subject, "[DRY RUN] Email notification — set SENDGRID_API_KEY/EMAIL_API_KEY or RESEND_API_KEY to enable delivery" ); None }; let event_type = template.event_type(); let log_id = Uuid::new_v4().to_string(); let is_configured = self.resend_api_key.is_some() || self.sendgrid_api_key.is_some(); let status = if provider_id.is_some() || !is_configured { "SENT" } else { "FAILED" }; sqlx::query( r#"INSERT INTO notification_log (id, merchant_id, recipient_email, event_type, subject, status, provider_id) VALUES ($1, $2, $3, $4, $5, $6, $7)"#, ) .bind(&log_id) .bind(merchant_id) .bind(recipient_email) .bind(event_type) .bind(&subject) .bind(status) .bind(&provider_id) .execute(&self.pool) .await .map_err(AppError::Database)?; Ok(()) } /// Legacy support method: converts older enum types and forwards it polmorphicly. pub async fn send_legacy( &self, recipient_email: &str, merchant_id: Option<&str>, event: NotificationEvent<'_>, ) -> AppResult<()> { let template = event.into_template(); self.send(recipient_email, merchant_id, template.as_ref()) .await } async fn send_via_resend( &self, api_key: &str, to: &str, subject: &str, html: &str, ) -> Result { let client = reqwest::Client::new(); let payload = serde_json::json!({ "from": self.from_email, "to": [to], "subject": subject, "html": html }); let resp = client .post("https://api.resend.com/emails") .bearer_auth(api_key) .json(&payload) .send() .await .map_err(|e| e.to_string())?; if resp.status().is_success() { let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; Ok(body["id"].as_str().unwrap_or("unknown").to_string()) } else { let status = resp.status(); let err_body = resp.text().await.unwrap_or_default(); tracing::error!("Resend API error {}: {}", status, err_body); Err(format!("Resend API error: {}", status)) } } async fn send_via_sendgrid( &self, api_key: &str, endpoint: &str, to: &str, subject: &str, html: &str, ) -> Result { let client = reqwest::Client::new(); let payload = serde_json::json!({ "personalizations": [ { "to": [ { "email": to } ] } ], "from": { "email": self.from_email }, "subject": subject, "content": [ { "type": "text/html", "value": html } ] }); let resp = client .post(endpoint) .bearer_auth(api_key) .json(&payload) .send() .await .map_err(|e| e.to_string())?; if resp.status().is_success() { let msg_id = resp .headers() .get("x-message-id") .and_then(|h| h.to_str().ok()) .unwrap_or("sendgrid-accepted") .to_string(); Ok(msg_id) } else { let status = resp.status(); let err_body = resp.text().await.unwrap_or_default(); tracing::error!("SendGrid API error {}: {}", status, err_body); Err(format!("SendGrid API error: {}", status)) } } pub async fn get_notification_log( &self, merchant_id: &str, limit: i64, ) -> AppResult> { sqlx::query_as::<_, NotificationLog>( r#"SELECT * FROM notification_log WHERE merchant_id = $1 ORDER BY sent_at DESC LIMIT $2"#, ) .bind(merchant_id) .bind(limit) .fetch_all(&self.pool) .await .map_err(AppError::Database) } }