Payout engine for Playto Pay — merchants accumulate balance from customer payments and withdraw to their Indian bank accounts.
Stack: Django + DRF · PostgreSQL · Celery + Redis · React + Tailwind
Requires Docker and Docker Compose.
docker-compose up --buildThis starts Postgres, Redis, Django (port 8000), Celery worker, Celery beat, and React (port 3000).
On first run, backend automatically runs migrations and seeds 3 merchants with credit history.
- Dashboard: http://localhost:3000
- API: http://localhost:8000/api/v1/
- Python 3.9+
- PostgreSQL running locally
- Redis running locally
- Node.js 18+
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txtCreate a .env file in backend/:
SECRET_KEY=your-secret-key
DATABASE_URL=postgres://postgres:postgres@localhost:5432/playto
REDIS_URL=redis://localhost:6379/0
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
python manage.py migrate
python seed.py # seeds 3 merchants + credit history
python manage.py runserverIn a second terminal (Celery worker):
cd backend && source venv/bin/activate
celery -A config worker --loglevel=infoIn a third terminal (Celery beat — for retry logic):
cd backend && source venv/bin/activate
celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseSchedulercd frontend
npm install
npm run devcd backend && source venv/bin/activate
python manage.py test payoutsTests:
- ConcurrencyTest — two simultaneous 6000 paise requests on a 10000 paise balance. Asserts exactly one succeeds, one is rejected, final balance is 4000.
- IdempotencyTest — same request sent twice with same
Idempotency-Key. Asserts one payout row, both responses identical. - StateMachineTest — asserts
failed → completed,completed → pending,pending → completedall raiseInvalidTransition.
All payout endpoints require:
X-Merchant-Id: <id>header — identifies the merchant (no auth for demo)
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/merchants/ |
List all merchants |
| GET | /api/v1/merchants/<id>/balance/ |
Available + held balance |
| GET | /api/v1/merchants/<id>/ledger/ |
Last 50 ledger entries |
| POST | /api/v1/payouts/ |
Create payout |
| GET | /api/v1/payouts/ |
List payouts |
| GET | /api/v1/payouts/<id>/ |
Single payout detail |
POST /api/v1/payouts/ — required headers:
Idempotency-Key: <uuid>
X-Merchant-Id: <merchant_id>
Content-Type: application/json
Body:
{ "amount_paise": 50000, "bank_account_id": 1 }No balance column. Balance is always computed as SUM(credits) - SUM(debits) from LedgerEntry rows. A stored balance can drift; a derived balance cannot.
select_for_update() for concurrency. Locks the merchant row before the balance check so two concurrent requests serialize at the DB. The check and debit are in the same transaction.atomic() — no TOCTOU race possible.
IntegrityError catch for idempotency. A unique constraint on (merchant, key) handles the in-flight race without a pre-check + lock pattern. The DB does the work.
State machine in the model. transition_to() enforces legal transitions for every caller. Failed and completed are terminal — no outgoing transitions.
Atomic fund return. Status change and compensating credit are in the same transaction.atomic(). A payout cannot be marked failed without returning funds, and funds cannot be returned without the status change.
Initial balances after seeding (will change as payouts are made):
| Merchant | Starting Balance |
|---|---|
| Rahul Sharma — DesignCo | ₹9,000 |
| Priya Patel — DevStudio | ₹23,000 |
| Amit Verma — ContentLab | ₹2,550 |