diff --git a/drizzle/0046_settlement_reconciliation.sql b/drizzle/0046_settlement_reconciliation.sql new file mode 100644 index 000000000..26a029ab0 --- /dev/null +++ b/drizzle/0046_settlement_reconciliation.sql @@ -0,0 +1,100 @@ +-- Settlement & Reconciliation Engine — PostgreSQL Persistence Tables +-- All polyglot services (Go, Rust, Python, TypeScript) auto-create tables on startup, +-- but this migration ensures schema consistency across environments. + +-- Go settlement-batch-processor (port 9211) +CREATE TABLE IF NOT EXISTS "settlement_batches" ( + "batch_id" TEXT PRIMARY KEY, + "status" TEXT NOT NULL DEFAULT 'pending', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "completed_at" TIMESTAMPTZ, + "agent_count" INT NOT NULL DEFAULT 0, + "total_volume" DOUBLE PRECISION NOT NULL DEFAULT 0, + "total_fees" DOUBLE PRECISION NOT NULL DEFAULT 0, + "total_commission" DOUBLE PRECISION NOT NULL DEFAULT 0, + "net_settlement" DOUBLE PRECISION NOT NULL DEFAULT 0, + "entries_json" JSONB NOT NULL DEFAULT '[]' +); +CREATE INDEX IF NOT EXISTS "idx_settlement_batches_status" ON "settlement_batches"("status"); +CREATE INDEX IF NOT EXISTS "idx_settlement_batches_created" ON "settlement_batches"("created_at" DESC); + +-- Go revenue-reconciler (port 9101) +CREATE TABLE IF NOT EXISTS "reconciliation_reports" ( + "id" BIGINT PRIMARY KEY, + "period" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "projected_json" JSONB NOT NULL DEFAULT '{}', + "actual_json" JSONB NOT NULL DEFAULT '{}', + "revenue_variance_pct" DOUBLE PRECISION NOT NULL DEFAULT 0, + "volume_variance_pct" DOUBLE PRECISION NOT NULL DEFAULT 0, + "agent_variance_pct" DOUBLE PRECISION NOT NULL DEFAULT 0, + "insights_json" JSONB NOT NULL DEFAULT '[]', + "generated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "approved_by" TEXT, + "approved_at" TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS "idx_recon_reports_period" ON "reconciliation_reports"("period"); +CREATE INDEX IF NOT EXISTS "idx_recon_reports_status" ON "reconciliation_reports"("status"); + +CREATE TABLE IF NOT EXISTS "discrepancy_alerts" ( + "id" SERIAL PRIMARY KEY, + "period" TEXT NOT NULL, + "metric" TEXT NOT NULL, + "projected" DOUBLE PRECISION NOT NULL, + "actual" DOUBLE PRECISION NOT NULL, + "variance_pct" DOUBLE PRECISION NOT NULL, + "severity" TEXT NOT NULL DEFAULT 'warning', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS "idx_discrepancy_severity" ON "discrepancy_alerts"("severity"); + +-- Rust fund-flow-settlement (port 8251) +CREATE TABLE IF NOT EXISTS "fx_rates" ( + "corridor" TEXT PRIMARY KEY, + "from_currency" TEXT NOT NULL, + "to_currency" TEXT NOT NULL, + "rate" DOUBLE PRECISION NOT NULL, + "spread_bps" INT NOT NULL, + "effective_rate" DOUBLE PRECISION NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS "installment_schedules" ( + "application_id" BIGINT PRIMARY KEY, + "schedule_json" JSONB NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS "reconciliation_results" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "agent_id" BIGINT NOT NULL, + "float_balance" DOUBLE PRECISION NOT NULL, + "gl_net" DOUBLE PRECISION NOT NULL, + "transaction_total" DOUBLE PRECISION NOT NULL, + "discrepancy" DOUBLE PRECISION NOT NULL, + "is_reconciled" BOOLEAN NOT NULL, + "recommendations" JSONB NOT NULL DEFAULT '[]', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS "idx_recon_results_agent" ON "reconciliation_results"("agent_id"); + +CREATE TABLE IF NOT EXISTS "settlement_batches_rust" ( + "batch_id" TEXT PRIMARY KEY, + "total_settlements" INT NOT NULL DEFAULT 0, + "total_amount" DOUBLE PRECISION NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'initiated', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Billing projections (for revenue-reconciler real data queries) +CREATE TABLE IF NOT EXISTS "billing_projections" ( + "id" SERIAL PRIMARY KEY, + "period" TEXT NOT NULL, + "agent_id" BIGINT NOT NULL, + "projected_tx_count" BIGINT NOT NULL DEFAULT 0, + "projected_volume" DOUBLE PRECISION NOT NULL DEFAULT 0, + "projected_platform_revenue" DOUBLE PRECISION NOT NULL DEFAULT 0, + "projected_client_revenue" DOUBLE PRECISION NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS "idx_billing_projections_period" ON "billing_projections"("period"); diff --git a/server/routers/temporalSagaOrchestrator.ts b/server/routers/temporalSagaOrchestrator.ts index 29e96930a..f04ec3f4f 100644 --- a/server/routers/temporalSagaOrchestrator.ts +++ b/server/routers/temporalSagaOrchestrator.ts @@ -12,7 +12,7 @@ import { z } from "zod"; import { router, protectedProcedure, adminProcedure } from "../_core/trpc"; import { getDb } from "../db"; import { sql } from "drizzle-orm"; -import { publishEvent } from "../kafkaClient"; +import { publishEvent, type KafkaTopic } from "../kafkaClient"; import { cacheGet, cacheSet, cacheInvalidate } from "../lib/cacheClient"; import { tbCreateTransfer } from "../tbClient"; import { fluvioPublish } from "../lib/fluvioClient"; @@ -433,4 +433,134 @@ export const temporalSagaRouter = router({ return rows; }), + + startSettlementSaga: protectedProcedure + .input(z.object({ + terminalId: z.string().min(1), + batchRef: z.string().optional(), + settlementDate: z.string().optional(), + })) + .mutation(async ({ input }) => { + const workflowId = `settle_${input.terminalId}_${Date.now()}`; + + const steps: SagaStep[] = [ + { + name: "create_batch", + execute: async (ctx) => { + const db = (await getDb())!; + if (!db) return { batchId: 0 }; + const batchRef = ctx.data.batchRef || `BATCH-${Date.now()}`; + const result = await db.execute(sql` + INSERT INTO pos_settlement_batches (batch_ref, terminal_id, status, created_at) + VALUES (${batchRef}, ${ctx.data.terminalId}, 'pending', NOW()) + RETURNING id + `); + const batchId = Array.isArray(result) && result[0] ? (result[0] as Record).id : 0; + ctx.results.batchId = batchId; + ctx.results.batchRef = batchRef; + return { batchId, batchRef }; + }, + compensate: async (ctx) => { + const db = (await getDb())!; + if (db && ctx.results.batchId) { + await db.execute(sql`DELETE FROM pos_settlement_batches WHERE id = ${ctx.results.batchId}`); + } + }, + }, + { + name: "process_transactions", + execute: async (ctx) => { + const db = (await getDb())!; + if (!db) return { processed: 0 }; + const txResult = await db.execute(sql` + SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total + FROM transactions + WHERE terminal_id = ${ctx.data.terminalId} + AND status = 'completed' + `); + const row = Array.isArray(txResult) ? txResult[0] as Record : {}; + const cnt = Number(row?.cnt ?? 0); + const total = Number(row?.total ?? 0); + if (ctx.results.batchId) { + await db.execute(sql` + UPDATE pos_settlement_batches + SET status = 'processing', transaction_count = ${cnt}, total_amount = ${total} + WHERE id = ${ctx.results.batchId} + `); + } + return { processed: cnt, totalAmount: total }; + }, + compensate: async (ctx) => { + const db = (await getDb())!; + if (db && ctx.results.batchId) { + await db.execute(sql`UPDATE pos_settlement_batches SET status = 'failed' WHERE id = ${ctx.results.batchId}`); + } + }, + }, + { + name: "settle_batch", + execute: async (ctx) => { + const db = (await getDb())!; + if (!db) return { settled: false }; + const settleRef = `SETTLE-${ctx.results.batchRef}-${Date.now()}`; + if (ctx.results.batchId) { + await db.execute(sql` + UPDATE pos_settlement_batches + SET status = 'settled', settlement_ref = ${settleRef}, settled_at = NOW() + WHERE id = ${ctx.results.batchId} + `); + } + ctx.results.settleRef = settleRef; + return { settled: true, settleRef }; + }, + compensate: async (ctx) => { + const db = (await getDb())!; + if (db && ctx.results.batchId) { + await db.execute(sql`UPDATE pos_settlement_batches SET status = 'processing', settlement_ref = NULL WHERE id = ${ctx.results.batchId}`); + } + }, + }, + { + name: "reconcile_batch", + execute: async (ctx) => { + const db = (await getDb())!; + if (!db) return { reconciled: false }; + if (ctx.results.batchId) { + await db.execute(sql` + UPDATE pos_settlement_batches SET status = 'reconciled' WHERE id = ${ctx.results.batchId} + `); + } + return { reconciled: true }; + }, + compensate: async (ctx) => { + const db = (await getDb())!; + if (db && ctx.results.batchId) { + await db.execute(sql`UPDATE pos_settlement_batches SET status = 'settled' WHERE id = ${ctx.results.batchId}`); + } + }, + }, + ]; + + const sagaCtx: SagaContext = { + workflowId, + data: { + terminalId: input.terminalId, + batchRef: input.batchRef, + settlementDate: input.settlementDate, + }, + amount: 0, + agentId: 0, + results: {}, + }; + + const result = await executeSaga("settlement_saga", steps, sagaCtx); + + publishEvent("settlement.saga" as KafkaTopic, workflowId, { + workflowId, + success: result.success, + terminalId: input.terminalId, + }).catch(() => {}); + + return result; + }), }); diff --git a/server/routers/transactionReconciliation.ts b/server/routers/transactionReconciliation.ts index 04bcf857c..e0eee8576 100644 --- a/server/routers/transactionReconciliation.ts +++ b/server/routers/transactionReconciliation.ts @@ -236,4 +236,135 @@ export const transactionReconciliationRouter = router({ }; } }), + + updateStatus: protectedProcedure + .input( + z.object({ + id: z.number().min(1), + status: z.string().min(1).max(50), + notes: z.string().max(500).optional(), + }) + ) + .mutation(async ({ input }) => { + const database = await getDb(); + if (!database) + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + + const [record] = await database + .select() + .from(transactions) + .where(eq(transactions.id, input.id)) + .limit(1); + if (!record) throw new TRPCError({ code: "NOT_FOUND" }); + + enforceTransition(record.status ?? "initiated", input.status); + + const [updated] = await database + .update(transactions) + .set({ status: input.status, updatedAt: new Date() }) + .where(eq(transactions.id, input.id)) + .returning(); + + logOperation("STATUS_UPDATED", { + transactionId: input.id, + from: record.status, + to: input.status, + notes: input.notes, + }); + + publishEvent("transaction.reconciliation" as KafkaTopic, String(input.id), { + type: "status_updated", + transactionId: input.id, + from: record.status, + to: input.status, + }).catch(() => {}); + + return { success: true, transaction: updated }; + }), + + markDisputed: protectedProcedure + .input( + z.object({ + id: z.number().min(1), + reason: z.string().min(1).max(1000), + disputeRef: z.string().max(100).optional(), + }) + ) + .mutation(async ({ input }) => { + const database = await getDb(); + if (!database) + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + + const [record] = await database + .select() + .from(transactions) + .where(eq(transactions.id, input.id)) + .limit(1); + if (!record) throw new TRPCError({ code: "NOT_FOUND" }); + + enforceTransition(record.status ?? "completed", "disputed"); + + const [updated] = await database + .update(transactions) + .set({ status: "disputed", updatedAt: new Date() }) + .where(eq(transactions.id, input.id)) + .returning(); + + logOperation("MARKED_DISPUTED", { + transactionId: input.id, + reason: input.reason, + disputeRef: input.disputeRef, + }); + + publishEvent("transaction.reconciliation" as KafkaTopic, String(input.id), { + type: "disputed", + transactionId: input.id, + reason: input.reason, + }).catch(() => {}); + + return { success: true, transaction: updated }; + }), + + markResolved: protectedProcedure + .input( + z.object({ + id: z.number().min(1), + resolution: z.string().min(1).max(1000), + resolvedBy: z.string().max(200).optional(), + }) + ) + .mutation(async ({ input }) => { + const database = await getDb(); + if (!database) + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + + const [record] = await database + .select() + .from(transactions) + .where(eq(transactions.id, input.id)) + .limit(1); + if (!record) throw new TRPCError({ code: "NOT_FOUND" }); + + enforceTransition(record.status ?? "disputed", "resolved"); + + const [updated] = await database + .update(transactions) + .set({ status: "resolved", updatedAt: new Date() }) + .where(eq(transactions.id, input.id)) + .returning(); + + logOperation("MARKED_RESOLVED", { + transactionId: input.id, + resolution: input.resolution, + resolvedBy: input.resolvedBy, + }); + + publishEvent("transaction.reconciliation" as KafkaTopic, String(input.id), { + type: "resolved", + transactionId: input.id, + resolution: input.resolution, + }).catch(() => {}); + + return { success: true, transaction: updated }; + }), }); diff --git a/services/go/revenue-reconciler/main.go b/services/go/revenue-reconciler/main.go index fd2c4961b..a2c20c094 100644 --- a/services/go/revenue-reconciler/main.go +++ b/services/go/revenue-reconciler/main.go @@ -17,7 +17,7 @@ import ( "strings" "os" "os/signal" - "sync" + "syscall" "time" ) @@ -116,20 +116,108 @@ type DiscrepancyAlert struct { // ═══════════════════════════════════════════════════════════════════════════════ type ReconciliationEngine struct { - config *Config - mu sync.RWMutex - reports []ReconciliationReport - alerts []DiscrepancyAlert - lastRun time.Time + config *Config + lastRun time.Time runCount int64 } func NewReconciliationEngine(cfg *Config) *ReconciliationEngine { + if db != nil { + db.Exec(`CREATE TABLE IF NOT EXISTS reconciliation_reports ( + id BIGINT PRIMARY KEY, + period TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + projected_json JSONB NOT NULL DEFAULT '{}', + actual_json JSONB NOT NULL DEFAULT '{}', + revenue_variance_pct DOUBLE PRECISION NOT NULL DEFAULT 0, + volume_variance_pct DOUBLE PRECISION NOT NULL DEFAULT 0, + agent_variance_pct DOUBLE PRECISION NOT NULL DEFAULT 0, + insights_json JSONB NOT NULL DEFAULT '[]', + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + approved_by TEXT, + approved_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_recon_reports_period ON reconciliation_reports(period); + CREATE INDEX IF NOT EXISTS idx_recon_reports_status ON reconciliation_reports(status); + + CREATE TABLE IF NOT EXISTS discrepancy_alerts ( + id SERIAL PRIMARY KEY, + period TEXT NOT NULL, + metric TEXT NOT NULL, + projected DOUBLE PRECISION NOT NULL, + actual DOUBLE PRECISION NOT NULL, + variance_pct DOUBLE PRECISION NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_discrepancy_severity ON discrepancy_alerts(severity);`) + } return &ReconciliationEngine{ - config: cfg, - reports: make([]ReconciliationReport, 0), - alerts: make([]DiscrepancyAlert, 0), + config: cfg, + } +} + +func (re *ReconciliationEngine) persistReport(report ReconciliationReport) { + if db == nil { + return } + projJSON, _ := json.Marshal(report.Projected) + actJSON, _ := json.Marshal(report.Actual) + insJSON, _ := json.Marshal(report.Insights) + db.Exec( + `INSERT INTO reconciliation_reports (id, period, status, projected_json, actual_json, revenue_variance_pct, volume_variance_pct, agent_variance_pct, insights_json, generated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET status=$3, actual_json=$5, revenue_variance_pct=$6, volume_variance_pct=$7, agent_variance_pct=$8, insights_json=$9`, + report.ID, report.Period, string(report.Status), string(projJSON), string(actJSON), + report.RevenueVariancePct, report.VolumeVariancePct, report.AgentVariancePct, + string(insJSON), report.GeneratedAt, + ) +} + +func (re *ReconciliationEngine) persistAlert(alert DiscrepancyAlert) { + if db == nil { + return + } + db.Exec( + `INSERT INTO discrepancy_alerts (period, metric, projected, actual, variance_pct, severity) + VALUES ($1, $2, $3, $4, $5, $6)`, + alert.Period, alert.Metric, alert.Projected, alert.Actual, alert.VariancePct, alert.Severity, + ) +} + +func (re *ReconciliationEngine) publishReconMiddleware(eventType string, period string, payload map[string]interface{}) { + payload["event_type"] = eventType + payload["timestamp"] = time.Now().UTC().Format(time.RFC3339) + payload["source"] = "revenue-reconciler" + + daprPort := re.config.DaprHTTPPort + go func() { + body, _ := json.Marshal(payload) + http.Post(fmt.Sprintf("http://localhost:%s/v1.0/publish/pubsub/reconciliation.%s", daprPort, eventType), "application/json", strings.NewReader(string(body))) + }() + + go func() { + body, _ := json.Marshal(map[string]interface{}{"records": []map[string]interface{}{{"key": period, "value": payload}}}) + req, _ := http.NewRequest("POST", fmt.Sprintf("http://%s/topics/reconciliation.%s", re.config.KafkaBrokers, eventType), strings.NewReader(string(body))) + if req != nil { + req.Header.Set("Content-Type", "application/vnd.kafka.json.v2+json") + http.DefaultClient.Do(req) + } + }() + + go func() { + body, _ := json.Marshal(map[string]interface{}{"table": "reconciliation_reports", "source": "revenue-reconciler", "data": payload}) + http.Post(re.config.LakehouseEndpoint+"/v1/ingest", "application/json", strings.NewReader(string(body))) + }() + + osURL := os.Getenv("OPENSEARCH_URL") + if osURL == "" { + osURL = "http://localhost:9200" + } + go func() { + body, _ := json.Marshal(payload) + http.Post(osURL+"/reconciliation-reports/_doc", "application/json", strings.NewReader(string(body))) + }() } func (re *ReconciliationEngine) RunReconciliation(ctx context.Context, period string) (*ReconciliationReport, error) { @@ -168,11 +256,17 @@ func (re *ReconciliationEngine) RunReconciliation(ctx context.Context, period st GeneratedAt: time.Now(), } - re.mu.Lock() - re.reports = append(re.reports, report) re.lastRun = time.Now() re.runCount++ - re.mu.Unlock() + + // Persist to PostgreSQL + re.persistReport(report) + + // Publish to middleware stack (Kafka, Dapr, Lakehouse, OpenSearch) + re.publishReconMiddleware("completed", period, map[string]interface{}{ + "period": period, "status": string(status), + "revenue_variance_pct": revenueVar, "volume_variance_pct": volumeVar, + }) // If discrepancy detected, trigger Temporal workflow and alert if status == StatusDiscrepancy { @@ -180,9 +274,6 @@ func (re *ReconciliationEngine) RunReconciliation(ctx context.Context, period st re.createAlert(report, revenueVar, volumeVar) } - // Export to Lakehouse for long-term analytics - re.exportToLakehouse(report) - log.Printf("[Reconciliation] Complete for %s: status=%s, revenueVar=%.2f%%, volumeVar=%.2f%%", period, status, revenueVar, volumeVar) @@ -190,8 +281,24 @@ func (re *ReconciliationEngine) RunReconciliation(ctx context.Context, period st } func (re *ReconciliationEngine) fetchProjectedMetrics(period string) ProjectedMetrics { - // In production: query the billing_reconciliation_reports table for projections - // or fetch from the financial model API endpoint + if db != nil { + var p ProjectedMetrics + p.Period = period + err := db.QueryRow( + `SELECT COALESCE(SUM(projected_tx_count), 0), COALESCE(SUM(projected_volume), 0), + COALESCE(SUM(projected_platform_revenue), 0), COALESCE(SUM(projected_client_revenue), 0), + COALESCE(COUNT(DISTINCT agent_id), 0) + FROM billing_projections WHERE period = $1`, period, + ).Scan(&p.Transactions, &p.GrossVolume, &p.PlatformRevenue, &p.ClientRevenue, &p.AgentCount) + if err == nil && p.Transactions > 0 { + if p.AgentCount > 0 { + p.TxPerAgent = float64(p.Transactions) / float64(p.AgentCount) + } + p.BillingModel = "revenue_share" + return p + } + } + // Fallback to default projections return ProjectedMetrics{ Period: period, Transactions: 1500000, @@ -205,9 +312,25 @@ func (re *ReconciliationEngine) fetchProjectedMetrics(period string) ProjectedMe } func (re *ReconciliationEngine) fetchActualMetrics(period string) ActualMetrics { - // In production: aggregate from platform_billing_ledger table - // SELECT SUM(platform_revenue), SUM(client_revenue), COUNT(*), COUNT(DISTINCT agent_id) - // FROM platform_billing_ledger WHERE processed_at BETWEEN period_start AND period_end + if db != nil { + var a ActualMetrics + a.Period = period + err := db.QueryRow( + `SELECT COUNT(*), COALESCE(SUM(amount), 0), + COALESCE(SUM(platform_fee), 0), COALESCE(SUM(agent_commission), 0), + COALESCE(COUNT(DISTINCT agent_id), 0) + FROM transactions + WHERE status = 'success' + AND TO_CHAR(created_at, 'YYYY-MM') = $1`, period, + ).Scan(&a.Transactions, &a.GrossVolume, &a.PlatformRevenue, &a.ClientRevenue, &a.AgentCount) + if err == nil && a.Transactions > 0 { + if a.AgentCount > 0 { + a.TxPerAgent = float64(a.Transactions) / float64(a.AgentCount) + } + return a + } + } + // Fallback to default actuals return ActualMetrics{ Period: period, Transactions: 1423000, @@ -260,18 +383,13 @@ func (re *ReconciliationEngine) createAlert(report ReconciliationReport, revVar, Timestamp: time.Now(), } - re.mu.Lock() - re.alerts = append(re.alerts, alert) - re.mu.Unlock() + re.persistAlert(alert) log.Printf("[Alert] Discrepancy alert created: %s severity for period %s (%.2f%% variance)", severity, report.Period, revVar) } -func (re *ReconciliationEngine) exportToLakehouse(report ReconciliationReport) { - log.Printf("[Lakehouse] Exporting reconciliation report for period %s", report.Period) - // In production: write Parquet file to Lakehouse S3 bucket for Spark/Trino queries -} + // ═══════════════════════════════════════════════════════════════════════════════ // Scheduled Reconciliation (runs every hour) @@ -298,15 +416,18 @@ func (re *ReconciliationEngine) StartScheduler(ctx context.Context) { // ═══════════════════════════════════════════════════════════════════════════════ func (re *ReconciliationEngine) handleHealth(w http.ResponseWriter, r *http.Request) { - re.mu.RLock() - defer re.mu.RUnlock() + var reportCount, alertCount int + if db != nil { + db.QueryRow(`SELECT COUNT(*) FROM reconciliation_reports`).Scan(&reportCount) + db.QueryRow(`SELECT COUNT(*) FROM discrepancy_alerts`).Scan(&alertCount) + } json.NewEncoder(w).Encode(map[string]interface{}{ "status": "healthy", "service": "revenue-reconciler", "lastRun": re.lastRun, "runCount": re.runCount, - "reports": len(re.reports), - "alerts": len(re.alerts), + "reports": reportCount, + "alerts": alertCount, }) } @@ -324,15 +445,59 @@ func (re *ReconciliationEngine) handleRunReconciliation(w http.ResponseWriter, r } func (re *ReconciliationEngine) handleGetReports(w http.ResponseWriter, r *http.Request) { - re.mu.RLock() - defer re.mu.RUnlock() - json.NewEncoder(w).Encode(re.reports) + var reports []ReconciliationReport + if db != nil { + rows, err := db.Query( + `SELECT id, period, status, projected_json, actual_json, + revenue_variance_pct, volume_variance_pct, agent_variance_pct, + insights_json, generated_at, approved_by, approved_at + FROM reconciliation_reports ORDER BY generated_at DESC LIMIT 50`) + if err == nil { + defer rows.Close() + for rows.Next() { + var r ReconciliationReport + var projJSON, actJSON, insJSON, status string + var approvedBy sql.NullString + var approvedAt sql.NullTime + if err := rows.Scan(&r.ID, &r.Period, &status, &projJSON, &actJSON, + &r.RevenueVariancePct, &r.VolumeVariancePct, &r.AgentVariancePct, + &insJSON, &r.GeneratedAt, &approvedBy, &approvedAt); err == nil { + r.Status = ReconciliationStatus(status) + _ = json.Unmarshal([]byte(projJSON), &r.Projected) + _ = json.Unmarshal([]byte(actJSON), &r.Actual) + _ = json.Unmarshal([]byte(insJSON), &r.Insights) + if approvedBy.Valid { + r.ApprovedBy = approvedBy.String + } + if approvedAt.Valid { + r.ApprovedAt = &approvedAt.Time + } + reports = append(reports, r) + } + } + } + } + json.NewEncoder(w).Encode(reports) } func (re *ReconciliationEngine) handleGetAlerts(w http.ResponseWriter, r *http.Request) { - re.mu.RLock() - defer re.mu.RUnlock() - json.NewEncoder(w).Encode(re.alerts) + var alerts []DiscrepancyAlert + if db != nil { + rows, err := db.Query( + `SELECT period, metric, projected, actual, variance_pct, severity, created_at + FROM discrepancy_alerts ORDER BY created_at DESC LIMIT 100`) + if err == nil { + defer rows.Close() + for rows.Next() { + var a DiscrepancyAlert + if err := rows.Scan(&a.Period, &a.Metric, &a.Projected, &a.Actual, + &a.VariancePct, &a.Severity, &a.Timestamp); err == nil { + alerts = append(alerts, a) + } + } + } + } + json.NewEncoder(w).Encode(alerts) } diff --git a/services/go/settlement-batch-processor/main.go b/services/go/settlement-batch-processor/main.go index c4b69f97e..0134fc959 100644 --- a/services/go/settlement-batch-processor/main.go +++ b/services/go/settlement-batch-processor/main.go @@ -13,12 +13,13 @@ import ( "net/http" "strings" "os" - "sync" "time" ) // SettlementBatchProcessor — Processes end-of-day settlement batches // Aggregates agent transactions, calculates net positions, generates settlement files +// Persistence: PostgreSQL (all state — NO in-memory maps) +// Middleware: Kafka, Dapr, Fluvio, Lakehouse, TigerBeetle, OpenSearch, Mojaloop type SettlementBatch struct { BatchID string `json:"batch_id"` @@ -47,11 +48,168 @@ type SettlementEntry struct { } var ( - batches = make(map[string]*SettlementBatch) - batchesMu sync.RWMutex - batchSeq int + pgDB *sql.DB ) +func initDB() { + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + log.Println("[settlement-batch-processor] DATABASE_URL not set — persistence disabled") + return + } + var err error + pgDB, err = sql.Open("postgres", dbURL) + if err != nil { + log.Printf("[settlement-batch-processor] PostgreSQL error: %v", err) + return + } + pgDB.SetMaxOpenConns(10) + pgDB.SetMaxIdleConns(5) + + // Auto-create tables + _, _ = pgDB.Exec(` + CREATE TABLE IF NOT EXISTS settlement_batches ( + batch_id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + agent_count INT NOT NULL DEFAULT 0, + total_volume DOUBLE PRECISION NOT NULL DEFAULT 0, + total_fees DOUBLE PRECISION NOT NULL DEFAULT 0, + total_commission DOUBLE PRECISION NOT NULL DEFAULT 0, + net_settlement DOUBLE PRECISION NOT NULL DEFAULT 0, + entries_json JSONB NOT NULL DEFAULT '[]' + ); + CREATE INDEX IF NOT EXISTS idx_settlement_batches_status ON settlement_batches(status); + CREATE INDEX IF NOT EXISTS idx_settlement_batches_created ON settlement_batches(created_at DESC); + `) + log.Println("[settlement-batch-processor] PostgreSQL persistence initialized") +} + +func persistBatch(batch *SettlementBatch) { + if pgDB == nil { + return + } + entriesJSON, _ := json.Marshal(batch.Entries) + _, err := pgDB.Exec( + `INSERT INTO settlement_batches (batch_id, status, created_at, completed_at, agent_count, total_volume, total_fees, total_commission, net_settlement, entries_json) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (batch_id) DO UPDATE SET + status=$2, completed_at=$4, agent_count=$5, total_volume=$6, + total_fees=$7, total_commission=$8, net_settlement=$9, entries_json=$10`, + batch.BatchID, batch.Status, batch.CreatedAt, batch.CompletedAt, + batch.AgentCount, batch.TotalVolume, batch.TotalFees, batch.TotalComm, + batch.NetSettlement, string(entriesJSON), + ) + if err != nil { + log.Printf("[settlement-batch-processor] persist error: %v", err) + } +} + +func loadBatchFromDB(batchID string) *SettlementBatch { + if pgDB == nil { + return nil + } + var b SettlementBatch + var entriesJSON string + var completedAt sql.NullTime + err := pgDB.QueryRow( + `SELECT batch_id, status, created_at, completed_at, agent_count, total_volume, total_fees, total_commission, net_settlement, entries_json + FROM settlement_batches WHERE batch_id=$1`, batchID, + ).Scan(&b.BatchID, &b.Status, &b.CreatedAt, &completedAt, &b.AgentCount, + &b.TotalVolume, &b.TotalFees, &b.TotalComm, &b.NetSettlement, &entriesJSON) + if err != nil { + return nil + } + if completedAt.Valid { + b.CompletedAt = &completedAt.Time + } + _ = json.Unmarshal([]byte(entriesJSON), &b.Entries) + return &b +} + +func publishMiddleware(eventType string, batchID string, payload map[string]interface{}) { + payload["event_type"] = eventType + payload["timestamp"] = time.Now().UTC().Format(time.RFC3339) + payload["source"] = "settlement-batch-processor" + + // Kafka + kafkaURL := os.Getenv("KAFKA_REST_URL") + if kafkaURL == "" { + kafkaURL = "http://localhost:8082" + } + go func() { + body, _ := json.Marshal(map[string]interface{}{"records": []map[string]interface{}{{"key": batchID, "value": payload}}}) + req, _ := http.NewRequest("POST", kafkaURL+"/topics/settlement.batch."+eventType, strings.NewReader(string(body))) + if req != nil { + req.Header.Set("Content-Type", "application/vnd.kafka.json.v2+json") + http.DefaultClient.Do(req) + } + }() + + // Dapr + daprPort := os.Getenv("DAPR_HTTP_PORT") + if daprPort == "" { + daprPort = "3500" + } + go func() { + body, _ := json.Marshal(payload) + http.Post(fmt.Sprintf("http://localhost:%s/v1.0/publish/pubsub/settlement.batch.%s", daprPort, eventType), "application/json", strings.NewReader(string(body))) + }() + + // Lakehouse + lakehouseURL := os.Getenv("LAKEHOUSE_URL") + if lakehouseURL == "" { + lakehouseURL = "http://localhost:8070" + } + go func() { + body, _ := json.Marshal(map[string]interface{}{"table": "settlement_batches", "source": "settlement-batch-processor", "data": payload}) + http.Post(lakehouseURL+"/v1/ingest", "application/json", strings.NewReader(string(body))) + }() + + // OpenSearch + osURL := os.Getenv("OPENSEARCH_URL") + if osURL == "" { + osURL = "http://localhost:9200" + } + go func() { + body, _ := json.Marshal(payload) + http.Post(osURL+"/settlement-batches/_doc", "application/json", strings.NewReader(string(body))) + }() + + // TigerBeetle (settlement ledger entry) + tbURL := os.Getenv("TIGERBEETLE_SIDECAR_URL") + if tbURL == "" { + tbURL = "http://localhost:8230" + } + if eventType == "completed" { + go func() { + tbPayload, _ := json.Marshal(map[string]interface{}{ + "debit_account_id": "3001", "credit_account_id": "4001", + "amount": payload["net_settlement"], "ledger": 1, "code": 200, + }) + http.Post(tbURL+"/transfers", "application/json", strings.NewReader(string(tbPayload))) + }() + } + + // Mojaloop (interbank settlement notification) + mojaloopURL := os.Getenv("MOJALOOP_URL") + if mojaloopURL == "" { + mojaloopURL = "http://localhost:4003" + } + if eventType == "completed" { + go func() { + mjPayload, _ := json.Marshal(map[string]interface{}{ + "settlementId": batchID, "amount": payload["net_settlement"], + "currency": "NGN", "settlementModel": "DEFERRED_NET", + }) + http.Post(mojaloopURL+"/v1/settlementWindows", "application/json", strings.NewReader(string(mjPayload))) + }() + } +} + +var batchSeq int + func generateBatchID() string { batchSeq++ return fmt.Sprintf("BATCH-%s-%04d", time.Now().Format("20060102"), batchSeq) @@ -62,37 +220,86 @@ func handleCreateBatch(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"method not allowed"}`, 405) return } - batchesMu.Lock() - defer batchesMu.Unlock() + batch := &SettlementBatch{ BatchID: generateBatchID(), Status: "processing", CreatedAt: time.Now(), } - // Simulate processing 10 agents - for i := 1; i <= 10; i++ { - cashIn := float64(50000 + i*10000) - cashOut := float64(30000 + i*5000) - transfer := float64(20000 + i*3000) - fees := (cashIn + cashOut + transfer) * 0.01 - comm := fees * 0.6 - entry := SettlementEntry{ - AgentID: fmt.Sprintf("AGT-%03d", i), - AgentCode: fmt.Sprintf("54LINK-%03d", i), - TxCount: 20 + i*5, - CashInVolume: cashIn, - CashOutVolume: cashOut, - TransferVol: transfer, - FeesCollected: math.Round(fees*100) / 100, - Commission: math.Round(comm*100) / 100, - NetPosition: math.Round((cashIn-cashOut)*100) / 100, - SettlementAmt: math.Round((cashIn-cashOut-comm)*100) / 100, + + // Query real agent data from PostgreSQL if available + if pgDB != nil { + rows, err := pgDB.Query( + `SELECT a.id, a.agent_code, + COALESCE(SUM(CASE WHEN t.type='cash_in' THEN t.amount ELSE 0 END), 0) as cash_in, + COALESCE(SUM(CASE WHEN t.type='cash_out' THEN t.amount ELSE 0 END), 0) as cash_out, + COALESCE(SUM(CASE WHEN t.type='transfer' THEN t.amount ELSE 0 END), 0) as transfer, + COUNT(*) as tx_count + FROM agents a + LEFT JOIN transactions t ON t.agent_id = a.id + AND t.status = 'success' + AND t.created_at >= CURRENT_DATE + GROUP BY a.id, a.agent_code + HAVING COUNT(*) > 0 + ORDER BY SUM(t.amount) DESC + LIMIT 500`) + if err == nil { + defer rows.Close() + for rows.Next() { + var agentID, agentCode string + var cashIn, cashOut, transfer float64 + var txCount int + if err := rows.Scan(&agentID, &agentCode, &cashIn, &cashOut, &transfer, &txCount); err == nil { + fees := (cashIn + cashOut + transfer) * 0.01 + comm := fees * 0.6 + entry := SettlementEntry{ + AgentID: agentID, + AgentCode: agentCode, + TxCount: txCount, + CashInVolume: cashIn, + CashOutVolume: cashOut, + TransferVol: transfer, + FeesCollected: math.Round(fees*100) / 100, + Commission: math.Round(comm*100) / 100, + NetPosition: math.Round((cashIn-cashOut)*100) / 100, + SettlementAmt: math.Round((cashIn-cashOut-comm)*100) / 100, + } + batch.Entries = append(batch.Entries, entry) + batch.TotalVolume += cashIn + cashOut + transfer + batch.TotalFees += fees + batch.TotalComm += comm + } + } + } + } + + // Fallback to demo data if no DB entries + if len(batch.Entries) == 0 { + for i := 1; i <= 10; i++ { + cashIn := float64(50000 + i*10000) + cashOut := float64(30000 + i*5000) + transfer := float64(20000 + i*3000) + fees := (cashIn + cashOut + transfer) * 0.01 + comm := fees * 0.6 + entry := SettlementEntry{ + AgentID: fmt.Sprintf("AGT-%03d", i), + AgentCode: fmt.Sprintf("54LINK-%03d", i), + TxCount: 20 + i*5, + CashInVolume: cashIn, + CashOutVolume: cashOut, + TransferVol: transfer, + FeesCollected: math.Round(fees*100) / 100, + Commission: math.Round(comm*100) / 100, + NetPosition: math.Round((cashIn-cashOut)*100) / 100, + SettlementAmt: math.Round((cashIn-cashOut-comm)*100) / 100, + } + batch.Entries = append(batch.Entries, entry) + batch.TotalVolume += cashIn + cashOut + transfer + batch.TotalFees += fees + batch.TotalComm += comm } - batch.Entries = append(batch.Entries, entry) - batch.TotalVolume += cashIn + cashOut + transfer - batch.TotalFees += fees - batch.TotalComm += comm } + batch.AgentCount = len(batch.Entries) batch.NetSettlement = math.Round((batch.TotalVolume-batch.TotalFees)*100) / 100 batch.TotalVolume = math.Round(batch.TotalVolume*100) / 100 @@ -101,17 +308,45 @@ func handleCreateBatch(w http.ResponseWriter, r *http.Request) { now := time.Now() batch.CompletedAt = &now batch.Status = "completed" - batches[batch.BatchID] = batch + + // Persist to PostgreSQL + persistBatch(batch) + + // Publish to middleware stack + publishMiddleware("completed", batch.BatchID, map[string]interface{}{ + "batch_id": batch.BatchID, "agent_count": batch.AgentCount, + "total_volume": batch.TotalVolume, "total_fees": batch.TotalFees, + "net_settlement": batch.NetSettlement, + }) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(batch) } func handleListBatches(w http.ResponseWriter, r *http.Request) { - batchesMu.RLock() - defer batchesMu.RUnlock() var list []*SettlementBatch - for _, b := range batches { - list = append(list, b) + if pgDB != nil { + rows, err := pgDB.Query( + `SELECT batch_id, status, created_at, completed_at, agent_count, + total_volume, total_fees, total_commission, net_settlement, entries_json + FROM settlement_batches ORDER BY created_at DESC LIMIT 100`) + if err == nil { + defer rows.Close() + for rows.Next() { + var b SettlementBatch + var entriesJSON string + var completedAt sql.NullTime + if err := rows.Scan(&b.BatchID, &b.Status, &b.CreatedAt, &completedAt, + &b.AgentCount, &b.TotalVolume, &b.TotalFees, &b.TotalComm, + &b.NetSettlement, &entriesJSON); err == nil { + if completedAt.Valid { + b.CompletedAt = &completedAt.Time + } + _ = json.Unmarshal([]byte(entriesJSON), &b.Entries) + list = append(list, &b) + } + } + } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"batches": list, "count": len(list)}) @@ -187,19 +422,10 @@ func authMiddleware(next http.Handler) http.Handler { } func main() { - // PostgreSQL persistence (WAL mode for concurrent reads/writes) - dbPath := os.Getenv("SETTLEMENT_BATCH_PROCESSOR_DB_PATH") - if dbPath == "" { - dbPath = "/tmp/settlement-batch-processor.db" - } - db, dbErr := sql.Open("postgres", os.Getenv("DATABASE_URL")) - if dbErr != nil { - log.Printf("[settlement-batch-processor] PostgreSQL unavailable (%v) — running in-memory only", dbErr) - } else { - defer db.Close() - log.Printf("[settlement-batch-processor] PostgreSQL persistence at %s", dbPath) - } - _ = db + initDB() + if pgDB != nil { + defer pgDB.Close() + } port := os.Getenv("PORT") if port == "" { diff --git a/services/python/opensearch-indexer/main.py b/services/python/opensearch-indexer/main.py index b689b90dc..5a1166831 100644 --- a/services/python/opensearch-indexer/main.py +++ b/services/python/opensearch-indexer/main.py @@ -369,13 +369,119 @@ async def get_metrics(): ), } +# ── Settlement & Reconciliation Indexing Pipeline ──────────────────────────── + +SETTLEMENT_MAPPING = { + "mappings": { + "properties": { + "batch_id": {"type": "keyword"}, + "batch_ref": {"type": "keyword"}, + "terminal_id": {"type": "keyword"}, + "agent_id": {"type": "keyword"}, + "status": {"type": "keyword"}, + "total_amount": {"type": "float"}, + "net_amount": {"type": "float"}, + "total_fees": {"type": "float"}, + "transaction_count": {"type": "integer"}, + "settlement_ref": {"type": "keyword"}, + "settled_at": {"type": "date"}, + "created_at": {"type": "date"}, + "indexed_at": {"type": "date"}, + } + }, + "settings": {"number_of_shards": 2, "number_of_replicas": 1, "refresh_interval": "5s"}, +} + +RECONCILIATION_MAPPING = { + "mappings": { + "properties": { + "reconciliation_id": {"type": "keyword"}, + "period": {"type": "keyword"}, + "status": {"type": "keyword"}, + "revenue_variance_pct": {"type": "float"}, + "volume_variance_pct": {"type": "float"}, + "agent_variance_pct": {"type": "float"}, + "projected_revenue": {"type": "float"}, + "actual_revenue": {"type": "float"}, + "generated_at": {"type": "date"}, + "indexed_at": {"type": "date"}, + } + }, + "settings": {"number_of_shards": 1, "number_of_replicas": 1, "refresh_interval": "10s"}, +} + +class SettlementIndexRequest(BaseModel): + documents: list[dict[str, Any]] + +class ReconciliationIndexRequest(BaseModel): + documents: list[dict[str, Any]] + +@app.post("/index/settlements") +async def index_settlements(req: SettlementIndexRequest): + """Index settlement batch documents into OpenSearch.""" + if not req.documents: + return {"indexed": 0, "errors": 0} + + start = time.monotonic() + try: + result = await bulk_index("settlement-batches", req.documents) + elapsed = time.monotonic() - start + indexed = len(req.documents) + metrics["total_indexed"] += indexed + metrics["total_batches"] += 1 + logger.info(f"Indexed {indexed} settlement docs in {elapsed:.2f}s") + log_audit("INDEX_SETTLEMENTS", f"batch_{indexed}", json.dumps({"count": indexed})) + return {"indexed": indexed, "errors": 0, "elapsed_ms": round(elapsed * 1000)} + except HTTPException: + raise + except Exception as e: + metrics["total_errors"] += len(req.documents) + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/index/reconciliations") +async def index_reconciliations(req: ReconciliationIndexRequest): + """Index reconciliation report documents into OpenSearch.""" + if not req.documents: + return {"indexed": 0, "errors": 0} + + start = time.monotonic() + try: + result = await bulk_index("reconciliation-reports", req.documents) + elapsed = time.monotonic() - start + indexed = len(req.documents) + metrics["total_indexed"] += indexed + metrics["total_batches"] += 1 + logger.info(f"Indexed {indexed} reconciliation docs in {elapsed:.2f}s") + log_audit("INDEX_RECONCILIATIONS", f"recon_{indexed}", json.dumps({"count": indexed})) + return {"indexed": indexed, "errors": 0, "elapsed_ms": round(elapsed * 1000)} + except HTTPException: + raise + except Exception as e: + metrics["total_errors"] += len(req.documents) + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/create-settlement-index") +async def create_settlement_index(): + """Create the settlement-batches index with proper mappings.""" + result = await os_request("PUT", "settlement-batches", SETTLEMENT_MAPPING) + logger.info(f"Created settlement-batches index: {result}") + return result + +@app.post("/create-reconciliation-index") +async def create_reconciliation_index(): + """Create the reconciliation-reports index with proper mappings.""" + result = await os_request("PUT", "reconciliation-reports", RECONCILIATION_MAPPING) + logger.info(f"Created reconciliation-reports index: {result}") + return result + if __name__ == "__main__": import uvicorn logger.info("=" * 60) - logger.info(" 54Link OpenSearch Indexer v1.0.0") + logger.info(" 54Link OpenSearch Indexer v2.0.0") logger.info(f" OpenSearch: {OPENSEARCH_URL}") logger.info(f" Port: {PORT}") + logger.info(f" Settlement & Reconciliation pipeline: enabled") logger.info("=" * 60) uvicorn.run(app, host="0.0.0.0", port=PORT) diff --git a/services/rust/fund-flow-settlement/src/main.rs b/services/rust/fund-flow-settlement/src/main.rs index 854dd99a4..15cc2d702 100644 --- a/services/rust/fund-flow-settlement/src/main.rs +++ b/services/rust/fund-flow-settlement/src/main.rs @@ -6,13 +6,13 @@ //! - GL reconciliation and discrepancy detection //! - Settlement batch processing //! -//! Designed for sub-millisecond latency on hot paths. +//! Persistence: PostgreSQL (all state — NO in-memory RwLock/HashMap) +//! Middleware: TigerBeetle, Lakehouse, OpenSearch use actix_web::{web, App, HttpServer, HttpResponse, middleware}; use chrono::{Utc, NaiveDate, Duration as ChronoDuration}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::RwLock; +use sqlx::{PgPool, postgres::PgPoolOptions, Row}; use uuid::Uuid; use sqlx::{PgPool, postgres::PgPoolOptions, Row}; @@ -108,51 +108,89 @@ pub struct SettlementBatch { pub created_at: String, } -// ── Application State ─────────────────────────────────────────────────────── +// ── Application State (PostgreSQL-backed) ─────────────────────────────────── pub struct AppState { pool: PgPool, } -impl AppState { - fn new() -> Self { - let mut rates = HashMap::new(); - let corridors = vec![ - ("NGN", "USD", 0.00065, 50u32), - ("USD", "NGN", 1540.0, 50), - ("NGN", "EUR", 0.00058, 75), - ("EUR", "NGN", 1720.0, 75), - ("NGN", "GBP", 0.00050, 100), - ("GBP", "NGN", 2000.0, 100), - ("NGN", "GHS", 0.0082, 60), - ("GHS", "NGN", 122.0, 60), - ("NGN", "KES", 0.0835, 50), - ("KES", "NGN", 12.0, 50), - ("NGN", "XOF", 0.40, 40), - ("XOF", "NGN", 2.50, 40), - ("USD", "EUR", 0.92, 30), - ("EUR", "USD", 1.09, 30), - ("USD", "GBP", 0.79, 30), - ("GBP", "USD", 1.27, 30), - ]; - - for (from, to, rate, spread) in corridors { - let key = format!("{}-{}", from, to); - let effective = rate * (1.0 - spread as f64 / 10000.0); - rates.insert(key, FXRate { - from: from.to_string(), - to: to.to_string(), - rate, - spread_bps: spread, - effective_rate: effective, - updated_at: Utc::now().to_rfc3339(), - }); - } +async fn init_db(pool: &PgPool) { + sqlx::query( + "CREATE TABLE IF NOT EXISTS fx_rates ( + corridor TEXT PRIMARY KEY, + from_currency TEXT NOT NULL, + to_currency TEXT NOT NULL, + rate DOUBLE PRECISION NOT NULL, + spread_bps INT NOT NULL, + effective_rate DOUBLE PRECISION NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ).execute(pool).await.ok(); - AppState { - fx_rates: RwLock::new(rates), - schedules: RwLock::new(HashMap::new()), - } + sqlx::query( + "CREATE TABLE IF NOT EXISTS installment_schedules ( + application_id BIGINT PRIMARY KEY, + schedule_json JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ).execute(pool).await.ok(); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS reconciliation_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id BIGINT NOT NULL, + float_balance DOUBLE PRECISION NOT NULL, + gl_net DOUBLE PRECISION NOT NULL, + transaction_total DOUBLE PRECISION NOT NULL, + discrepancy DOUBLE PRECISION NOT NULL, + is_reconciled BOOLEAN NOT NULL, + recommendations JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ).execute(pool).await.ok(); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_recon_results_agent ON reconciliation_results(agent_id)" + ).execute(pool).await.ok(); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS settlement_batches_rust ( + batch_id TEXT PRIMARY KEY, + total_settlements INT NOT NULL DEFAULT 0, + total_amount DOUBLE PRECISION NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'initiated', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ).execute(pool).await.ok(); + + // Seed FX rates + let corridors = vec![ + ("NGN", "USD", 0.00065, 50i32), + ("USD", "NGN", 1540.0, 50), + ("NGN", "EUR", 0.00058, 75), + ("EUR", "NGN", 1720.0, 75), + ("NGN", "GBP", 0.00050, 100), + ("GBP", "NGN", 2000.0, 100), + ("NGN", "GHS", 0.0082, 60), + ("GHS", "NGN", 122.0, 60), + ("NGN", "KES", 0.0835, 50), + ("KES", "NGN", 12.0, 50), + ("NGN", "XOF", 0.40, 40), + ("XOF", "NGN", 2.50, 40), + ("USD", "EUR", 0.92, 30), + ("EUR", "USD", 1.09, 30), + ("USD", "GBP", 0.79, 30), + ("GBP", "USD", 1.27, 30), + ]; + + for (from, to, rate, spread) in corridors { + let key = format!("{}-{}", from, to); + let effective = rate * (1.0 - spread as f64 / 10000.0); + sqlx::query( + "INSERT INTO fx_rates (corridor, from_currency, to_currency, rate, spread_bps, effective_rate) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (corridor) DO UPDATE SET rate=$4, spread_bps=$5, effective_rate=$6, updated_at=NOW()" + ) + .bind(&key).bind(from).bind(to).bind(rate).bind(spread).bind(effective) + .execute(pool).await.ok(); } } @@ -172,7 +210,6 @@ async fn generate_schedule( .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) .unwrap_or_else(|| Utc::now().date_naive()); - // Amortization calculation let monthly_rate = req.interest_rate / 100.0 / 12.0; let n = req.num_installments as f64; let monthly_payment = if monthly_rate > 0.0 { @@ -214,8 +251,11 @@ async fn generate_schedule( created_at: Utc::now().to_rfc3339(), }; - let mut schedules = data.schedules.write().unwrap(); - schedules.insert(req.application_id, schedule.clone()); + // Persist to PostgreSQL + let schedule_json = serde_json::to_string(&schedule).unwrap_or_default(); + sqlx::query("INSERT INTO installment_schedules (application_id, schedule_json) VALUES ($1, $2::jsonb) ON CONFLICT (application_id) DO UPDATE SET schedule_json=$2::jsonb") + .bind(req.application_id).bind(&schedule_json) + .execute(&data.pool).await.ok(); HttpResponse::Ok().json(schedule) } @@ -225,10 +265,16 @@ async fn get_schedule( path: web::Path, ) -> HttpResponse { let app_id = path.into_inner(); - let schedules = data.schedules.read().unwrap(); - match schedules.get(&app_id) { - Some(s) => HttpResponse::Ok().json(s), - None => HttpResponse::NotFound().json(serde_json::json!({ + match sqlx::query("SELECT schedule_json::TEXT FROM installment_schedules WHERE application_id=$1") + .bind(app_id).fetch_optional(&data.pool).await { + Ok(Some(row)) => { + let json_str: String = row.get(0); + match serde_json::from_str::(&json_str) { + Ok(s) => HttpResponse::Ok().json(s), + Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "corrupt schedule data"})), + } + } + _ => HttpResponse::NotFound().json(serde_json::json!({ "error": "Schedule not found" })), } @@ -237,13 +283,22 @@ async fn get_schedule( // ── FX Rate Handlers ──────────────────────────────────────────────────────── async fn get_fx_rates(data: web::Data) -> HttpResponse { - let rates = data.fx_rates.read().unwrap(); - let rate_list: Vec<&FXRate> = rates.values().collect(); - HttpResponse::Ok().json(serde_json::json!({ - "rates": rate_list, - "count": rate_list.len(), - "timestamp": Utc::now().to_rfc3339(), - })) + match sqlx::query("SELECT from_currency, to_currency, rate, spread_bps, effective_rate, updated_at::TEXT FROM fx_rates") + .fetch_all(&data.pool).await { + Ok(rows) => { + let rate_list: Vec = rows.iter().map(|r| FXRate { + from: r.get::(0), to: r.get::(1), + rate: r.get::(2), spread_bps: r.get::(3) as u32, + effective_rate: r.get::(4), updated_at: r.get::(5), + }).collect(); + let count = rate_list.len(); + HttpResponse::Ok().json(serde_json::json!({ + "rates": rate_list, "count": count, + "timestamp": Utc::now().to_rfc3339(), + })) + } + Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "failed to fetch rates"})) + } } async fn convert_fx( @@ -251,11 +306,14 @@ async fn convert_fx( req: web::Json, ) -> HttpResponse { let key = format!("{}-{}", req.from_currency, req.to_currency); - let rates = data.fx_rates.read().unwrap(); - match rates.get(&key) { - Some(rate_info) => { - let output = (req.amount * rate_info.effective_rate * 100.0).round() / 100.0; + match sqlx::query("SELECT rate, spread_bps, effective_rate FROM fx_rates WHERE corridor=$1") + .bind(&key).fetch_optional(&data.pool).await { + Ok(Some(row)) => { + let rate: f64 = row.get("rate"); + let spread_bps: i32 = row.get("spread_bps"); + let effective_rate: f64 = row.get("effective_rate"); + let output = (req.amount * effective_rate * 100.0).round() / 100.0; let fee = (req.amount * 0.01 * 100.0).round() / 100.0; HttpResponse::Ok().json(FXConvertResponse { @@ -263,31 +321,39 @@ async fn convert_fx( to_currency: req.to_currency.clone(), input_amount: req.amount, output_amount: output, - rate: rate_info.rate, - effective_rate: rate_info.effective_rate, - spread_bps: rate_info.spread_bps, + rate, + effective_rate, + spread_bps: spread_bps as u32, fee, timestamp: Utc::now().to_rfc3339(), }) } - None => HttpResponse::BadRequest().json(serde_json::json!({ + _ => HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Unsupported corridor: {}", key) })), } } async fn get_corridors(data: web::Data) -> HttpResponse { - let rates = data.fx_rates.read().unwrap(); - let corridors: Vec = rates.keys().cloned().collect(); - HttpResponse::Ok().json(serde_json::json!({ - "corridors": corridors, - "count": corridors.len(), - })) + match sqlx::query("SELECT corridor FROM fx_rates") + .fetch_all(&data.pool).await { + Ok(rows) => { + let corridors: Vec = rows.iter().map(|r| r.get::(0)).collect(); + let count = corridors.len(); + HttpResponse::Ok().json(serde_json::json!({ + "corridors": corridors, "count": count, + })) + } + Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "failed to fetch corridors"})) + } } // ── Reconciliation Handlers ───────────────────────────────────────────────── -async fn reconcile(req: web::Json) -> HttpResponse { +async fn reconcile( + data: web::Data, + req: web::Json, +) -> HttpResponse { let gl_net = req.gl_credits - req.gl_debits; let discrepancy = (req.float_balance - gl_net).abs(); let is_reconciled = discrepancy < 0.01; @@ -307,6 +373,16 @@ async fn reconcile(req: web::Json) -> HttpResponse { } } + // Persist reconciliation result to PostgreSQL + let recs_json = serde_json::to_string(&recommendations).unwrap_or_default(); + sqlx::query( + "INSERT INTO reconciliation_results (agent_id, float_balance, gl_net, transaction_total, discrepancy, is_reconciled, recommendations) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)" + ) + .bind(req.agent_id).bind(req.float_balance).bind(gl_net) + .bind(req.transaction_total).bind((discrepancy * 100.0).round() / 100.0) + .bind(is_reconciled).bind(&recs_json) + .execute(&data.pool).await.ok(); + HttpResponse::Ok().json(ReconcileResponse { agent_id: req.agent_id, float_balance: req.float_balance, @@ -319,14 +395,20 @@ async fn reconcile(req: web::Json) -> HttpResponse { }) } -async fn create_settlement_batch() -> HttpResponse { +async fn create_settlement_batch(data: web::Data) -> HttpResponse { + let batch_id = format!("BATCH-{}", Uuid::new_v4().to_string()[..8].to_uppercase()); let batch = SettlementBatch { - batch_id: format!("BATCH-{}", Uuid::new_v4().to_string()[..8].to_uppercase()), + batch_id: batch_id.clone(), total_settlements: 0, total_amount: 0.0, status: "initiated".to_string(), created_at: Utc::now().to_rfc3339(), }; + + sqlx::query("INSERT INTO settlement_batches_rust (batch_id, status) VALUES ($1, $2)") + .bind(&batch_id).bind("initiated") + .execute(&data.pool).await.ok(); + HttpResponse::Ok().json(batch) } @@ -336,7 +418,8 @@ async fn health() -> HttpResponse { HttpResponse::Ok().json(serde_json::json!({ "status": "healthy", "service": "fund-flow-settlement", - "version": "1.0.0", + "version": "2.0.0", + "persistence": "postgresql", "timestamp": Utc::now().to_rfc3339(), })) } @@ -431,7 +514,21 @@ async fn main() -> std::io::Result<()> { .and_then(|p| p.parse().ok()) .unwrap_or(8251); - let state = web::Data::new(AppState::new()); + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/fund_flow_settlement".to_string()); + + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(&db_url) + .await + .unwrap_or_else(|e| { + eprintln!("PostgreSQL connection failed: {}. Exiting.", e); + std::process::exit(1); + }); + + init_db(&pool).await; + + let state = web::Data::new(AppState { pool }); println!("Fund Flow Settlement Engine starting on :{}", port); @@ -439,14 +536,11 @@ async fn main() -> std::io::Result<()> { App::new() .app_data(state.clone()) .route("/health", web::get().to(health)) - // BNPL installment scheduling .route("/api/bnpl/schedule", web::post().to(generate_schedule)) .route("/api/bnpl/schedule/{app_id}", web::get().to(get_schedule)) - // FX rate engine .route("/api/fx/rates", web::get().to(get_fx_rates)) .route("/api/fx/convert", web::post().to(convert_fx)) .route("/api/fx/corridors", web::get().to(get_corridors)) - // Reconciliation .route("/api/reconcile", web::post().to(reconcile)) .route("/api/settlement/batch", web::post().to(create_settlement_batch)) })