Skip to content

Commit 23730e4

Browse files
authored
features: payment_analytics-multi_currency-payment_reconciliation-advanced_metrics (#239)
1 parent 19f9a4d commit 23730e4

18 files changed

Lines changed: 1434 additions & 39 deletions

.DS_Store

0 Bytes
Binary file not shown.

backend/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- Multi-Currency Support
2+
-- Creates tables for storing exchange rates and currency-related data.
3+
4+
-- -------------------------------------------------------------------------
5+
-- exchange_rates
6+
-- -------------------------------------------------------------------------
7+
CREATE TABLE IF NOT EXISTS exchange_rates (
8+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
9+
from_currency VARCHAR(3) NOT NULL,
10+
to_currency VARCHAR(3) NOT NULL,
11+
rate NUMERIC(20, 10) NOT NULL,
12+
source VARCHAR(100),
13+
last_updated TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
14+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
15+
16+
CONSTRAINT chk_currency_code CHECK (
17+
from_currency IN ('USD', 'EUR', 'GBP', 'JPY') AND
18+
to_currency IN ('USD', 'EUR', 'GBP', 'JPY')
19+
),
20+
CONSTRAINT chk_rate_positive CHECK (rate > 0),
21+
CONSTRAINT uq_currency_pair UNIQUE (from_currency, to_currency)
22+
);
23+
24+
-- -------------------------------------------------------------------------
25+
-- Indexes
26+
-- -------------------------------------------------------------------------
27+
CREATE INDEX IF NOT EXISTS idx_exchange_rates_currency_pair
28+
ON exchange_rates(from_currency, to_currency);
29+
30+
CREATE INDEX IF NOT EXISTS idx_exchange_rates_last_updated
31+
ON exchange_rates(last_updated DESC);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- Payment Analytics
2+
-- Creates tables for storing aggregated analytics data for performance.
3+
4+
-- -------------------------------------------------------------------------
5+
-- payment_analytics_daily
6+
-- -------------------------------------------------------------------------
7+
CREATE TABLE IF NOT EXISTS payment_analytics_daily (
8+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
9+
merchant_id VARCHAR(255),
10+
date DATE NOT NULL,
11+
total_payments BIGINT NOT NULL DEFAULT 0,
12+
total_amount NUMERIC(20, 10) NOT NULL DEFAULT 0,
13+
successful_payments BIGINT NOT NULL DEFAULT 0,
14+
failed_payments BIGINT NOT NULL DEFAULT 0,
15+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
16+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
17+
18+
CONSTRAINT uq_merchant_date UNIQUE (merchant_id, date)
19+
);
20+
21+
-- -------------------------------------------------------------------------
22+
-- Indexes
23+
-- -------------------------------------------------------------------------
24+
CREATE INDEX IF NOT EXISTS idx_payment_analytics_merchant_date
25+
ON payment_analytics_daily(merchant_id, date DESC);
26+
27+
CREATE INDEX IF NOT EXISTS idx_payment_analytics_date
28+
ON payment_analytics_daily(date DESC);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
-- Payment Reconciliation
2+
-- Creates tables for payment reconciliation and audit logs.
3+
4+
-- -------------------------------------------------------------------------
5+
-- payment_reconciliations
6+
-- -------------------------------------------------------------------------
7+
CREATE TABLE IF NOT EXISTS payment_reconciliations (
8+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
9+
payment_id UUID,
10+
external_id VARCHAR(255),
11+
source VARCHAR(50) NOT NULL,
12+
amount BIGINT NOT NULL,
13+
currency VARCHAR(10) NOT NULL,
14+
status VARCHAR(50) NOT NULL DEFAULT 'pending',
15+
discrepancy_notes TEXT,
16+
resolved_by UUID,
17+
resolved_at TIMESTAMP WITH TIME ZONE,
18+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
19+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
20+
21+
CONSTRAINT chk_source CHECK (source IN ('stellar', 'bank', 'manual')),
22+
CONSTRAINT chk_status CHECK (status IN ('pending', 'matched', 'mismatched', 'manual_review', 'resolved'))
23+
);
24+
25+
-- -------------------------------------------------------------------------
26+
-- reconciliation_audit_logs
27+
-- -------------------------------------------------------------------------
28+
CREATE TABLE IF NOT EXISTS reconciliation_audit_logs (
29+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
30+
reconciliation_id UUID NOT NULL REFERENCES payment_reconciliations(id) ON DELETE CASCADE,
31+
actor_id UUID NOT NULL,
32+
action VARCHAR(100) NOT NULL,
33+
old_status VARCHAR(50),
34+
new_status VARCHAR(50),
35+
notes TEXT,
36+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
37+
38+
CONSTRAINT chk_action_status CHECK (old_status IN ('pending', 'matched', 'mismatched', 'manual_review', 'resolved') OR old_status IS NULL),
39+
CONSTRAINT chk_new_status CHECK (new_status IN ('pending', 'matched', 'mismatched', 'manual_review', 'resolved') OR new_status IS NULL)
40+
);
41+
42+
-- -------------------------------------------------------------------------
43+
-- Indexes
44+
-- -------------------------------------------------------------------------
45+
CREATE INDEX IF NOT EXISTS idx_payment_reconciliations_status
46+
ON payment_reconciliations(status);
47+
48+
CREATE INDEX IF NOT EXISTS idx_payment_reconciliations_payment_id
49+
ON payment_reconciliations(payment_id);
50+
51+
CREATE INDEX IF NOT EXISTS idx_payment_reconciliations_external_id
52+
ON payment_reconciliations(external_id);
53+
54+
CREATE INDEX IF NOT EXISTS idx_payment_reconciliations_created_at
55+
ON payment_reconciliations(created_at DESC);
56+
57+
CREATE INDEX IF NOT EXISTS idx_reconciliation_audit_logs_reconciliation_id
58+
ON reconciliation_audit_logs(reconciliation_id);
59+
60+
CREATE INDEX IF NOT EXISTS idx_reconciliation_audit_logs_created_at
61+
ON reconciliation_audit_logs(created_at DESC);

backend/src/app.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ use tower_http::{cors::CorsLayer, trace::TraceLayer};
1010
use crate::{
1111
config::Config,
1212
http::{
13-
admin, audit, auth, batches, disputes, files, health, identity, jobs, metrics as metrics_http,
14-
notifications, payments, profiles, transfers, version as version_http, withdrawals,
13+
admin, analytics, audit, auth, batches, currency, disputes, files, health, identity, jobs,
14+
metrics as metrics_http, notifications, payments, profiles, transfers, version as version_http,
15+
withdrawals,
16+
},
17+
http::{
18+
get_reconciliation_audit_log, get_reconciliations, resolve_reconciliation,
19+
run_reconciliation,
1520
},
1621
job_worker::JobWorker,
1722
middleware::{
@@ -132,6 +137,26 @@ pub async fn create_app(
132137
.route("/audit-logs/:id", get(audit::get_audit_log))
133138
.layer(middleware::from_fn(role_guard::admin_only()));
134139

140+
// -------------------- Analytics --------------------
141+
let analytics_routes = Router::new()
142+
.route("/analytics/payments", get(analytics::get_payment_analytics))
143+
.route("/analytics/merchant/:merchant_id", get(analytics::get_merchant_performance))
144+
.route("/analytics/report", post(analytics::generate_custom_report))
145+
.route("/analytics/export", post(analytics::export_to_csv));
146+
147+
// -------------------- Reconciliation --------------------
148+
let reconciliation_routes = Router::new()
149+
.route("/reconciliation/run", post(run_reconciliation))
150+
.route("/reconciliation", get(get_reconciliations))
151+
.route("/reconciliation/:id/resolve", post(resolve_reconciliation))
152+
.route("/reconciliation/:id/audit", get(get_reconciliation_audit_log));
153+
154+
// -------------------- Currency --------------------
155+
let currency_routes = Router::new()
156+
.route("/currency/convert", get(currency::convert_currency))
157+
.route("/currency/rates/:from/:to", get(currency::get_exchange_rate))
158+
.route("/currency/supported", get(currency::get_supported_currencies));
159+
135160
// -------------------- Batches --------------------
136161
let batch_routes = Router::new()
137162
.route("/batches", post(batches::create_batch))
@@ -189,7 +214,10 @@ pub async fn create_app(
189214
.nest("/files", files_routes)
190215
.nest("/batches", batch_routes)
191216
.nest("/admin", admin_routes)
192-
.nest("/audit", audit_routes);
217+
.nest("/audit", audit_routes)
218+
.nest("/", currency_routes)
219+
.nest("/", analytics_routes)
220+
.nest("/", reconciliation_routes);
193221

194222
// v2-only protected routes (disputes)
195223
let v2_only_protected = Router::new()

backend/src/http/analytics.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use axum::{
2+
extract::{Path, Query, State},
3+
http::StatusCode,
4+
response::IntoResponse,
5+
Json,
6+
};
7+
use chrono::{DateTime, Duration, Utc};
8+
use serde::Deserialize;
9+
use std::sync::Arc;
10+
11+
use crate::models::Payment;
12+
use crate::service::{
13+
CustomReportRequest, MerchantPerformance, PaymentAnalytics, ServiceContainer,
14+
};
15+
16+
#[derive(Debug, Deserialize)]
17+
pub struct AnalyticsQuery {
18+
pub merchant_id: Option<String>,
19+
pub start_date: Option<DateTime<Utc>>,
20+
pub end_date: Option<DateTime<Utc>>,
21+
}
22+
23+
/// Get payment analytics
24+
pub async fn get_payment_analytics(
25+
State(services): State<Arc<ServiceContainer>>,
26+
Query(query): Query<AnalyticsQuery>,
27+
) -> Result<Json<PaymentAnalytics>, (StatusCode, String)> {
28+
let end_date = query.end_date.unwrap_or_else(Utc::now);
29+
let start_date = query.start_date.unwrap_or_else(|| end_date - Duration::days(30));
30+
31+
let analytics = services
32+
.analytics
33+
.get_payment_analytics(query.merchant_id, start_date, end_date)
34+
.await
35+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
36+
37+
Ok(Json(analytics))
38+
}
39+
40+
/// Get merchant performance dashboard
41+
pub async fn get_merchant_performance(
42+
State(services): State<Arc<ServiceContainer>>,
43+
Path(merchant_id): Path<String>,
44+
Query(query): Query<MerchantPerformanceQuery>,
45+
) -> Result<Json<MerchantPerformance>, (StatusCode, String)> {
46+
let days = query.days.unwrap_or(30);
47+
48+
let performance = services
49+
.analytics
50+
.get_merchant_performance(merchant_id, days)
51+
.await
52+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
53+
54+
Ok(Json(performance))
55+
}
56+
57+
#[derive(Debug, Deserialize)]
58+
pub struct MerchantPerformanceQuery {
59+
pub days: Option<i64>,
60+
}
61+
62+
/// Generate custom report
63+
pub async fn generate_custom_report(
64+
State(services): State<Arc<ServiceContainer>>,
65+
Json(request): Json<CustomReportRequest>,
66+
) -> Result<Json<Vec<Payment>>, (StatusCode, String)> {
67+
let payments = services
68+
.analytics
69+
.generate_custom_report(request)
70+
.await
71+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
72+
73+
Ok(Json(payments))
74+
}
75+
76+
/// Export data to CSV
77+
pub async fn export_to_csv(
78+
State(services): State<Arc<ServiceContainer>>,
79+
Json(request): Json<CustomReportRequest>,
80+
) -> Result<impl IntoResponse, (StatusCode, String)> {
81+
let csv = services
82+
.analytics
83+
.export_to_csv(request)
84+
.await
85+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
86+
87+
Ok((
88+
StatusCode::OK,
89+
[(axum::http::header::CONTENT_TYPE, "text/csv")],
90+
csv,
91+
))
92+
}

backend/src/http/batches.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ pub async fn process_batch(
200200
Path(batch_id): Path<String>,
201201
) -> Result<Json<BatchReportResponse>, ApiError> {
202202
// Verify batch exists
203-
let batch = services
203+
let _batch = services
204204
.batch
205205
.get_batch(&batch_id)
206206
.await?

backend/src/http/currency.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
use axum::{
2+
extract::{Path, Query, State},
3+
http::StatusCode,
4+
Json,
5+
};
6+
use serde::{Deserialize, Serialize};
7+
use std::str::FromStr;
8+
use std::sync::Arc;
9+
10+
use crate::service::{Currency, ServiceContainer};
11+
12+
#[derive(Debug, Deserialize)]
13+
pub struct ConvertQuery {
14+
amount: i64,
15+
from: String,
16+
to: String,
17+
}
18+
19+
#[derive(Debug, Serialize)]
20+
pub struct ConvertResponse {
21+
original_amount: i64,
22+
original_currency: Currency,
23+
converted_amount: i64,
24+
converted_currency: Currency,
25+
rate: f64,
26+
}
27+
28+
#[derive(Debug, Serialize)]
29+
pub struct CurrenciesResponse {
30+
currencies: Vec<CurrencyInfo>,
31+
}
32+
33+
#[derive(Debug, Serialize)]
34+
pub struct CurrencyInfo {
35+
code: String,
36+
name: String,
37+
}
38+
39+
/// Convert an amount from one currency to another
40+
pub async fn convert_currency(
41+
State(services): State<Arc<ServiceContainer>>,
42+
Query(query): Query<ConvertQuery>,
43+
) -> Result<Json<ConvertResponse>, (StatusCode, String)> {
44+
let from = Currency::from_str(&query.from)
45+
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid 'from' currency".to_string()))?;
46+
let to = Currency::from_str(&query.to)
47+
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid 'to' currency".to_string()))?;
48+
49+
let converted_amount = services
50+
.currency
51+
.convert(query.amount, from, to)
52+
.await
53+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
54+
55+
let rate = services
56+
.currency
57+
.get_exchange_rate(from, to)
58+
.await
59+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
60+
61+
Ok(Json(ConvertResponse {
62+
original_amount: query.amount,
63+
original_currency: from,
64+
converted_amount,
65+
converted_currency: to,
66+
rate: rate.rate,
67+
}))
68+
}
69+
70+
/// Get exchange rate between two currencies
71+
pub async fn get_exchange_rate(
72+
State(services): State<Arc<ServiceContainer>>,
73+
Path((from, to)): Path<(String, String)>,
74+
) -> Result<Json<crate::service::ExchangeRate>, (StatusCode, String)> {
75+
let from_currency = Currency::from_str(&from)
76+
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid 'from' currency".to_string()))?;
77+
let to_currency = Currency::from_str(&to)
78+
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid 'to' currency".to_string()))?;
79+
80+
let rate = services
81+
.currency
82+
.get_exchange_rate(from_currency, to_currency)
83+
.await
84+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
85+
86+
Ok(Json(rate))
87+
}
88+
89+
/// Get list of supported currencies
90+
pub async fn get_supported_currencies(
91+
State(services): State<Arc<ServiceContainer>>,
92+
) -> Json<CurrenciesResponse> {
93+
let currencies = services.currency.get_supported_currencies();
94+
let currency_info = currencies
95+
.into_iter()
96+
.map(|c| CurrencyInfo {
97+
code: c.as_str().to_string(),
98+
name: match c {
99+
Currency::USD => "US Dollar".to_string(),
100+
Currency::EUR => "Euro".to_string(),
101+
Currency::GBP => "British Pound".to_string(),
102+
Currency::JPY => "Japanese Yen".to_string(),
103+
},
104+
})
105+
.collect();
106+
107+
Json(CurrenciesResponse {
108+
currencies: currency_info,
109+
})
110+
}

0 commit comments

Comments
 (0)