Skip to content

Repository files navigation

Tupay Ledger & Settlement Engine

A cross-border financial ledger and swap engine moving money between Nigeria (NGN) and China (CNY), built on strict double-entry bookkeeping, subunit-integer precision, and concurrency-safe swap execution.

Architecture

  • Controllers (app/Http/Controllers/Api/V1) validate input and delegate to a service; they don't contain business logic.
  • Services (app/Services) hold the business logic: SwapService orchestrates a swap (locking, balance check, ledger posting), LedgerService posts and reads ledger entries, ElevatedActionTokenService issues/consumes EATs, RateService implements the SWR exchange-rate cache, RedisLockService wraps distributed locking.
  • Support (app/Support) is pure domain math with no framework dependencies: MoneyMath (BCMath + Banker's Rounding) and SlippageCalculator (tiered fee logic).
  • Models (app/Models) are thin Eloquent wrappers — Wallet::getBalance() is the only computed accessor, everything else is relationships and casts.
  • Jobs (app/Jobs) run off the request thread: ProcessSettlementJob applies settlement webhooks, RevalidateExchangeRateJob refreshes the exchange-rate cache.

Step-Up 2FA & Elevated Action Tokens (EAT)

POST /api/swap never accepts a raw TOTP code. Instead:

  1. POST /api/2fa/challenge verifies the TOTP code and accepts an action_payload describing exactly what the caller intends to do, e.g. {"action":"swap","amount":150000000,"currency":"NGN"}.
  2. On success, ElevatedActionTokenService::issue() stores eat:{token} => "{userId}:{sha256(canonical_json(action_payload))}" in Redis with a 60-second TTL.
  3. POST /api/swap must send that token in X-Elevated-Action-Token. EnsureElevatedAction rebuilds the same hash from the actual request body and calls ElevatedActionTokenService::consume().
  4. consume() uses GETDEL, an atomic read-and-delete, so no two concurrent requests can both read the token before one deletes it. The stored userId:hash is compared with hash_equals().

A token minted for "swap 100 NGN" can't be replayed for "swap 200 NGN", and any replay after first use or after 60 seconds hits a missing or mismatched key and fails closed with 401.

Deadlock Prevention & Lock Acquisition Order

Before touching the database, SwapService acquires Redis locks on the user ID and both wallet IDs. These keys are sorted alphabetically before acquisition. This deterministic ordering prevents deadlocks: if two concurrent swaps both need the same pair of wallets, they will always attempt to acquire locks in the same order, so the second request blocks on the first lock rather than creating a circular wait.

After Redis locks are held, the engine opens a PostgreSQL transaction at REPEATABLE READ isolation and issues SELECT ... FOR UPDATE on both wallet rows. This two-layer locking (Redis + DB row locks) ensures:

  • Redis locks prevent concurrent entry into the swap logic for the same user/wallets
  • FOR UPDATE prevents dirty reads from parallel transactions that bypassed Redis
  • REPEATABLE READ prevents phantom reads from new ledger entries inserted mid-transaction

Locks are always released in a finally block, so an exception cannot leave locks held.


Double-Entry Ledger

Every financial movement produces at least two ledger_entries rows: one debit and one credit. The sum of all entries for a completed transaction equals zero.

No mutable balance column: Wallet balances are computed dynamically via:

SELECT SUM(CASE WHEN type = 'credit' THEN amount ELSE -amount END) as balance
FROM ledger_entries
WHERE wallet_id = ?

Database guardrail: A PostgreSQL trigger (enforce_wallet_balance) fires AFTER INSERT on ledger_entries. It recalculates the running balance for the affected wallet and raises an exception if it would go below zero. This enforces the non-negative invariant at the storage layer, independent of application logic.

Subunit precision: All amounts are stored as BIGINT in subunits (kobo for NGN, fen for CNY). Floating-point arithmetic is never used for financial calculations. BCMath handles all rate conversions at scale 8, with Banker's Rounding (PHP_ROUND_HALF_EVEN) applied when scaling back to integer subunits.


Ledger Pagination Index

The ledger_entries table has a compound index on (wallet_id, created_at). This allows PostgreSQL to satisfy the paginated ledger query with an index scan rather than a sequential scan, keeping response times consistent even at millions of rows.


Tiered Dynamic Slippage

Swaps exceeding 1,000,000 NGN (100,000,000 kobo) incur a progressive spread fee:

  • Base spread: 0.5%
  • Additional 0.1% per additional 500,000 NGN tier above the threshold

Example: a 2,000,000 NGN swap has 2 tiers above threshold, so the spread is 0.5% + (2 * 0.1%) = 0.7%, and the fee is 2,000,000 * 0.7% = 14,000 NGN.


Settlement Webhook

Signature Verification

The provider signs the raw request body with HMAC-SHA256 using a shared secret (SETTLEMENT_WEBHOOK_SECRET). The VerifyWebhookSignature middleware recomputes the signature server-side and compares using hash_equals (timing-attack safe).

Generating a Test Signature

Write your exact payload to a file (single line, no trailing newline):

echo -n '{"provider_reference":"REF-001","status":"completed","wallet_id":"...","amount":5000000,"currency":"NGN"}' > storage/webhook-payload.json

Run the signing command:

php artisan webhook:sign storage/webhook-payload.json

Pass the output as X-Webhook-Signature. The secret defaults to SETTLEMENT_WEBHOOK_SECRET from .env.

Idempotency

Duplicate webhook delivery is handled at two layers:

  1. Redis SETNX: On receipt, the controller sets webhook:{provider_reference}:{status} in Redis with a 24-hour TTL. If the key already exists, the webhook was already received and a 200 is returned immediately without dispatching a job.
  2. Ledger check: Inside ProcessSettlementJob, before posting ledger entries, the job checks whether a credit entry already exists for the transaction. If so it skips posting and logs the event.

Out-of-Order Delivery

The state machine in ProcessSettlementJob handles out-of-order delivery gracefully. A COMPLETED status arriving before INITIATED still applies the ledger credit correctly because the idempotency check is on ledger entries, not on status transitions. A COMPLETED status can never be reverted to INITIATED.


Quality Gates

# Static analysis
vendor/bin/phpstan analyse --no-progress

# Code style
vendor/bin/pint --test

# Parallel concurrency test (Linux / CI only, requires pcntl)
php artisan test --group=concurrency

CI

GitHub Actions runs on every push:

  1. Spins up PostgreSQL 16 and Redis 7 as service containers
  2. Installs PHP 8.4 with bcmath, pdo_pgsql, pcntl, and curl extensions
  3. Runs Pint, PHPStan Level 8, migrations, seeds, and the parallel concurrency test

The pcntl extension is required for spatie/async process forks. It is not available on Windows, so the concurrency test must run in CI or WSL2.


Verification Artifacts

  • tupay-api.http — REST Client file covering the full challenge-then-swap flow, EAT replay rejection, ledger history, valid webhook, idempotent webhook replay, and invalid signature rejection
  • Database seeders provide a test user with an encrypted TOTP secret and funded NGN/CNY wallets

Form Requests and Response Helpers

For brevity and assessment clarity, controllers use inline $request->validate() and response()->json() directly. In a production codebase, each endpoint would have a dedicated FormRequest class for validation and authorization, and a typed API Resource or Response helper class for consistent response shaping. The architectural intent is the same, the service layer is fully decoupled from the HTTP layer regardless.

Why PostgreSQL

PostgreSQL was chosen over MySQL for three specific reasons relevant to this assessment:

  1. Trigger semantics: PostgreSQL's AFTER INSERT trigger with RAISE EXCEPTION integrates cleanly with Laravel's DB transaction rollback. MySQL triggers have subtler rollback behaviour that requires extra care.
  2. SELECT ... FOR UPDATE under REPEATABLE READ: PostgreSQL's implementation of REPEATABLE READ with pessimistic row locks is stricter and better documented for the concurrent write patterns this engine requires.
  3. BIGINT precision and check constraint support: PostgreSQL handles large subunit integers and aggregate-based check constraints more predictably than MySQL 8.0 for financial workloads.

MySQL 8.0 would also satisfy the spec, but PostgreSQL is the stronger choice for a payments engine.

About

Production-grade, secure, and high-performance backend, for a cross-border financial engine handling money transfer between Nigeria (NGN) and China (CNY). Demonstrated mastery in financial precision, strict double-entry ledger design, non-blocking distributed lock safety, step-up stateful security, and high-concurrency race condition prevention.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages