Fintech-grade payout system for Indian merchants receiving international payments.
Stack: Django + DRF · PostgreSQL · Celery · Redis · React + Tailwind
- Python 3.11+
- PostgreSQL 14+
- Redis 6+
- Node.js 18+ (for frontend)
cd backend
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Configure environment
cp .env.example .env
# Edit .env with your DB credentials
# Run migrations
python manage.py migrate
# Seed test data (merchants with starting balance)
python manage.py shell < scripts/seed.py
# Start Django dev server
python manage.py runserver 8000
# In separate terminal: Start Celery worker
celery -A config worker --loglevel=info --concurrency=4
# In separate terminal: Start Celery beat scheduler
celery -A config beat --loglevel=infocd frontend
npm install
npm run devdocker compose up --buildcd backend
pytest# Idempotency tests
pytest apps/payouts/tests/test_idempotency.py -v
# Concurrency tests
pytest apps/payouts/tests/test_concurrency.py -v
# State machine tests
pytest apps/payouts/tests/test_state_machine.py -v
# Ledger integrity tests
pytest apps/payouts/tests/test_ledger.py -vTests use a separate test database. Create it with:
createdb -U postgres payout_engine_testOr set TEST_DB_NAME in your environment.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/payouts/ |
Create payout request |
| GET | /api/v1/payouts/ |
List payouts (query: merchant_id, status, limit, offset) |
| GET | /api/v1/payouts/<id>/ |
Get payout details |
Required Headers for POST:
Idempotency-Key: <uuid-v4>
X-Merchant-Id: <merchant-uuid>
Content-Type: application/json
Request Body:
{
"amount_paise": 50000,
"bank_account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/balance/ |
Get available/held/total balance |
Query Params: merchant_id
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/ledger/ |
List ledger entries |
Query Params: merchant_id, limit, offset
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/merchants/ |
List all merchants |
- All amounts stored as
BIGINTin paise (₹1 = 100 paise) - No floats. Ever. Float division only for display formatting
- Balance is never stored — always computed as
SUM(ledger_entries.amount_paise)
SELECT FOR UPDATEserializes payouts per merchant- Prevents double-spend: two concurrent ₹60 requests with ₹100 balance → one succeeds, one fails
- Client sends
Idempotency-Key: <uuid>header - Scoped per
(merchant_id, key)with 24h window - Replay returns byte-identical cached response
pending → processing → completed
→ failed
Terminal states (completed, failed) cannot transition.
┌─────────────────────────────────────────────────────────────────┐
│ React Dashboard │
└──────────────────────────┬──────────────────────────────────────┘
│ HTTP / polling (3s)
┌──────────────────────────▼──────────────────────────────────────┐
│ Django REST Framework │
│ POST /api/v1/payouts │ GET /api/v1/balance/ │
│ GET /api/v1/payouts/ │ GET /api/v1/ledger/ │
└──────────┬───────────────────────────────┬──────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌────────────────────────┐
│ PostgreSQL │ │ Celery Worker │
│ - merchants │ │ - process_payout │
│ - bank_accounts │ │ - recover_stuck │
│ - ledger_entries │ └─────────────────────────┘
│ - payouts │
│ - idempotency_records│
└──────────────────────┘
| Variable | Default | Description |
|---|---|---|
SECRET_KEY |
(dev key) | Django secret |
DEBUG |
True |
Debug mode |
DB_NAME |
payout_engine |
PostgreSQL database |
DB_USER |
postgres |
PostgreSQL user |
DB_PASSWORD |
postgres |
PostgreSQL password |
DB_HOST |
localhost |
PostgreSQL host |
DB_PORT |
5432 |
PostgreSQL port |
REDIS_URL |
redis://localhost:6379/0 |
Redis connection |
PAYOUT_IDEMPOTENCY_WINDOW_HOURS |
24 |
Idempotency key TTL |
PAYOUT_MAX_RETRIES |
3 |
Celery retry limit |
PAYOUT_PROCESSING_TIMEOUT_SECONDS |
30 |
Stuck payout threshold |
PAYOUT_BANK_FAILURE_RATE |
0.2 |
Simulated failure rate |
cd backend
pytest apps/payouts/tests/test_idempotency.py -vWhat it proves:
- Same key replay returns identical response (200, not 201)
- No duplicate payout/ledger entries on replay
- Same key with different params → 409 conflict
- Concurrent same-key requests → exactly one creates payout
- Same key across different merchants → allowed (scoped per merchant)
cd backend
pytest apps/payouts/tests/test_concurrency.py -vWhat it proves:
- Two concurrent ₹60 payouts with ₹100 balance → one succeeds, one fails
- Ten concurrent payouts → only those within balance succeed
- Different merchants don't block each other (lock scoped to merchant_id)
- Balance never goes negative
MIT