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.
- 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:SwapServiceorchestrates a swap (locking, balance check, ledger posting),LedgerServiceposts and reads ledger entries,ElevatedActionTokenServiceissues/consumes EATs,RateServiceimplements the SWR exchange-rate cache,RedisLockServicewraps distributed locking. - Support (
app/Support) is pure domain math with no framework dependencies:MoneyMath(BCMath + Banker's Rounding) andSlippageCalculator(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:ProcessSettlementJobapplies settlement webhooks,RevalidateExchangeRateJobrefreshes the exchange-rate cache.
POST /api/swap never accepts a raw TOTP code. Instead:
POST /api/2fa/challengeverifies the TOTP code and accepts anaction_payloaddescribing exactly what the caller intends to do, e.g.{"action":"swap","amount":150000000,"currency":"NGN"}.- On success,
ElevatedActionTokenService::issue()storeseat:{token} => "{userId}:{sha256(canonical_json(action_payload))}"in Redis with a 60-second TTL. POST /api/swapmust send that token inX-Elevated-Action-Token.EnsureElevatedActionrebuilds the same hash from the actual request body and callsElevatedActionTokenService::consume().consume()usesGETDEL, an atomic read-and-delete, so no two concurrent requests can both read the token before one deletes it. The storeduserId:hashis compared withhash_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.
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 UPDATEprevents dirty reads from parallel transactions that bypassed RedisREPEATABLE READprevents phantom reads from new ledger entries inserted mid-transaction
Locks are always released in a finally block, so an exception cannot leave locks held.
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.
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.
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.
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).
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.jsonRun the signing command:
php artisan webhook:sign storage/webhook-payload.jsonPass the output as X-Webhook-Signature. The secret defaults to SETTLEMENT_WEBHOOK_SECRET from .env.
Duplicate webhook delivery is handled at two layers:
- 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. - 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.
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.
# 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=concurrencyGitHub Actions runs on every push:
- Spins up PostgreSQL 16 and Redis 7 as service containers
- Installs PHP 8.4 with
bcmath,pdo_pgsql,pcntl, andcurlextensions - 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.
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
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.
PostgreSQL was chosen over MySQL for three specific reasons relevant to this assessment:
- Trigger semantics: PostgreSQL's
AFTER INSERTtrigger withRAISE EXCEPTIONintegrates cleanly with Laravel's DB transaction rollback. MySQL triggers have subtler rollback behaviour that requires extra care. SELECT ... FOR UPDATEunderREPEATABLE READ: PostgreSQL's implementation ofREPEATABLE READwith pessimistic row locks is stricter and better documented for the concurrent write patterns this engine requires.BIGINTprecision 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.