Double-Entry Ledger & Financial Safety
Roja's financial engine is designed around one uncompromising rule: zero tolerance for money loss, double disbursements, or unrecorded drift. Because Roja handles peer-to-peer loans, automated direct-debit sweeps, investor returns, and platform fee splits in Nigerian Naira, every money movement is safeguarded by cryptographic decimal precision, a double-entry audit mirror, and durable lookup-before-act idempotency markers.
The Audit Mirror Philosophy
In Roja, the wallet service (backend/internal/services/wallet) is the operational source of truth. External financial institutions and payment gateways (such as Providus Bank, Monnify, and Mono) act as real-world custodians.
┌─────────────────────────┐
│ EXTERNAL RAILS │ ◄─── Real Custodians of Fiat Cash (Providus, Monnify, Mono)
└────────────┬────────────┘
│ (Provider Webhook / Sync API)
▼
┌─────────────────────────┐
│ WALLET SERVICE │ ◄─── Operational Source of Truth (CreditWallet, DebitWallet)
└────────────┬────────────┘
│ (Atomic Database Transaction)
▼
┌─────────────────────────┐
│ DOUBLE-ENTRY LEDGER │ ◄─── Immutable Audit Mirror (Σ Debits = Σ Credits per Tx)
└─────────────────────────┘
The double-entry ledger (ledger_entries, accounts) functions as an Audit Mirror:
- Non-blocking Execution: External provider API calls and wallet state transitions complete first. Once posted, balanced debit and credit entries are written into
ledger_entriesin the exact same database transaction that flips the transaction state toposted. - Mathematical Invariant: Every posted financial transaction must produce a perfectly balanced set of entries:
Total Debits = Total Credits (per transaction_id)
- Zero Ambiguity: Provider transaction fees, VAT, and company revenue sweeps have distinct ledger legs so that platform assets and liabilities reconcile down to the kobo.
Chart of Accounts
Roja defines a strict, single-currency (NGN) chart of accounts. Accounts are divided into wallet-linked accounts and platform system accounts.
| Account Kind | Backed By | Normal Side | Accounting Meaning |
|---|---|---|---|
customer_wallet | Customer wallet row (wallets.id) | Credit | Platform Liability: funds held on behalf of a registered borrower or lender. |
merchant_float | System (Provider Merchant Account) | Credit | Holding account mirroring the provider-backed float wallet used to settle transfers. |
fee_income | System | Credit | Revenue: platform commission, origination fees, and late penalties earned by Roja. |
suspense | System | Debit | Clearing: temporary holding account for in-flight or unreconciled transfers. |
provider_contra | System | Debit | Asset: actual cash balance held at external payment gateways and banking rails. |
company_settlement | System | Debit | Asset-to-Asset: cash swept from operational rails into Roja's corporate bank account. |
provider_transfer_fee | System | Debit | Expense: gateway processing fees and vendor VAT incurred per outbound bank push. |
Why merchant_float is Credit-Normal
Like customer wallets, merchant_float is a holding liability for customer liquidity. When a lender funds a loan ticket, debiting merchant_float decreases the holding balance, while crediting customer_wallet or company_settlement maintains strict ledger balance.
Provider Fees and VAT Accounting
When an outbound transfer rail processes an N10,000 disbursement with an N50 gateway fee and N3.75 VAT:
provider_contrais credited N10,053.75 (the exact cash deducted by the bank).merchant_floatis debited N10,000.00 (the principal delivered to the borrower).provider_transfer_feeis debited N53.75 (the total provider expense).
This prevents silent balance drift between the ledger's asset accounts and real bank statements.
Decimal Money Discipline
Roja enforces strict precision rules across Go, SQL, and TypeScript. Floating-point types (float32, float64, JavaScript number) are prohibited for financial calculations.
Rules in Go (backend/internal/money)
- Always import and use
shopspring/decimal. - Wrap financial fields using the canonical
money.Moneystruct:type Money struct {Amount decimal.Decimal `json:"amount"`Currency string `json:"currency"` // "NGN"} - Custom JSON serialization emits fixed two-decimal strings (e.g.
"15000.00"). - CI runs
backend/scripts/decimal-containment-check.shon every pull request. Any usage of raw float arithmetic in services or models fails the build.
Rules in PostgreSQL
- Columns storing money use
numeric(18,2). - Non-negativity constraints in
54_money_nonneg_checks.sqlensure wallet balances cannot drop below zero at the database engine level:ALTER TABLE walletsADD CONSTRAINT chk_wallets_balance_non_negativeCHECK (balance >= 0);
Rules on the Wire & Frontends
- All JSON requests and responses serialize money as string objects:
{"principal": {"amount": "250000.00","currency": "NGN"}}
- Frontend applications use helpers from
@roja/common/money(formatMoney,nairaToKobo,koboToNaira) which parse decimal strings directly without converting to JavaScript IEEE-754 numbers.
The "Lookup-Before-Act" Idempotency Pattern
Network calls to banking providers can time out, disconnect, or return ambiguous 5xx responses. Roja applies a strict Lookup-Before-Act protocol to guarantee that a financial disbursement or direct-debit repayment is never submitted twice.
┌────────────────────────────────────────────────────────┐
│ FINANCIAL OPERATION │
└──────────────────────────┬─────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ Step 1: Write Attempt Marker to │
│ Database (Dedicated State Table) │
└────────────────┬──────────────────┘
│
▼
┌───────────────────────────────────┐
│ Step 2: Commit Marker Immediately │
│ (Survives Network Timeouts) │
└────────────────┬──────────────────┘
│
▼
┌───────────────────────────────────┐
│ Step 3: Invoke External Banking │
│ Provider Rail with Idempotency Key│
└────────────────┬──────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
Provider Returns Provider Returns
Success / 200 Error / Timeout
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Step 4a: Update Marker │ │ Step 4b: Retain Marker; │
│ to 'Posted'; Post to │ │ Reconciliation Worker │
│ Double-Entry Ledger. │ │ Scans Status Asynchronously│
└─────────────────────────┘ └─────────────────────────┘
Critical Attempt Marker Tables
| Table | Stage | Behavior on Ambiguous Failure |
|---|---|---|
disbursement_payout_attempt | Borrower loan disbursement | Marker is written before invoking the payout provider. Ambiguous network failures retain the marker so retries query existing status rather than initiating a duplicate transfer. Only an explicit synchronous refusal deletes the row. |
installment_payout_attempt | Lender repayment distribution | Written before invoking the settlement rail. Fenced by unique constraint on (installment_id, reference). |
wallet_repayment_saga | Mono DirectPay collection saga | Write-once row created on the DB pool before checkout initiation. submitLenderPayoutOnEvidence strictly refuses to invoke a provider unless the saga row already carries the exact (reference, provider) pair. |
mandate_transactions | Direct-debit mandate debits | Written strictly before sending the debit instruction to the clearing house. Serialized via PostgreSQL row-level locks on the parent installment. |
Zero-Deletes Rule: Never delete rows from attempt marker tables or sagas during error recovery. An ambiguous provider response might have actually transferred funds. The Asynq reconciliation crons will poll the provider's query API or wait for an authenticated webhook before finalizing the state.
Reconciliation & Drift Reapers
Automated background workers continuously audit the health of the financial engine:
- Hourly In-Flight Drift Reaper: Scans for transactions stuck in
pendinglonger than 30 minutes, probes the provider's status API, and transitions the state to eitherpostedorfailed. - 6-Hour Ledger Mirror Reconciliation: Reconstructs balances by summing all ledger debits and credits and compares the result against the actual balances reported by provider float APIs. Any discrepancy is logged to
reconciliation_discrepanciesand alerts the on-call engineer via Prometheus. - Double-Submit Prevention: All mutable financial API routes require an
Idempotency-Keyheader. Requests sharing a key within a 24-hour window return the cached response without re-executing logic.
Next Steps
Explore the lifecycles of tickets, loans, KYC verification, scoring, and collections.
Frontend ArchitectureLearn how frontends handle money formatting, hooks, and client state.
Async Workers & QueuesUnderstand how Asynq queues and transactional outbox events process background tasks.
Developer SetupReturn to the local development environment and database migration guide.