RTIX / src /application /services /merchant.rs
github-actions
deploy: clean backend production release
b198f8a
Raw
History Blame Contribute Delete
57.5 kB
use crate::domain::error::{AppError, AppResult};
use crate::domain::models::{Merchant, OrderRecord, ProductLink};
use crate::infrastructure::repositories::{MerchantRepository, OrderRepository, ProductRepository};
use crate::interfaces::http::api::RealtimeEvent;
use async_trait::async_trait;
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::broadcast;
use uuid::Uuid;
use crate::infrastructure::db::DbPool;
#[derive(Serialize)]
pub struct StorefrontProfile {
pub merchant_id: String,
pub brand_name: String,
pub business_address: Option<String>,
pub base_pincode: Option<String>,
pub upi_id: Option<String>,
pub announcement_banner: Option<String>,
pub avg_rating: f64,
pub review_count: i64,
}
#[derive(Serialize)]
pub struct CatalogItem {
pub product: ProductLink,
pub avg_rating: f64,
pub review_count: i64,
}
#[async_trait]
#[allow(clippy::too_many_arguments)]
pub trait MerchantService: Send + Sync {
async fn get_profile(&self, merchant_id: &str) -> AppResult<Option<Merchant>>;
async fn get_by_slug(&self, slug: &str) -> AppResult<Option<Merchant>>;
async fn update_profile(
&self,
merchant_id: &str,
brand_name: Option<String>,
social_url: Option<String>,
upi_id: Option<String>,
business_address: Option<String>,
delivery_rate_per_km: Option<f64>,
delivery_base_fee: Option<f64>,
logistics_config: Option<serde_json::Value>,
base_pincode: Option<String>,
auto_settle_threshold: Option<f64>,
announcement_banner: Option<String>,
) -> AppResult<()>;
async fn get_products(&self, merchant_id: &str) -> AppResult<Vec<ProductLink>>;
async fn get_orders(&self, merchant_id: &str) -> AppResult<Vec<OrderRecord>>;
async fn get_order(&self, merchant_id: &str, transaction_id: &str) -> AppResult<OrderRecord>;
async fn get_customers(
&self,
merchant_id: &str,
) -> AppResult<Vec<crate::domain::models::CustomerRecord>>;
async fn get_products_by_slug(&self, slug: &str) -> AppResult<Vec<ProductLink>>;
async fn get_merchant_upi(&self, merchant_id: &str) -> AppResult<Option<String>>;
async fn approve_settlement(
&self,
merchant_id: &str,
transaction_id: &str,
return_weight: Option<f64>,
request_id: Option<String>,
utr_number: Option<String>,
) -> AppResult<()>;
async fn mark_order_shipped(
&self,
merchant_id: &str,
transaction_id: &str,
shipping_method: Option<String>,
estimated_delivery_at: Option<chrono::NaiveDateTime>,
) -> AppResult<()>;
async fn bulk_mark_orders_shipped(
&self,
merchant_id: &str,
transaction_ids: Vec<String>,
) -> AppResult<()>;
async fn delete_link(&self, link_id: &str, merchant_id: &str) -> AppResult<()>;
async fn create_link(
&self,
merchant_id: &str,
product_name: &str,
price_inr: f64,
image_data: Option<String>,
expected_weight: f64,
inventory_count: i32,
is_unlimited: bool,
category: Option<String>,
) -> AppResult<ProductLink>;
async fn update_inventory(
&self,
merchant_id: &str,
link_id: &str,
count: i32,
is_unlimited: bool,
) -> AppResult<()>;
async fn upgrade_plan(&self, merchant_id: &str, plan: &str) -> AppResult<()>;
async fn get_analytics(
&self,
merchant_id: &str,
) -> AppResult<crate::domain::models::analytics::AnalyticsResponse>;
async fn get_storefront_profile(&self, slug: &str) -> AppResult<Option<StorefrontProfile>>;
async fn get_catalog(&self, slug: &str) -> AppResult<Vec<CatalogItem>>;
async fn reset_account(&self, merchant_id: &str) -> AppResult<()>;
async fn submit_feedback(
&self,
merchant_id: &str,
category: &str,
message: &str,
) -> AppResult<()>;
async fn calculate_credit_worthiness(&self, merchant_id: &str) -> AppResult<serde_json::Value>;
async fn get_dispute_evidence(
&self,
merchant_id: &str,
transaction_id: &str,
) -> AppResult<Vec<crate::application::services::arbitration::DisputeEvidence>>;
async fn upload_dispute_evidence(
&self,
merchant_id: &str,
transaction_id: &str,
evidence_url: String,
) -> AppResult<Uuid>;
async fn resolve_dispute(
&self,
merchant_id: &str,
transaction_id: &str,
resolution: String,
) -> AppResult<()>;
async fn dispute_order(
&self,
merchant_id: &str,
transaction_id: &str,
reason: String,
request_id: Option<String>,
) -> AppResult<()>;
async fn delete_order(&self, merchant_id: &str, transaction_id: &str) -> AppResult<()>;
async fn get_growth_insights(&self, merchant_id: &str) -> AppResult<serde_json::Value>;
async fn get_accounting_summary(&self, merchant_id: &str) -> AppResult<serde_json::Value>;
async fn generate_gst_report(
&self,
merchant_id: &str,
month: u32,
year: i32,
) -> AppResult<String>;
async fn create_coupon(
&self,
merchant_id: &str,
coupon: crate::domain::models::Coupon,
) -> AppResult<()>;
async fn get_coupons(&self, merchant_id: &str)
-> AppResult<Vec<crate::domain::models::Coupon>>;
async fn delete_coupon(&self, merchant_id: &str, coupon_id: &uuid::Uuid) -> AppResult<()>;
async fn validate_coupon(
&self,
merchant_id: &str,
code: &str,
amount: f64,
) -> AppResult<crate::domain::models::Coupon>;
async fn get_product_reviews(
&self,
product_id: &str,
) -> AppResult<Vec<crate::domain::models::ProductFeedback>>;
async fn get_order_invoice(&self, merchant_id: &str, transaction_id: &str)
-> AppResult<String>;
async fn get_financial_ledger(&self, merchant_id: &str) -> AppResult<Vec<LedgerRecord>>;
}
#[derive(Serialize)]
pub struct LedgerRecord {
pub transaction_id: String,
pub created_at: Option<chrono::NaiveDateTime>,
pub status: String,
pub gross_amount: f64,
pub platform_fee: f64,
pub delivery_fee: f64,
pub tax_amount: f64,
pub net_settlement: f64,
pub settled_at: Option<chrono::NaiveDateTime>,
pub utr_number: Option<String>,
}
pub struct RtixMerchantService {
merchant_repo: Arc<dyn MerchantRepository>,
product_repo: Arc<dyn ProductRepository>,
order_repo: Arc<dyn OrderRepository>,
coupon_repo: Arc<dyn crate::infrastructure::repositories::CouponRepository>,
pool: DbPool,
tx: broadcast::Sender<RealtimeEvent>,
}
impl RtixMerchantService {
pub fn new(
merchant_repo: Arc<dyn MerchantRepository>,
product_repo: Arc<dyn ProductRepository>,
order_repo: Arc<dyn OrderRepository>,
coupon_repo: Arc<dyn crate::infrastructure::repositories::CouponRepository>,
pool: DbPool,
tx: broadcast::Sender<RealtimeEvent>,
) -> Self {
Self {
merchant_repo,
product_repo,
order_repo,
coupon_repo,
pool,
tx,
}
}
}
#[async_trait]
impl MerchantService for RtixMerchantService {
async fn get_profile(&self, merchant_id: &str) -> AppResult<Option<Merchant>> {
self.merchant_repo.find_by_id(merchant_id).await
}
async fn get_by_slug(&self, slug: &str) -> AppResult<Option<Merchant>> {
self.merchant_repo.find_by_slug(slug).await
}
async fn update_profile(
&self,
merchant_id: &str,
brand_name: Option<String>,
social_url: Option<String>,
upi_id: Option<String>,
business_address: Option<String>,
delivery_rate_per_km: Option<f64>,
delivery_base_fee: Option<f64>,
logistics_config: Option<serde_json::Value>,
base_pincode: Option<String>,
auto_settle_threshold: Option<f64>,
announcement_banner: Option<String>,
) -> AppResult<()> {
let mut changes = Vec::new();
if brand_name.is_some() {
changes.push("brand_name");
}
if upi_id.is_some() {
changes.push("upi_id");
}
if social_url.is_some() {
changes.push("social_url");
}
if business_address.is_some() {
changes.push("business_address");
}
if base_pincode.is_some() {
changes.push("base_pincode");
}
if delivery_rate_per_km.is_some() {
changes.push("delivery_rate_per_km");
}
if delivery_base_fee.is_some() {
changes.push("delivery_base_fee");
}
if auto_settle_threshold.is_some() {
changes.push("auto_settle_threshold");
}
if announcement_banner.is_some() {
changes.push("announcement_banner");
}
self.merchant_repo
.update_profile(
merchant_id,
brand_name,
social_url,
upi_id,
business_address,
delivery_rate_per_km,
delivery_base_fee,
logistics_config,
base_pincode,
auto_settle_threshold,
announcement_banner,
)
.await?;
if let Ok(mut conn) = self.pool.acquire().await {
let details = if changes.is_empty() {
"Merchant profile settings updated".to_string()
} else {
format!("Merchant profile settings updated: {}", changes.join(", "))
};
crate::domain::audit::log_risk_event(
&mut conn,
None,
merchant_id,
"PROFILE_UPDATED",
"LOW",
Some(&details),
None,
None,
None,
Some(&self.tx),
)
.await;
}
Ok(())
}
async fn get_products(&self, merchant_id: &str) -> AppResult<Vec<ProductLink>> {
let profile = self.merchant_repo.find_by_id(merchant_id).await?;
let is_admin = profile
.map(|m| m.role == "DEVELOPER" || m.role == "ADMIN")
.unwrap_or(false);
let products = if is_admin {
sqlx::query_as::<_, ProductLink>(
"SELECT link_id, merchant_id, product_name, price_inr, image_data, expected_weight, link_views, inventory_count, is_unlimited, category, is_featured, sale_price_inr, sale_ends_at, created_at FROM product_links ORDER BY created_at DESC"
)
.fetch_all(&self.pool)
.await
.map_err(crate::domain::error::AppError::Database)?
} else {
self.product_repo.all_for_merchant(merchant_id).await?
};
let hydration_futures = products.into_iter().map(|mut p| async move {
p.image_data = crate::core::utils::hydrate_file_to_base64(p.image_data).await;
p
});
let hydrated = futures_util::future::join_all(hydration_futures).await;
Ok(hydrated)
}
async fn get_products_by_slug(&self, slug: &str) -> AppResult<Vec<ProductLink>> {
self.product_repo.find_by_slug(slug).await
}
async fn get_merchant_upi(&self, merchant_id: &str) -> AppResult<Option<String>> {
let profile = self.merchant_repo.find_by_id(merchant_id).await?;
Ok(profile.and_then(|p| p.upi_id))
}
async fn delete_link(&self, link_id: &str, merchant_id: &str) -> AppResult<()> {
let product_info = self.product_repo.find_by_id(link_id).await.ok().flatten();
let details = product_info
.map(|p| format!("Product link deleted: {} (ID: {})", p.product_name, link_id))
.unwrap_or_else(|| format!("Product link deleted: {}", link_id));
self.product_repo.delete(link_id, merchant_id).await?;
if let Ok(mut conn) = self.pool.acquire().await {
crate::domain::audit::log_risk_event(
&mut conn,
None,
merchant_id,
"PRODUCT_DELETED",
"LOW",
Some(&details),
None,
None,
None,
Some(&self.tx),
)
.await;
}
Ok(())
}
async fn create_link(
&self,
merchant_id: &str,
product_name: &str,
price_inr: f64,
image_data: Option<String>,
expected_weight: f64,
inventory_count: i32,
is_unlimited: bool,
category: Option<String>,
) -> AppResult<ProductLink> {
let merchant = self
.merchant_repo
.find_by_id(merchant_id)
.await?
.ok_or_else(|| AppError::NotFound("Merchant not found".to_string()))?;
if merchant.plan == "FREE" {
let active_products = self.product_repo.all_for_merchant(merchant_id).await?;
if active_products.len() >= 10 {
return Err(AppError::Forbidden(
"Product limit reached (10). Upgrade to PRO to create unlimited product links."
.to_string(),
));
}
}
let link_id = Uuid::new_v4().to_string();
let product = ProductLink {
link_id: link_id.clone(),
merchant_id: merchant_id.to_string(),
product_name: product_name.to_string(),
price_inr,
image_data,
expected_weight,
link_views: 0,
inventory_count,
is_unlimited,
category,
is_featured: false,
sale_price_inr: None,
sale_ends_at: None,
created_at: Some(chrono::Utc::now().naive_utc()),
};
self.product_repo.create(&product).await?;
if let Ok(mut conn) = self.pool.acquire().await {
crate::domain::audit::log_risk_event(
&mut conn,
None,
merchant_id,
"PRODUCT_CREATED",
"LOW",
Some(&format!(
"Product link created: {} (Price: ₹{})",
product_name, price_inr
)),
None,
None,
None,
Some(&self.tx),
)
.await;
}
Ok(product)
}
async fn update_inventory(
&self,
merchant_id: &str,
link_id: &str,
count: i32,
is_unlimited: bool,
) -> AppResult<()> {
self.product_repo
.update_inventory(link_id, merchant_id, count, is_unlimited)
.await?;
if let Ok(mut conn) = self.pool.acquire().await {
let details = if is_unlimited {
format!(
"Inventory updated to Unlimited for product (ID: {})",
link_id
)
} else {
format!(
"Inventory count updated to {} for product (ID: {})",
count, link_id
)
};
crate::domain::audit::log_risk_event(
&mut conn,
None,
merchant_id,
"PRODUCT_INVENTORY_UPDATED",
"LOW",
Some(&details),
None,
None,
None,
Some(&self.tx),
)
.await;
}
Ok(())
}
async fn get_orders(&self, merchant_id: &str) -> AppResult<Vec<OrderRecord>> {
let profile = self.merchant_repo.find_by_id(merchant_id).await?;
let is_admin = profile
.map(|m| m.role == "DEVELOPER" || m.role == "ADMIN")
.unwrap_or(false);
if is_admin {
let orders =
sqlx::query_as::<_, OrderRecord>("SELECT * FROM orders ORDER BY created_at DESC")
.fetch_all(&self.pool)
.await
.map_err(crate::domain::error::AppError::Database)?;
let decrypted: Vec<OrderRecord> = orders
.into_iter()
.map(|mut o| {
o.decrypt_pii();
if o.vpa.as_deref() == Some("") {
o.vpa = None;
}
o
})
.collect();
Ok(decrypted)
} else {
self.order_repo.all_for_merchant(merchant_id).await
}
}
async fn get_order(&self, merchant_id: &str, transaction_id: &str) -> AppResult<OrderRecord> {
let order = self.order_repo.find_by_id(transaction_id).await?;
let order = order.ok_or_else(|| AppError::NotFound("Order not found".into()))?;
let profile = self.merchant_repo.find_by_id(merchant_id).await?;
let is_admin = profile
.map(|m| m.role == "DEVELOPER" || m.role == "ADMIN")
.unwrap_or(false);
if !is_admin && order.merchant_id != merchant_id {
return Err(AppError::Forbidden("Access denied".into()));
}
Ok(order)
}
async fn get_customers(
&self,
merchant_id: &str,
) -> AppResult<Vec<crate::domain::models::CustomerRecord>> {
let profile = self.merchant_repo.find_by_id(merchant_id).await?;
let is_admin = profile
.map(|m| m.role == "DEVELOPER" || m.role == "ADMIN")
.unwrap_or(false);
let orders = if is_admin {
let orders = sqlx::query_as::<_, OrderRecord>("SELECT * FROM orders")
.fetch_all(&self.pool)
.await
.map_err(crate::domain::error::AppError::Database)?;
orders
.into_iter()
.map(|mut o| {
o.decrypt_pii();
o
})
.collect::<Vec<_>>()
} else {
self.order_repo.all_for_merchant(merchant_id).await?
};
// Group and aggregate in memory in Rust by plaintext phone
use std::collections::HashMap;
let mut customer_map: HashMap<String, crate::domain::models::CustomerRecord> =
HashMap::new();
for order in orders {
let phone = order.buyer_phone.clone();
let price = order.price_inr;
let order_date = order.created_at;
if let Some(record) = customer_map.get_mut(&phone) {
record.order_count += 1;
record.total_spent += price;
if let Some(date) = order_date {
if record.last_order_date.is_none() || Some(date) > record.last_order_date {
record.last_order_date = Some(date);
}
}
} else {
customer_map.insert(
phone.clone(),
crate::domain::models::CustomerRecord {
buyer_phone: phone,
order_count: 1,
total_spent: price,
last_order_date: order_date,
},
);
}
}
let mut customers: Vec<crate::domain::models::CustomerRecord> =
customer_map.into_values().collect();
// Sort by last order date descending
customers.sort_by(|a, b| b.last_order_date.cmp(&a.last_order_date));
Ok(customers)
}
async fn approve_settlement(
&self,
merchant_id: &str,
transaction_id: &str,
return_weight: Option<f64>,
request_id: Option<String>,
utr_number: Option<String>,
) -> AppResult<()> {
let mut order = self.get_order(merchant_id, transaction_id).await?;
// 1. Idempotency & State Guarding
if order.status == crate::domain::constants::ORDER_STATUS_SETTLED {
tracing::warn!(
"Settlement IDEMPOTENCY: Order {} already settled. Skipping.",
transaction_id
);
return Ok(());
}
let allowed_statuses = [
crate::domain::constants::ORDER_STATUS_DELIVERED_PENDING_APPROVAL,
crate::domain::constants::ORDER_STATUS_DISPUTED_HELD,
];
if !allowed_statuses.contains(&order.status.as_str()) {
return Err(AppError::BadRequest(
format!("Invalid State Transition: Cannot settle order in '{}' status. Manual review or delivery confirmation required.", order.status),
));
}
// 2. Weight Correction & UTR Capture
if let Some(w) = return_weight {
order.return_weight = w;
// Volumetric Exception Flow: Verify weight integrity (with 2% tolerance threshold)
if order.outbound_weight > 0.0 && !crate::core::utils::verify_volumetric_integrity(order.outbound_weight, w, 2.0) {
// Escalate to DISPUTED status and lock payout
let dispute_status = crate::domain::constants::ORDER_STATUS_DISPUTED.to_string();
let mut tx = self.pool.begin().await.map_err(AppError::Database)?;
sqlx::query("UPDATE orders SET status = $1, return_weight = $2 WHERE transaction_id = $3")
.bind(&dispute_status)
.bind(order.return_weight)
.bind(transaction_id)
.execute(&mut *tx)
.await?;
if let Some(ref rid) = request_id {
crate::domain::audit::log_risk_event(
&mut tx,
Some(transaction_id),
merchant_id,
"VOLUMETRIC_MISMATCH",
"HIGH",
Some(&format!(
"Volumetric exception: Outbound weight ({}g) and return weight ({}g) mismatch. Payout locked, dispute opened.",
order.outbound_weight, w
)),
Some((order.outbound_weight - w).abs()),
Some(rid),
order.device_fingerprint.as_deref(),
Some(&self.tx),
)
.await;
}
tx.commit().await.map_err(AppError::Database)?;
let _ = self.tx.send(RealtimeEvent::OrderStatusChanged {
transaction_id: transaction_id.to_string(),
merchant_id: merchant_id.to_string(),
new_status: dispute_status.clone(),
});
return Err(AppError::BadRequest(format!(
"Volumetric Exception: Outbound weight ({}g) and return weight ({}g) mismatch. Payout locked, order status moved to DISPUTED.",
order.outbound_weight, w
)));
}
}
if let Some(ref utr) = utr_number {
order.utr_number = Some(utr.clone());
}
let original_status = order.status.clone();
order.status = crate::domain::constants::ORDER_STATUS_SETTLED.to_string();
order.settled_at = Some(chrono::Utc::now().naive_utc());
let mut tx = self.pool.begin().await.map_err(AppError::Database)?;
// 3. Atomic Finality
sqlx::query("UPDATE orders SET status = $1, settled_at = $2, return_weight = $3, utr_number = $4 WHERE transaction_id = $5 AND status = $6")
.bind(&order.status)
.bind(order.settled_at)
.bind(order.return_weight)
.bind(&order.utr_number)
.bind(transaction_id)
.bind(&original_status)
.execute(&mut *tx)
.await?;
// 4. Institutional Ledger Entry
let (gross, platform, delivery, tax, net) = order.calculate_net_settlement();
sqlx::query(
r#"
INSERT INTO settlements (
transaction_id, merchant_id, gross_amount_inr, platform_fee_inr,
delivery_fee_inr, tax_amount_inr, net_payout_inr, utr_number
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
)
.bind(transaction_id)
.bind(merchant_id)
.bind(gross)
.bind(platform)
.bind(delivery)
.bind(tax)
.bind(net)
.bind(&order.utr_number)
.execute(&mut *tx)
.await?;
// 5. Institutional Audit Log
if let Some(rid) = request_id {
crate::domain::audit::log_risk_event(
&mut tx,
Some(transaction_id),
merchant_id,
"SETTLEMENT_FINALIZED",
"LOW",
Some("Settlement finalized manually by merchant. Net payout calculation locked."),
None,
Some(&rid),
order.device_fingerprint.as_deref(),
Some(&self.tx),
)
.await;
}
tx.commit().await.map_err(AppError::Database)?;
// Broadcast for real-time frontend updates
let _ = self.tx.send(RealtimeEvent::OrderStatusChanged {
transaction_id: transaction_id.to_string(),
merchant_id: merchant_id.to_string(),
new_status: order.status.clone(),
});
Ok(())
}
async fn mark_order_shipped(
&self,
merchant_id: &str,
transaction_id: &str,
shipping_method: Option<String>,
estimated_delivery_at: Option<chrono::NaiveDateTime>,
) -> AppResult<()> {
let order = self.get_order(merchant_id, transaction_id).await?;
if order.status != crate::domain::constants::ORDER_STATUS_PAID_PENDING_DELIVERY {
return Err(AppError::BadRequest(
"Order is not in a state that can be shipped".to_string(),
));
}
sqlx::query("UPDATE orders SET status = $1, shipped_at = CURRENT_TIMESTAMP, shipping_method = $2, estimated_delivery_at = $3 WHERE transaction_id = $4")
.bind(crate::domain::constants::ORDER_STATUS_DELIVERED_PENDING_APPROVAL)
.bind(shipping_method)
.bind(estimated_delivery_at)
.bind(transaction_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn bulk_mark_orders_shipped(
&self,
merchant_id: &str,
transaction_ids: Vec<String>,
) -> AppResult<()> {
self.order_repo
.bulk_mark_shipped(merchant_id, &transaction_ids)
.await
}
async fn delete_order(&self, merchant_id: &str, transaction_id: &str) -> AppResult<()> {
let order = self.get_order(merchant_id, transaction_id).await?;
self.order_repo.delete(&order.transaction_id).await
}
async fn dispute_order(
&self,
merchant_id: &str,
transaction_id: &str,
reason: String,
request_id: Option<String>,
) -> AppResult<()> {
let order = self.get_order(merchant_id, transaction_id).await?;
let mut tx = self.pool.begin().await.map_err(AppError::Database)?;
sqlx::query("UPDATE orders SET status = $1 WHERE transaction_id = $2")
.bind(crate::domain::constants::ORDER_STATUS_DISPUTED)
.bind(&order.transaction_id)
.execute(&mut *tx)
.await?;
if let Some(rid) = request_id {
crate::domain::audit::log_risk_event(
&mut tx,
Some(transaction_id),
merchant_id,
"ORDER_DISPUTED",
"MEDIUM",
Some(&format!("Merchant raised dispute: {}", reason)),
None,
Some(&rid),
order.device_fingerprint.as_deref(),
Some(&self.tx),
)
.await;
}
tx.commit().await.map_err(AppError::Database)?;
Ok(())
}
async fn get_storefront_profile(&self, slug: &str) -> AppResult<Option<StorefrontProfile>> {
let merchant = self.merchant_repo.find_by_slug(slug).await?;
if let Some(m) = merchant {
if m.is_frozen {
return Err(AppError::Forbidden(
"Merchant account is frozen due to unpaid outstanding invoices.".to_string(),
));
}
let stats = sqlx::query("SELECT AVG(rating)::FLOAT8 as avg_rating, COUNT(*)::BIGINT as review_count FROM product_feedback f JOIN orders o ON f.transaction_id = o.transaction_id WHERE o.merchant_id = $1 AND f.is_public = TRUE")
.bind(&m.merchant_id)
.fetch_one(&self.pool)
.await?;
use sqlx::Row;
Ok(Some(StorefrontProfile {
merchant_id: m.merchant_id,
brand_name: m.brand_name,
business_address: m.business_address,
base_pincode: Some(m.base_pincode),
upi_id: m.upi_id,
announcement_banner: m.announcement_banner,
avg_rating: stats.get::<Option<f64>, _>("avg_rating").unwrap_or(0.0),
review_count: stats.get::<Option<i64>, _>("review_count").unwrap_or(0),
}))
} else {
Ok(None)
}
}
async fn get_catalog(&self, slug: &str) -> AppResult<Vec<CatalogItem>> {
let products = self.product_repo.find_by_slug(slug).await?;
if products.is_empty() {
return Ok(vec![]);
}
let link_ids: Vec<Uuid> = products
.iter()
.map(|p| Uuid::parse_str(&p.link_id).unwrap_or_default())
.collect();
let stats_map = sqlx::query(
"SELECT product_id, AVG(rating)::FLOAT8 as avg_rating, COUNT(*)::BIGINT as review_count \
FROM product_feedback \
WHERE product_id = ANY($1) AND is_public = TRUE \
GROUP BY product_id"
)
.bind(&link_ids)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|r| {
use sqlx::Row;
(
r.get::<Uuid, _>("product_id"),
(r.get::<Option<f64>, _>("avg_rating").unwrap_or(0.0), r.get::<Option<i64>, _>("review_count").unwrap_or(0))
)
})
.collect::<std::collections::HashMap<_, _>>();
let catalog_futures = products.into_iter().map(|mut p| {
let stats_map_ref = &stats_map;
async move {
p.image_data = crate::core::utils::hydrate_file_to_base64(p.image_data).await;
let pid = Uuid::parse_str(&p.link_id).unwrap_or_default();
let (avg, count) = stats_map_ref.get(&pid).cloned().unwrap_or((0.0, 0));
CatalogItem {
product: p,
avg_rating: avg,
review_count: count,
}
}
});
let catalog = futures_util::future::join_all(catalog_futures).await;
Ok(catalog)
}
async fn get_analytics(
&self,
merchant_id: &str,
) -> AppResult<crate::domain::models::analytics::AnalyticsResponse> {
use futures_util::TryFutureExt;
use sqlx::Row;
let profile = self.merchant_repo.find_by_id(merchant_id).await?;
let is_admin = profile
.as_ref()
.map(|m| m.role == "DEVELOPER" || m.role == "ADMIN")
.unwrap_or(false);
let (merchant_opt, summary, total_views, risk_data, daily_rows, regional_rows) = if is_admin
{
tokio::try_join!(
self.merchant_repo.find_by_id(merchant_id),
sqlx::query(
r#"
SELECT
COALESCE(SUM(CASE WHEN status = $1 THEN price_inr ELSE 0 END), 0)::FLOAT8 as total_revenue,
COUNT(*)::BIGINT as total_orders,
COALESCE(SUM(platform_fee), 0)::FLOAT8 as platform_fee_total,
COALESCE(AVG(distance_km), 0)::FLOAT8 as avg_distance,
COUNT(*) FILTER (WHERE risk_score > 60)::BIGINT as risk_mitigation_count
FROM orders
WHERE status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED')
"#
)
.bind(crate::domain::constants::ORDER_STATUS_SETTLED)
.fetch_one(&self.pool)
.map_err(crate::domain::error::AppError::Database),
sqlx::query_scalar(
"SELECT COALESCE(SUM(link_views), 0)::BIGINT FROM product_links",
)
.fetch_one(&self.pool)
.map_err(crate::domain::error::AppError::Database),
sqlx::query(
r#"
SELECT
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) <= 25)::BIGINT as low,
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) > 25 AND COALESCE(risk_score, 0) <= 50)::BIGINT as medium,
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) > 50 AND COALESCE(risk_score, 0) <= 75)::BIGINT as high,
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) > 75)::BIGINT as critical
FROM orders
WHERE status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED')
"#
)
.fetch_one(&self.pool)
.map_err(crate::domain::error::AppError::Database),
sqlx::query(
r#"
SELECT
TO_CHAR(created_at, 'YYYY-MM-DD') as day,
COALESCE(SUM(price_inr), 0)::FLOAT8 as revenue,
COUNT(*)::BIGINT as order_count,
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) > 60)::BIGINT as high_risk_count,
COALESCE(SUM(price_inr * (1.0 - (COALESCE(risk_score, 0) / 100.0))), 0)::FLOAT8 as risk_weighted_volume
FROM orders
WHERE status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED') AND created_at > CURRENT_DATE - INTERVAL '30 days'
GROUP BY day
ORDER BY day ASC
"#
)
.fetch_all(&self.pool)
.map_err(crate::domain::error::AppError::Database),
sqlx::query(
r#"
SELECT
SUBSTRING(shipping_pincode, 1, 3) as prefix,
COUNT(*)::BIGINT as order_count,
COALESCE(SUM(price_inr), 0)::FLOAT8 as total_revenue,
COALESCE(AVG(distance_km), 0)::FLOAT8 as avg_distance
FROM orders
WHERE status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED') AND shipping_pincode IS NOT NULL
GROUP BY prefix
ORDER BY order_count DESC
LIMIT 10
"#,
)
.fetch_all(&self.pool)
.map_err(crate::domain::error::AppError::Database),
)?
} else {
tokio::try_join!(
self.merchant_repo.find_by_id(merchant_id),
sqlx::query(
r#"
SELECT
COALESCE(SUM(CASE WHEN status = $1 THEN price_inr ELSE 0 END), 0)::FLOAT8 as total_revenue,
COUNT(*)::BIGINT as total_orders,
COALESCE(SUM(platform_fee), 0)::FLOAT8 as platform_fee_total,
COALESCE(AVG(distance_km), 0)::FLOAT8 as avg_distance,
COUNT(*) FILTER (WHERE risk_score > 60)::BIGINT as risk_mitigation_count
FROM orders
WHERE merchant_id = $2 AND status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED')
"#
)
.bind(crate::domain::constants::ORDER_STATUS_SETTLED)
.bind(merchant_id)
.fetch_one(&self.pool)
.map_err(crate::domain::error::AppError::Database),
sqlx::query_scalar(
"SELECT COALESCE(SUM(link_views), 0)::BIGINT FROM product_links WHERE merchant_id = $1",
)
.bind(merchant_id)
.fetch_one(&self.pool)
.map_err(crate::domain::error::AppError::Database),
sqlx::query(
r#"
SELECT
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) <= 25)::BIGINT as low,
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) > 25 AND COALESCE(risk_score, 0) <= 50)::BIGINT as medium,
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) > 50 AND COALESCE(risk_score, 0) <= 75)::BIGINT as high,
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) > 75)::BIGINT as critical
FROM orders WHERE merchant_id = $1 AND status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED')
"#
)
.bind(merchant_id)
.fetch_one(&self.pool)
.map_err(crate::domain::error::AppError::Database),
sqlx::query(
r#"
SELECT
TO_CHAR(created_at, 'YYYY-MM-DD') as day,
COALESCE(SUM(price_inr), 0)::FLOAT8 as revenue,
COUNT(*)::BIGINT as order_count,
COUNT(*) FILTER (WHERE COALESCE(risk_score, 0) > 60)::BIGINT as high_risk_count,
COALESCE(SUM(price_inr * (1.0 - (COALESCE(risk_score, 0) / 100.0))), 0)::FLOAT8 as risk_weighted_volume
FROM orders
WHERE merchant_id = $1 AND status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED') AND created_at > CURRENT_DATE - INTERVAL '30 days'
GROUP BY day
ORDER BY day ASC
"#
)
.bind(merchant_id)
.fetch_all(&self.pool)
.map_err(crate::domain::error::AppError::Database),
sqlx::query(
r#"
SELECT
SUBSTRING(shipping_pincode, 1, 3) as prefix,
COUNT(*)::BIGINT as order_count,
COALESCE(SUM(price_inr), 0)::FLOAT8 as total_revenue,
COALESCE(AVG(distance_km), 0)::FLOAT8 as avg_distance
FROM orders
WHERE merchant_id = $1 AND status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED') AND shipping_pincode IS NOT NULL
GROUP BY prefix
ORDER BY order_count DESC
LIMIT 10
"#,
)
.bind(merchant_id)
.fetch_all(&self.pool)
.map_err(crate::domain::error::AppError::Database),
)?
};
let merchant = merchant_opt
.ok_or_else(|| crate::domain::error::AppError::NotFound("Merchant not found".into()))?;
let total_revenue: f64 = summary.get("total_revenue");
let total_orders: i64 = summary.get("total_orders");
let platform_fee_total: f64 = summary.get("platform_fee_total");
let avg_distance: f64 = summary.get("avg_distance");
let risk_mitigation_count: i64 = summary.get("risk_mitigation_count");
let conversion_rate = if total_views > 0 {
(total_orders as f64 / total_views as f64) * 100.0
} else {
0.0
};
let risk_distribution = crate::domain::models::analytics::RiskDistribution {
low: risk_data.get::<i64, _>("low"),
medium: risk_data.get::<i64, _>("medium"),
high: risk_data.get::<i64, _>("high"),
critical: risk_data.get::<i64, _>("critical"),
};
let daily_metrics = daily_rows
.into_iter()
.map(|r| crate::domain::models::analytics::DailyMetric {
day: r.get("day"),
revenue: r.get::<f64, _>("revenue"),
order_count: r.get::<i64, _>("order_count"),
high_risk_count: r.get::<i64, _>("high_risk_count"),
risk_weighted_volume: r.get::<f64, _>("risk_weighted_volume"),
})
.collect();
let regional_metrics = regional_rows
.into_iter()
.map(|r| crate::domain::models::analytics::RegionalMetric {
pincode_prefix: r.get::<Option<String>, _>("prefix").unwrap_or_default(),
order_count: r.get("order_count"),
total_revenue: r.get("total_revenue"),
avg_distance: r.get("avg_distance"),
})
.collect();
Ok(crate::domain::models::analytics::AnalyticsResponse {
total_revenue,
total_orders,
total_views,
conversion_rate,
daily_metrics,
regional_metrics: if merchant.plan == "PRO" {
regional_metrics
} else {
vec![]
},
risk_mitigation_count,
risk_distribution,
platform_fee_total,
abandoned_cart_rate: 0.0, // Future: Track checkout drops
average_order_value: if total_orders > 0 {
total_revenue / total_orders as f64
} else {
0.0
},
avg_distance,
plan: merchant.plan,
})
}
async fn reset_account(&self, merchant_id: &str) -> AppResult<()> {
self.merchant_repo.reset_account(merchant_id).await?;
if let Ok(mut conn) = self.pool.acquire().await {
crate::domain::audit::log_risk_event(
&mut conn,
None,
merchant_id,
"ACCOUNT_RESET",
"LOW",
Some("Merchant account data has been reset to defaults."),
None,
None,
None,
Some(&self.tx),
)
.await;
}
Ok(())
}
async fn submit_feedback(
&self,
merchant_id: &str,
category: &str,
message: &str,
) -> AppResult<()> {
sqlx::query("INSERT INTO feedback (merchant_id, category, message) VALUES ($1, $2, $3)")
.bind(merchant_id)
.bind(category)
.bind(message)
.execute(&self.pool)
.await?;
if let Ok(mut conn) = self.pool.acquire().await {
crate::domain::audit::log_risk_event(
&mut conn,
None,
merchant_id,
"FEEDBACK_SUBMITTED",
"LOW",
Some(&format!(
"Merchant feedback submitted for category: {}",
category
)),
None,
None,
None,
Some(&self.tx),
)
.await;
}
Ok(())
}
async fn upgrade_plan(&self, merchant_id: &str, plan: &str) -> AppResult<()> {
sqlx::query("UPDATE merchants SET plan = $1 WHERE merchant_id = $2")
.bind(plan)
.bind(merchant_id)
.execute(&self.pool)
.await?;
tracing::info!("Merchant {} upgraded to {} plan", merchant_id, plan);
if let Ok(mut conn) = self.pool.acquire().await {
crate::domain::audit::log_risk_event(
&mut conn,
None,
merchant_id,
"PLAN_UPGRADED",
"LOW",
Some(&format!("Subscription plan upgraded to {}", plan)),
None,
None,
None,
Some(&self.tx),
)
.await;
}
Ok(())
}
async fn calculate_credit_worthiness(&self, merchant_id: &str) -> AppResult<serde_json::Value> {
let orders = sqlx::query(
"SELECT COUNT(*) as total, \
COUNT(*) FILTER (WHERE status = $1) as settled, \
COUNT(*) FILTER (WHERE status = $2) as disputed, \
SUM(price_inr) FILTER (WHERE status = $1) as total_revenue \
FROM orders WHERE merchant_id = $3 AND status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED')",
)
.bind(crate::domain::constants::ORDER_STATUS_SETTLED)
.bind(crate::domain::constants::ORDER_STATUS_DISPUTED)
.bind(merchant_id)
.fetch_one(&self.pool)
.await?;
use sqlx::Row;
let total: i64 = orders.get("total");
let settled: i64 = orders.get("settled");
let disputed: i64 = orders.get("disputed");
let revenue: f64 = orders.get::<Option<f64>, _>("total_revenue").unwrap_or(0.0);
let settlement_rate = if total > 0 {
settled as f64 / total as f64
} else {
0.0
};
let dispute_rate = if total > 0 {
disputed as f64 / total as f64
} else {
0.0
};
// Base Score (Starting at 300)
let mut score = 300.0;
// Volume Bonus
score += (total as f64 * 2.0).min(200.0);
// Settlement Reliability Bonus
score += settlement_rate * 300.0;
// Dispute Penalty
score -= dispute_rate * 500.0;
// Revenue Multiplier
score += (revenue / 10000.0).min(100.0);
let final_score = score.clamp(300.0, 900.0);
let tier = if final_score > 800.0 {
"PLATINUM"
} else if final_score > 650.0 {
"GOLD"
} else {
"SILVER"
};
Ok(serde_json::json!({
"score": final_score.round(),
"tier": tier,
"metrics": {
"settlement_reliability": settlement_rate * 100.0,
"dispute_incidence": dispute_rate * 100.0,
"total_reconciled_volume": revenue
},
"credit_limit_multiplier": if tier == "PLATINUM" { 5.0 } else if tier == "GOLD" { 2.5 } else { 1.0 }
}))
}
async fn get_dispute_evidence(
&self,
_merchant_id: &str,
transaction_id: &str,
) -> AppResult<Vec<crate::application::services::arbitration::DisputeEvidence>> {
let evidences = sqlx::query_as::<_, crate::application::services::arbitration::DisputeEvidence>(
"SELECT evidence_id, transaction_id, evidence_url, uploader_role, metadata FROM dispute_evidence WHERE transaction_id = $1"
)
.bind(transaction_id)
.fetch_all(&self.pool)
.await?;
Ok(evidences)
}
async fn upload_dispute_evidence(
&self,
_merchant_id: &str,
transaction_id: &str,
evidence_url: String,
) -> AppResult<Uuid> {
let arbitration =
crate::application::services::arbitration::ArbitrationService::new(self.pool.clone());
arbitration
.upload_evidence(transaction_id, &evidence_url, "MERCHANT")
.await
}
async fn resolve_dispute(
&self,
_merchant_id: &str,
transaction_id: &str,
resolution: String,
) -> AppResult<()> {
let arbitration =
crate::application::services::arbitration::ArbitrationService::new(self.pool.clone());
arbitration
.resolve_dispute(transaction_id, &resolution)
.await
}
async fn get_growth_insights(&self, merchant_id: &str) -> AppResult<serde_json::Value> {
let top_products = sqlx::query(
"SELECT p.product_name, COUNT(o.transaction_id) as order_count, SUM(o.price_inr) as revenue \
FROM product_links p \
LEFT JOIN orders o ON p.link_id = o.link_id \
WHERE p.merchant_id = $1 AND (o.status = $2 OR o.status IS NULL) \
GROUP BY p.product_name \
ORDER BY revenue DESC NULLS LAST \
LIMIT 5"
)
.bind(merchant_id)
.bind(crate::domain::constants::ORDER_STATUS_SETTLED)
.fetch_all(&self.pool)
.await?;
let coupon_usage = sqlx::query(
"SELECT coupon_code, COUNT(*) as usage_count, SUM(discount_amount) as total_savings \
FROM orders \
WHERE merchant_id = $1 AND status NOT IN ('PENDING_UPI_SETTLEMENT', 'PAYMENT_FAILED') AND coupon_code IS NOT NULL \
GROUP BY coupon_code \
ORDER BY usage_count DESC",
)
.bind(merchant_id)
.fetch_all(&self.pool)
.await?;
use sqlx::Row;
let daily_revenue = sqlx::query(
"SELECT DATE(created_at) as date, SUM(price_inr) as revenue \
FROM orders \
WHERE merchant_id = $1 AND status = $2 AND created_at > CURRENT_DATE - INTERVAL '30 days' \
GROUP BY DATE(created_at) \
ORDER BY date ASC"
)
.bind(merchant_id)
.bind(crate::domain::constants::ORDER_STATUS_SETTLED)
.fetch_all(&self.pool)
.await?;
let revenues: Vec<f64> = daily_revenue
.iter()
.map(|r| r.get::<f64, _>("revenue"))
.collect();
let avg_daily_rev = if !revenues.is_empty() {
revenues.iter().sum::<f64>() / revenues.len() as f64
} else {
0.0
};
let forecasted_revenue = avg_daily_rev * 30.0;
let volatility = if revenues.len() > 1 {
let mean = avg_daily_rev;
let variance =
revenues.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / revenues.len() as f64;
variance.sqrt() / mean.max(1.0)
} else {
0.5
};
let confidence = (1.0 - volatility).clamp(0.0, 1.0) * 100.0;
Ok(serde_json::json!({
"top_products": top_products.into_iter().map(|p| serde_json::json!({
"name": p.get::<String, _>("product_name"),
"orders": p.get::<i64, _>("order_count"),
"revenue": p.get::<Option<f64>, _>("revenue").unwrap_or(0.0)
})).collect::<Vec<_>>(),
"coupon_performance": coupon_usage.into_iter().map(|c| serde_json::json!({
"code": c.get::<String, _>("coupon_code"),
"usage": c.get::<i64, _>("usage_count"),
"savings": c.get::<Option<f64>, _>("total_savings").unwrap_or(0.0)
})).collect::<Vec<_>>(),
"forecasting": {
"next_30_days_projected_revenue": forecasted_revenue,
"confidence_score": confidence,
"growth_velocity": if avg_daily_rev > 0.0 { "STABLE" } else { "IDLE" }
},
"retention_rate": 0.0,
}))
}
async fn get_accounting_summary(&self, _merchant_id: &str) -> AppResult<serde_json::Value> {
Ok(serde_json::json!({
"total_payouts": 0.0,
"pending_settlements": 0.0,
"tax_liability": 0.0
}))
}
async fn generate_gst_report(
&self,
merchant_id: &str,
month: u32,
year: i32,
) -> AppResult<String> {
let orders = sqlx::query_as::<_, crate::domain::models::OrderRecord>(
"SELECT * FROM orders WHERE merchant_id = $1 AND EXTRACT(MONTH FROM created_at) = $2 AND EXTRACT(YEAR FROM created_at) = $3 AND status = $4"
)
.bind(merchant_id)
.bind(month as f64)
.bind(year as f64)
.bind(crate::domain::constants::ORDER_STATUS_SETTLED)
.fetch_all(&self.pool)
.await?;
let mut csv =
"Date,Transaction ID,Buyer,Gross Price,CGST,SGST,IGST,Total Tax,Net Settlement\n"
.to_string();
for o in orders {
let tax = o.cgst + o.sgst + o.igst;
let net = o.price_inr - (o.platform_fee + o.delivery_fee);
csv.push_str(&format!(
"{},{},{},{:.2},{:.2},{:.2},{:.2},{:.2},{:.2}\n",
o.created_at
.map(|d| d.date().to_string())
.unwrap_or_default(),
o.transaction_id,
o.buyer_name,
o.price_inr,
o.cgst,
o.sgst,
o.igst,
tax,
net
));
}
Ok(csv)
}
async fn create_coupon(
&self,
merchant_id: &str,
coupon: crate::domain::models::Coupon,
) -> AppResult<()> {
let code = coupon.code.clone();
let val = coupon.discount_value;
let dtype = coupon.discount_type.clone();
self.coupon_repo.create(&coupon).await?;
if let Ok(mut conn) = self.pool.acquire().await {
crate::domain::audit::log_risk_event(
&mut conn,
None,
merchant_id,
"COUPON_CREATED",
"LOW",
Some(&format!(
"Coupon created: {} (Discount: {} {})",
code, val, dtype
)),
None,
None,
None,
Some(&self.tx),
)
.await;
}
Ok(())
}
async fn get_coupons(
&self,
merchant_id: &str,
) -> AppResult<Vec<crate::domain::models::Coupon>> {
self.coupon_repo.all_for_merchant(merchant_id).await
}
async fn delete_coupon(&self, merchant_id: &str, coupon_id: &uuid::Uuid) -> AppResult<()> {
self.coupon_repo.delete(merchant_id, coupon_id).await?;
if let Ok(mut conn) = self.pool.acquire().await {
crate::domain::audit::log_risk_event(
&mut conn,
None,
merchant_id,
"COUPON_DELETED",
"LOW",
Some(&format!("Coupon deleted: {}", coupon_id)),
None,
None,
None,
Some(&self.tx),
)
.await;
}
Ok(())
}
async fn validate_coupon(
&self,
merchant_id: &str,
code: &str,
amount: f64,
) -> AppResult<crate::domain::models::Coupon> {
let coupon = self.coupon_repo.find_by_code(merchant_id, code).await?;
let coupon =
coupon.ok_or_else(|| AppError::NotFound("Coupon not found or inactive".into()))?;
if !coupon.is_valid(amount) {
return Err(AppError::BadRequest(
"Coupon conditions not met or expired".into(),
));
}
Ok(coupon)
}
async fn get_product_reviews(
&self,
product_id: &str,
) -> AppResult<Vec<crate::domain::models::ProductFeedback>> {
let reviews = sqlx::query_as::<_, crate::domain::models::ProductFeedback>(
"SELECT * FROM product_feedback WHERE product_id = $1 AND is_public = TRUE ORDER BY created_at DESC"
)
.bind(product_id)
.fetch_all(&self.pool)
.await?;
Ok(reviews)
}
async fn get_order_invoice(
&self,
merchant_id: &str,
transaction_id: &str,
) -> AppResult<String> {
let order = self
.order_repo
.find_by_id(transaction_id)
.await?
.ok_or_else(|| AppError::NotFound("Order not found".into()))?;
if order.merchant_id != merchant_id {
return Err(AppError::Forbidden("Access denied to this order".into()));
}
let merchant = self
.merchant_repo
.find_by_id(&order.merchant_id)
.await?
.ok_or_else(|| AppError::NotFound("Merchant not found".into()))?;
Ok(
crate::application::services::billing::BillingService::generate_invoice_html(
&order, &merchant,
),
)
}
async fn get_financial_ledger(&self, merchant_id: &str) -> AppResult<Vec<LedgerRecord>> {
let orders = self.order_repo.all_for_merchant(merchant_id).await?;
let ledger = orders
.into_iter()
.map(|order| {
let (gross, platform, delivery, tax, net) = order.calculate_net_settlement();
LedgerRecord {
transaction_id: order.transaction_id,
created_at: order.created_at,
status: order.status,
gross_amount: gross,
platform_fee: platform,
delivery_fee: delivery,
tax_amount: tax,
net_settlement: net,
settled_at: order.settled_at,
utr_number: order.utr_number,
}
})
.collect();
Ok(ledger)
}
}