How I Handle Payment Wallets and Refunds in Django
A practical ledger-first approach to wallets, refunds, and payment webhooks on production Django booking and marketplace platforms.
When you build booking platforms or marketplaces with real money flowing through them, "payments" is never just a checkout button. Wallets, partial refunds, failed captures, and support disputes all need a clear data model from day one.
Here is the approach I use on production Django systems like Fetazone — a booking platform with wallets, refunds, and ticket scanning — without turning the codebase into an accounting nightmare.
Separate ledger events from user balances
The biggest mistake is storing a single `wallet_balance` field and mutating it on every action. Instead, I treat the wallet as a derived value:
- Every credit, debit, hold, release, and refund is an immutable `LedgerEntry` row.
- The displayed balance is the sum of posted entries for that wallet.
- Support can audit any dispute by replaying the ledger.
This pattern costs a few extra rows but saves weeks when a client asks "why did this user's balance change on March 3?"
Model refunds as state machines, not boolean flags
A refund is rarely instant. It might be requested, pending gateway confirmation, partially approved, or rejected. I use explicit status fields and guard transitions in the service layer — never in the view.
Typical states: `requested → approved → processing → completed` with branches for `rejected` and `failed`. Each transition creates a ledger entry only when money actually moves.
Never refund more than the captured amount
Database constraints help here. I store `captured_amount` on the payment record and validate refund totals in a single `RefundService` class that:
1. Locks the payment row (`select_for_update`).
2. Sums existing completed refunds.
3. Rejects anything that would exceed the capture.
Views call the service; they never touch amounts directly.
Use idempotency keys for webhooks
Payment gateways retry webhooks. Without idempotency, you double-credit wallets. Every inbound webhook gets an idempotency key stored in a `ProcessedWebhook` table before side effects run.
What I would do differently on a greenfield project
- Add a `currency` field everywhere from the start — even if you only support one today.
- Log actor + reason on every manual adjustment (support credits are real money).
- Build a read-only admin "wallet timeline" view early; support will live in it.
When this matters for your project
If you are scoping a booking system, marketplace escrow, or any platform where users hold a balance, nail the ledger model before you wire up the payment UI. The UI is easy to change; bad money semantics are expensive to fix in production.
Need something similar built? I ship full payment and wallet flows as part of platform builds — solo, end-to-end, with Django.