Skip to main content

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_entries in the exact same database transaction that flips the transaction state to posted.
  • 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 KindBacked ByNormal SideAccounting Meaning
customer_walletCustomer wallet row (wallets.id)CreditPlatform Liability: funds held on behalf of a registered borrower or lender.
merchant_floatSystem (Provider Merchant Account)CreditHolding account mirroring the provider-backed float wallet used to settle transfers.
fee_incomeSystemCreditRevenue: platform commission, origination fees, and late penalties earned by Roja.
suspenseSystemDebitClearing: temporary holding account for in-flight or unreconciled transfers.
provider_contraSystemDebitAsset: actual cash balance held at external payment gateways and banking rails.
company_settlementSystemDebitAsset-to-Asset: cash swept from operational rails into Roja's corporate bank account.
provider_transfer_feeSystemDebitExpense: 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:

  1. provider_contra is credited N10,053.75 (the exact cash deducted by the bank).
  2. merchant_float is debited N10,000.00 (the principal delivered to the borrower).
  3. provider_transfer_fee is 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.Money struct:
    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.sh on 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.sql ensure wallet balances cannot drop below zero at the database engine level:
    ALTER TABLE wallets
    ADD CONSTRAINT chk_wallets_balance_non_negative
    CHECK (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

TableStageBehavior on Ambiguous Failure
disbursement_payout_attemptBorrower loan disbursementMarker 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_attemptLender repayment distributionWritten before invoking the settlement rail. Fenced by unique constraint on (installment_id, reference).
wallet_repayment_sagaMono DirectPay collection sagaWrite-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_transactionsDirect-debit mandate debitsWritten 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:

  1. Hourly In-Flight Drift Reaper: Scans for transactions stuck in pending longer than 30 minutes, probes the provider's status API, and transitions the state to either posted or failed.
  2. 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_discrepancies and alerts the on-call engineer via Prometheus.
  3. Double-Submit Prevention: All mutable financial API routes require an Idempotency-Key header. Requests sharing a key within a 24-hour window return the cached response without re-executing logic.

Next Steps