A payment engine simulation designed with robust financial systems in mind, built using Django, DRF, PostgreSQL, Celery, and Redis. It features exact-once processing, Redis-based atomic idempotency using Lua scripts, strict row-level locking for balance management, and a transactional outbox pattern to guarantee event delivery.
The Playto Payout Engine is designed with institutional-grade reliability, ensuring exact-once processing and absolute financial safety under high concurrency.
Core Technical Value Drivers:
- Zero Race Conditions: The system models balances via an append-only Ledger rather than a mutable row. Pessimistic PostgreSQL row-level locks (
SELECT FOR UPDATE) strictly prevent overdrawing, even during simultaneous API requests. - Exact-Once Execution: An atomic Redis Lua script acts as an impenetrable idempotency gate, blocking duplicates at the cache layer with zero database penalty.
- Guaranteed Delivery: A Transactional Outbox pattern guarantees that if a payout is committed to the database, the asynchronous background worker (Celery) will process it. Event drops are impossible.
- Self-Healing Mechanics: Bank gateway timeouts are automatically caught, retried with exponential backoff, and eventually rolled back securely, releasing held funds without manual intervention.
- System Architecture & Request Flow
- Asynchronous Outbox Worker Flow
- Data Model & Relationships
- Reconciliation API Sync Flow
The system is designed to handle high concurrency without race conditions, overdrawn accounts, or duplicate processing. Below is a high-level architecture diagram showing how the React dashboard, Django API, PostgreSQL, Redis, and Celery workers interact:
The flowchart below traces every possible path a payout request can take from the client to the final HTTP response. This is the synchronous portion of the system — the client receives an immediate acknowledgement while actual processing happens asynchronously.
flowchart TD
%% Define colors
classDef default fill:#1E293B,stroke:#475569,stroke-width:1px,color:#E2E8F0;
classDef client fill:#0ea5e9,stroke:#0284c7,stroke-width:2px,color:#ffffff;
classDef redis fill:#ef4444,stroke:#dc2626,stroke-width:2px,color:#ffffff;
classDef error fill:#f97316,stroke:#ea580c,stroke-width:2px,color:#ffffff;
classDef success fill:#22c55e,stroke:#16a34a,stroke-width:2px,color:#ffffff;
classDef db fill:#3b82f6,stroke:#2563eb,stroke-width:2px,color:#ffffff;
Client[Client Request]:::client --> API[Django API]
API --> RedisLua["Redis Lua Script (Atomic Gate)"]:::redis
RedisLua -- "Key = PENDING" --> Conflict[409 Conflict]:::error
RedisLua -- "Key = JSON Response" --> OK[200 OK Cached Response]:::success
RedisLua -- "Redis Unreachable" --> ServerError[500 Internal Server Error]:::error
RedisLua -- "Key = null → SET PENDING" --> DB_Tx[Start DB Transaction]:::db
subgraph PostgreSQL Transaction
DB_Lock["Row Lock Merchant (SELECT FOR UPDATE)"]
DB_Bal[Validate Balance]
DB_Payout[Insert Payout]
DB_Ledger[Insert Ledger HOLD]
DB_Outbox[Insert OutboxEvent]
end
style PostgreSQL Transaction fill:#0f172a,stroke:#3b82f6,stroke-width:2px,stroke-dasharray: 5 5
DB_Tx --> DB_Lock
DB_Lock --> DB_Bal
DB_Bal --> DB_Payout
DB_Payout --> DB_Ledger
DB_Ledger --> DB_Outbox
DB_Outbox --> Return["202 Accepted (Redis key stays PENDING)"]:::success
How to read this diagram:
- Every payout request first hits the Redis Lua script — a single atomic operation that checks if an idempotency key already exists.
- If the key is
PENDING, another request is already in-flight → return 409 Conflict. - If the key contains a JSON response (written by the Celery worker after processing), replay it as 200 OK — this is the idempotent replay.
- If Redis is unreachable, the system cannot safely proceed without the idempotency gate → return 500.
- If the key doesn't exist, set it to
PENDINGand proceed into a single PostgreSQL transaction that acquires a row-level lock on the merchant, validates the balance, and atomically inserts the payout, aHOLDledger entry, and an outbox event. - The client receives 202 Accepted immediately — actual bank processing happens asynchronously.
This diagram shows the asynchronous background processing pipeline. Once the synchronous API request is done, the Celery infrastructure takes over to actually "send" the payout to the bank.
flowchart LR
%% Define colors
classDef default fill:#1E293B,stroke:#475569,stroke-width:1px,color:#E2E8F0;
classDef celery fill:#10b981,stroke:#059669,stroke-width:2px,color:#ffffff;
classDef db fill:#3b82f6,stroke:#2563eb,stroke-width:2px,color:#ffffff;
classDef bank fill:#8b5cf6,stroke:#7c3aed,stroke-width:2px,color:#ffffff;
classDef success fill:#22c55e,stroke:#16a34a,stroke-width:2px,color:#ffffff;
classDef failure fill:#ef4444,stroke:#dc2626,stroke-width:2px,color:#ffffff;
classDef retry fill:#eab308,stroke:#ca8a04,stroke-width:2px,color:#ffffff;
CeleryBeat[Celery Beat Scheduler]:::celery --> |Every 10s| RelayTask[relay_outbox Task]:::celery
RelayTask --> |"Poll OutboxEvent (skip_locked)"| DB_Outbox[(PostgreSQL OutboxEvent)]:::db
DB_Outbox --> |Dispatch| ProcessTask[process_payout Task]:::celery
ProcessTask --> Simulation{Bank Gateway Simulation}:::bank
Simulation -- "70% Success" --> SuccessPath[Update Payout → SUCCESS]:::success
Simulation -- "20% Failure" --> FailPath[Rollback → FAILED]:::failure
Simulation -- "10% Timeout" --> RetryPath["Re-enqueue (Exp. Backoff)"]:::retry
SuccessPath --> WriteLedger["Write DEBIT + Delete HOLD"]:::db
FailPath --> DeleteHold[Delete HOLD Ledger]:::db
WriteLedger --> UpdateRedis["Update Redis Key → JSON Response"]:::redis
DeleteHold --> UpdateRedis
How to read this diagram:
- Celery Beat fires the
relay_outboxtask every 10 seconds, which polls PostgreSQL for unprocessedOutboxEventrows usingskip_locked(preventing multiple workers from grabbing the same event). - Each event is dispatched as a
process_payoutCelery task into the Redis queue. - The worker simulates a bank gateway with three probabilistic outcomes:
- Success (70%) — the payout status is updated to
SUCCESS, aDEBITledger entry is written, and theHOLDentry is removed. - Failure (20%) — the payout status is set to
FAILED, and theHOLDentry is deleted (balance is released back). - Timeout (10%) — the task is re-enqueued with exponential backoff for retry.
- Success (70%) — the payout status is updated to
- On terminal states (SUCCESS or FAILED), the Redis idempotency key is updated from
PENDINGto the final JSON response, enabling future idempotent replays.
-
Redis-Only Idempotency: An atomic Redis Lua script acts as the sole idempotency gate. If the key exists as
PENDING, return409 Conflict. If it contains JSON (written by the Celery worker), replay it as200 OK. If Redis is unreachable, the API returns500 Internal Server Error— it cannot safely proceed without the idempotency gate. -
Ledger Over Mutable Balance: Balances are derived dynamically using
SUM()aggregations on an append-onlyLedgerrather than storing a mutable integer, eliminating race conditions. -
Pessimistic Row-Level Locking: PostgreSQL's
SELECT ... FOR UPDATEensures only one request modifies a merchant's ledger at any given moment. -
Transactional Outbox Pattern: An
OutboxEventis committed within the same transaction as the ledger hold. A Celery worker later relays these events, guaranteeing exactly-once delivery. -
Worker-Driven State Finalization: The Celery worker updates the Redis idempotency key from
PENDINGto the final JSON response once the payout reaches a terminal state (SUCCESSorFAILED).
For more details on these architectural decisions, please see EXPLAINER.md.
playto/
├── api/ # Django app for API routes and views
├── assets/ # Static assets (architecture diagrams)
├── playto/ # Django project settings
├── web/ # React frontend (Vite)
├── docker-compose.yml # Docker compose for full stack
├── docker-compose.dev.yml # Docker compose for local DB & Redis
├── Dockerfile # Multi-stage Dockerfile for backend/celery
├── Makefile # Local development shortcuts
├── requirements.txt # Python dependencies
├── seed.py # Database seed script
├── test_e2e.py # End-to-end test script
└── test_benchmark.py # Load/benchmark test script
The class diagram below shows the four core database models and their relationships. All IDs are UUIDs. The Merchant is the top-level entity that owns payouts and ledger entries. Each Payout generates exactly one OutboxEvent for asynchronous processing, and can reference multiple Ledger entries (a HOLD on creation, then a DEBIT or deletion on resolution).
classDiagram
class Merchant {
+UUID id
+String name
+String email
+DateTime created_at
+DateTime updated_at
}
class Payout {
+UUID id
+UUID merchant_id
+String bank_account_id
+BigInt amount_paise
+String status
+Int retry_count
+DateTime created_at
+DateTime updated_at
}
class Ledger {
+UUID id
+UUID merchant_id
+String entry_type
+BigInt amount_paise
+UUID payout_id
+DateTime created_at
}
class OutboxEvent {
+UUID id
+String event_type
+JSON payload
+String status
+DateTime created_at
+DateTime processed_at
}
Merchant "1" --> "*" Payout : has
Merchant "1" --> "*" Ledger : has
Payout "1" --> "*" Ledger : references
Payout "1" --> "1" OutboxEvent : triggers
Key relationships:
- A Merchant can have many payouts and ledger entries. The merchant's available balance is computed as
SUM(ledger entries)rather than stored as a mutable field. - Each Payout starts with a
HOLDledger entry (reserving funds) and ends with either aDEBIT(on success) or deletion of the hold (on failure). - Each Payout triggers exactly one OutboxEvent, ensuring the async processing is guaranteed to fire even if the worker is temporarily down.
This state diagram shows the lifecycle of a single payout. A payout can only move forward through these states — there are no backward transitions except for the timeout retry loop between PROCESSING and PENDING.
stateDiagram-v2
classDef pending fill:#eab308,stroke:#ca8a04,color:white,font-weight:bold;
classDef processing fill:#3b82f6,stroke:#2563eb,color:white,font-weight:bold;
classDef success fill:#22c55e,stroke:#16a34a,color:white,font-weight:bold;
classDef failed fill:#ef4444,stroke:#dc2626,color:white,font-weight:bold;
[*] --> PENDING : Payout Created
PENDING --> PROCESSING : Worker Picks Up
PROCESSING --> SUCCESS : Bank Approved
PROCESSING --> FAILED : Bank Declined
PENDING --> PROCESSING : Retry After Timeout
PROCESSING --> PENDING : Timeout (re-enqueued)
FAILED --> [*] : Terminal
SUCCESS --> [*] : Terminal
class PENDING pending
class PROCESSING processing
class SUCCESS success
class FAILED failed
State descriptions:
- PENDING — The payout has been created and is waiting for a Celery worker to pick it up from the outbox.
- PROCESSING — A worker has claimed the payout and is simulating the bank gateway call.
- SUCCESS — The bank approved the transaction. The ledger is updated with a
DEBITentry and theHOLDis removed. This is a terminal state. - FAILED — The bank declined the transaction. The
HOLDledger entry is deleted, releasing the reserved funds back to the merchant. This is a terminal state. - Timeout loop — If the bank gateway hangs (10% probability), the payout is re-enqueued back to
PENDINGwith exponential backoff, and the cycle repeats.
The full stack runs as 7 containers via docker-compose.yml:
| Container | Image / Build | Port | Purpose |
|---|---|---|---|
playto-postgres |
postgres:15 |
5432 | Primary database (persistent volume) |
playto-redis |
redis:7-alpine |
6379 | Celery broker + idempotency gate |
playto-backend |
./Dockerfile |
8000 | Django API server (Gunicorn) |
playto-celery-worker |
./Dockerfile |
— | Payout processing worker |
playto-celery-beat |
./Dockerfile |
— | Periodic outbox relay scheduler |
playto-flower |
./Dockerfile |
5555 | Celery Flower monitoring dashboard |
playto-frontend |
./web/Dockerfile |
5173 | React dashboard (Vite) |
docker compose up -ddocker compose down| Service | URL |
|---|---|
| Django API | http://localhost:8000 |
| React Dashboard | http://localhost:5173 |
| Flower Dashboard | http://localhost:5555 |
For local development, only PostgreSQL and Redis run in Docker. The Django server, Celery, and the frontend run natively.
- Docker & Docker Compose
- Python 3.12+ with a virtual environment
- Node.js 20+
- GNU Make (or use the commands directly)
docker compose -f docker-compose.dev.yml up -d
# Or using Makefile:
# make db-start# Windows
.\.venv\Scripts\activate
pip install -r requirements.txt
# macOS/Linux
source .venv/bin/activate
pip install -r requirements.txtpython manage.py migrate
python seed.py
# Or using Makefile:
# make migrate
# make seedTerminal 1: Django API on localhost:8000
python manage.py runserver
# Or using Makefile: make serverTerminal 2: Celery worker (solo pool)
celery -A playto worker --loglevel=info -P solo
# Or using Makefile: make celery-workerTerminal 3: Celery Beat scheduler
celery -A playto beat --loglevel=info
# Or using Makefile: make celery-beatTerminal 4: Vite dev server on localhost:5173
cd web && npm run dev
# Or using Makefile: make frontendTerminal 5: Flower dashboard on localhost:5555
celery -A playto flower --port=5555
# Or using Makefile: make flowerCreate a .env file inside web/ with your merchant UUID:
VITE_MERCHANT_ID=<your-merchant-uuid>
Note: All environment variables (
DATABASE_URL,REDIS_URL) have local defaults baked intosettings.py. You can run the app without a.envfile as long as PostgreSQL and Redis are available onlocalhost.
| Command | Description |
|---|---|
make db-start |
Start PostgreSQL and Redis containers (detached) |
make db-stop |
Stop PostgreSQL and Redis containers |
make db-logs |
Tail logs from database containers |
| Command | Description |
|---|---|
make server |
Start the Django development server |
make migrate |
Run Django database migrations |
make seed |
Seed the database with test data |
| Command | Description |
|---|---|
make celery-worker |
Start Celery worker (solo pool for Windows) |
make celery-beat |
Start Celery Beat scheduler |
make flower |
Start Flower monitoring dashboard (port 5555) |
| Command | Description |
|---|---|
make frontend-install |
Install frontend npm dependencies |
make frontend |
Start the Vite dev server |
| Command | Description |
|---|---|
make docker-up |
Start all 7 containers |
make docker-down |
Stop all containers |
make docker-build |
Rebuild all Docker images |
make docker-logs |
Tail logs from all containers |
| Command | Description |
|---|---|
make test |
Run Django test suite |
make clean |
Remove Docker volumes and stopped containers |
make help |
Show all available commands |
Run the E2E test suite using testcontainers to automatically spin up an isolated PostgreSQL instance:
python manage.py test api
# Or using Makefile:
# make testOur test suite natively covers core edge cases to ensure financial invariants are met under all conditions. Simple tests (like basic successful creation) are omitted from this summary for brevity:
- Missing Header: Rejects requests lacking an
Idempotency-Keyheader with400 Bad Request. - Duplicate Key (PENDING): Validates that if a request shares a key with a currently executing payout, it returns
409 Conflict. - Insufficient Balance: Validates ledger logic to ensure
400 Bad Requestif funds are too low. - Concurrency Overwithdrawing: A ThreadPoolExecutor concurrently bombards the endpoint to try and overdraw the account. It proves that row-level locking (
SELECT FOR UPDATE) prevents race conditions, blocking and failing subsequent requests when the balance is exhausted.
We successfully performed load testing against the local Docker environment to validate the concurrency mechanisms, idempotency guarantees, and system throughput.
| Metric | Result | Target / Threshold |
|---|---|---|
| Max Virtual Users (VUs) | 100 | - |
| Total Requests | 12,617 | - |
| Throughput (RPS) | ~140 req/s | - |
| Average Latency | 375 ms | - |
| p(95) Latency | 670 ms | < 500 ms ❌ |
| p(99) Latency | 752 ms | < 1000 ms ✅ |
| Error Rate (Failed Payouts) | 20.73% | < 1% ❌ |
Why the 20% Failure Rate is a Success: The 20.73% "failure" rate in this specific benchmark is actually a brilliant proof of the system's strict ledger invariants! The merchant was seeded with exactly ₹100,000 (10,000,000 paise). The load test requested 12,617 payouts of ₹10 (1000 paise) each. Exactly 10,000 requests succeeded (exhausting the exact balance to zero), and the remaining 2,617 requests were correctly blocked with a
400 Insufficient Fundserror, proving that our pessimistic row-level locks strictly prevent overdrawing even under massive concurrent load! The high p(95) latency is a direct consequence of requests waiting in the database lock queue.
Prerequisites: Ensure k6 is installed.
Run the load tests against the running API:
k6 run k6_load_test.js
# Or using Makefile:
# make test-loadFor detailed information on the high-throughput 10,000+ RPS test suite (including the Payout, Reconciliation, and Mixed Load tests), see Performance & Load Testing.
For more details on the new Reconciliation flow, see Reconciliation API.
This project is licensed under the MIT License.