Skip to content

Latest commit

 

History

49 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PayRoute — Cross-Border Payment Processing Service

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


PayRoute Dashboard

Transaction dashboard with status filters, paginated history, and live FX preview


How It Works

A payment moves through four atomic stages inside a single PostgreSQL transaction:

  1. FX Quote Lock — rate is locked at initiation, immune to market movement mid-flight
  2. Balance Debit — SELECT FOR UPDATE serialises concurrent debits, preventing overdrafts without application-level locking
  3. Provider Submission — order dispatched to downstream provider with a trackable reference
  4. 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.


Screenshots

Transaction List New Payment Transaction Detail
Transaction List New Payment Detail

Tech Stack

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

Project Structure

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

Quick Start (Docker)

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 --build

This 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:

# Stop
docker-compose down

# Wipe database and start fresh
docker-compose down -v

Running Without Docker

Prerequisites: 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

Test Accounts (Seeded)

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


API Reference

POST /fx/quote

Lock an FX rate before initiating a payment.

{
  "source_currency": "NGN",
  "dest_currency": "USD",
  "source_amount": 1000000
}

POST /payments

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_amount is in minor units — kobo for NGN (5,000,000 = 50,000 NGN)

GET /payments

List transactions with optional filters.

?status=processing&from=2024-01-01&to=2024-12-31&page=1&limit=20

GET /payments/:id

Full transaction detail including ledger entries and status timeline.

POST /webhooks/provider

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();
"

Running Tests

cd backend
npm install
npm run migrate
npm test

Tests cover the full payment lifecycle and webhook delivery scenarios including duplicate delivery, crash recovery, and failed payment handling.


Key Design Decisions

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.


Assumptions & Production Notes

  1. FX rates are simulated with ±0.3% random spread. In production, replace fxService.js with a live FX API (e.g. Open Exchange Rates, Wise).
  2. The downstream provider is simulated. providerService.js returns a fake provider_reference. In production this would be an HTTP call with its own retry and circuit-breaker logic.
  3. All amounts are in minor units throughout the system. The frontend divides by 100 for display only.
  4. 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.

License

MIT

About

Handles your cross-border payments efficiently

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages