A fintech-grade cross-border payment processor for Nigerian businesses sending payments to foreign suppliers. Built with Node.js/Express, PostgreSQL, and React — featuring double-entry bookkeeping, atomic transactions, idempotency controls, and HMAC webhook verification.
Stack: Node.js · Express · PostgreSQL 16 · React · Vite · Tailwind CSS · Docker
Transaction dashboard with status filters, paginated history, and live FX preview
A payment moves through four atomic stages inside a single PostgreSQL transaction:
- FX Quote Lock — rate is locked at initiation, immune to market movement mid-flight
- Balance Debit —
SELECT FOR UPDATEserialises concurrent debits, preventing overdrafts without application-level locking - Provider Submission — order dispatched to downstream provider with a trackable reference
- Webhook Settlement — provider confirms via signed webhook; idempotency constraint at DB level prevents double-crediting on duplicate delivery
Every balance change writes two ledger entries (debit + credit) that always net to zero. The ledger_balance_check view lets you verify balance integrity at any point in time.
| Transaction List | New Payment | Transaction Detail |
|---|---|---|
![]() |
![]() |
![]() |
| Layer | Technology |
|---|---|
| Backend | Node.js + Express |
| Frontend | React + Vite + Tailwind CSS |
| Database | PostgreSQL 16 |
| Infra | Docker + Docker Compose |
| Testing | Jest + Supertest |
| Auth | HMAC-SHA256 webhook verification |
payroute/
├── .env.example # Environment variable template
├── docker-compose.yml # Starts postgres + backend + frontend
├── screenshots/ # App screenshots for README
│
├── backend/
│ ├── Dockerfile
│ ├── migrations/
│ │ ├── 001_schema.sql # All tables, indexes, views
│ │ └── 002_seed.sql # Test accounts with NGN balances
│ └── src/
│ ├── index.js # Express app entry point
│ ├── db/
│ │ ├── index.js # pg connection pool + withTransaction()
│ │ └── migrate.js # Migration runner
│ ├── middleware/
│ │ └── idempotency.js # Idempotency key check and store
│ ├── routes/
│ │ ├── payments.js # POST /payments, GET /payments, GET /payments/:id
│ │ ├── webhooks.js # POST /webhooks/provider
│ │ └── fx.js # POST /fx/quote
│ ├── services/
│ │ ├── fxService.js # Simulated FX rates + quote creation
│ │ ├── ledgerService.js # Double-entry bookkeeping helpers
│ │ └── providerService.js# Simulated provider + HMAC verification
│ └── tests/
│ └── payments.test.js # Payment lifecycle + webhook tests
│
└── frontend/
├── Dockerfile # Multi-stage build + nginx
├── nginx.conf # Proxies /payments /fx /webhooks to backend
└── src/
├── api/
│ └── client.js # Axios wrapper for all API calls
├── components/
│ ├── StatusBadge.jsx # Colour-coded status pill
│ └── PageHeader.jsx # Reusable page header with action slot
└── pages/
├── TransactionList.jsx # Table + status filters + pagination
├── NewPayment.jsx # Payment form + live FX preview
└── TransactionDetail.jsx # Summary + timeline + ledger entries
Prerequisites: Docker Desktop installed and running.
# 1. Clone the repo
git clone https://github.com/Nebenmor/PayRoute.git
cd PayRoute
# 2. Create your .env file
cp .env.example .env # Mac/Linux
copy .env.example .env # Windows
# 3. Start everything
docker-compose up --buildThis will:
- Start PostgreSQL and wait for it to be healthy
- Run all database migrations automatically
- Seed test accounts with NGN balances
- Start the backend API on port 4000
- Build and serve the React frontend on port 3000
Open the app:
- Dashboard: http://localhost:3000
- API: http://localhost:4000
- Health check: http://localhost:4000/health
# Stop
docker-compose down
# Wipe database and start fresh
docker-compose down -vPrerequisites: Node.js 20+ · PostgreSQL 16
# Backend
cd backend
npm install
# Edit .env with your local DB credentials
npm run migrate # runs migrations + seed
npm run dev # starts on port 4000
# Frontend (new terminal)
cd frontend
npm install
npm run dev # starts on port 3000| Account ID | Owner | NGN Balance |
|---|---|---|
aaaaaaaa-0000-0000-0000-000000000001 |
Adekunle Fashola Imports Ltd | 500,000 NGN |
aaaaaaaa-0000-0000-0000-000000000002 |
Ngozi Tech Solutions | 250,000 NGN |
aaaaaaaa-0000-0000-0000-000000000003 |
Emeka & Sons Trading | 50,000 NGN |
Supported destination currencies: USD GBP EUR KES GHS
Lock an FX rate before initiating a payment.
{
"source_currency": "NGN",
"dest_currency": "USD",
"source_amount": 1000000
}Initiate a cross-border payment. Requires a unique Idempotency-Key header.
{
"sender_account_id": "aaaaaaaa-0000-0000-0000-000000000001",
"recipient_name": "Acme Supplies Ltd",
"recipient_account_no": "GB29NWBK60161331926819",
"recipient_country": "GB",
"source_currency": "NGN",
"dest_currency": "GBP",
"source_amount": 5000000
}
source_amountis in minor units — kobo for NGN (5,000,000 = 50,000 NGN)
List transactions with optional filters.
?status=processing&from=2024-01-01&to=2024-12-31&page=1&limit=20
Full transaction detail including ledger entries and status timeline.
Receive signed outcome webhooks from the downstream provider.
Header: X-Webhook-Signature: <hmac-sha256-hex>
{
"event_id": "evt_abc123",
"reference": "PRV-1234567890-ABCD",
"status": "completed"
}Valid statuses: completed · failed
Test a webhook locally:
docker exec payroute_backend node -e "
const crypto = require('crypto');
const body = JSON.stringify({event_id:'evt-test-1', reference:'PRV-REPLACE-ME', status:'completed'});
const sig = crypto.createHmac('sha256','dev_webhook_secret').update(body).digest('hex');
const http = require('http');
const req = http.request({hostname:'localhost',port:4000,path:'/webhooks/provider',method:'POST',headers:{'Content-Type':'application/json','x-webhook-signature':sig,'Content-Length':Buffer.byteLength(body)}},r=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>console.log(d))});
req.write(body);req.end();
"cd backend
npm install
npm run migrate
npm testTests cover the full payment lifecycle and webhook delivery scenarios including duplicate delivery, crash recovery, and failed payment handling.
Amounts stored as integers (minor units) All monetary values are integers representing the smallest currency unit (kobo for NGN, cents for USD). This eliminates floating-point rounding errors that can silently create or destroy fractions of currency at scale.
Double-entry bookkeeping
Every balance change produces two ledger entries — debit and credit — that always net to zero. The ledger_balance_check view lets you verify at any time that stored balances match the running sum of ledger entries. This is standard practice in production fintech systems.
SELECT FOR UPDATE for overdraft prevention
Balance reads during payment initiation lock the row, serialising concurrent payments from the same account. No application-level locking or race condition handling needed — the database enforces it.
Webhook idempotency at the database level
The UNIQUE (provider_event_id) constraint on webhook_events makes duplicate webhook delivery safe by design — the second delivery fails the constraint before any business logic runs. No double-crediting possible regardless of timing or concurrency.
Raw body persisted before processing
The webhook handler writes the raw event to webhook_events before attempting any business logic. If processing crashes mid-flight, the event is preserved for manual replay. No lost webhooks.
- FX rates are simulated with ±0.3% random spread. In production, replace
fxService.jswith a live FX API (e.g. Open Exchange Rates, Wise). - The downstream provider is simulated.
providerService.jsreturns a fakeprovider_reference. In production this would be an HTTP call with its own retry and circuit-breaker logic. - All amounts are in minor units throughout the system. The frontend divides by 100 for display only.
- Recipient accounts seeded in the database are internal and receive the dest-currency credit automatically. External recipients use the FX suspense account as a placeholder until the provider confirms settlement.
MIT



