Turning scattered status updates into enforceable payment lifecycle rules.
Payment systems often begin with a status column and a few convenient values: pending, successful, and failed.
As the product grows, more states appear. A customer has authorized the payment, but the provider has not completed it. An agent has accepted an order but has not paid the recipient. Funds are reserved while an external transfer is processing. A transaction expires, but the customer can still dispute it.
If every endpoint updates the status independently, the status field stops representing a lifecycle. It becomes a collection of guesses.
An explicit state machine makes the permitted paths visible and enforceable.
Status should describe business progress
A useful payment state describes what the platform knows and which actions remain valid.
A simplified lifecycle might be:
pending -> processing -> completed
| |
| +------> failed
+------------------> cancelled
+------------------> expired
processing or expired -> disputed
The exact states depend on the product. What matters is that transitions are intentional.
For example:
- pending: the transaction exists but no party has committed to fulfillment;
- processing: an external provider or human operator is actively fulfilling it;
- completed: the financial obligation has been satisfied;
- failed: fulfillment ended unsuccessfully;
- cancelled: the transaction was intentionally stopped while cancellation was still valid;
- expired: the allowed action window ended;
- disputed: normal completion rules are suspended for investigation.
Put transition rules in the domain layer
Endpoints should request transitions rather than assign statuses directly.
class Transfer:
def mark_paid(self):
if self.status not in {"pending", "processing"}:
raise InvalidTransition()
self.paid_at = now()
self.status = "processing"
def complete(self):
if self.status != "processing":
raise InvalidTransition()
self.completed_at = now()
self.status = "completed"
def dispute(self, reason):
if self.status in {"completed", "cancelled", "disputed"}:
raise InvalidTransition()
self.status = "disputed"
self.dispute_reason = reason
This keeps API endpoints, webhooks, background workers, and staff tools from inventing different transition rules.
Terminal states need strong protection
Completed, cancelled, and failed transactions are usually terminal. A delayed event should not reopen them.
Terminal protection should be checked while the transaction is locked:
with database_transaction():
transfer = lock_transfer(reference)
if transfer.status in TERMINAL_STATES:
return transfer
transfer.apply_provider_result(result)
transfer.save()
Without the lock, two actors may both observe a non-terminal state and trigger conflicting side effects.
Side effects belong to transitions
A status change is rarely the only effect. Completing a withdrawal may also:
- convert a hold into a debit;
- write a ledger entry;
- complete a linked transaction;
- record a receipt;
- notify the customer; and
- close an operator task.
These effects should be coordinated by one transition service. If each caller independently updates related records, the system can end up with a completed transaction, an unreleased hold, and a still-open operational task.
Where possible, financial and state changes should be atomic. Notifications and other external work can be queued after the database commit.
Expiration is a state transition, not a UI timer
A countdown displayed in an application is not authoritative. The backend must determine whether the deadline has passed and whether expiration is still legal.
Only eligible states should expire. A transaction already processing with an external rail should not be expired merely because its original initiation deadline passed. Doing so creates a race between an expiration task and a late success event.
Background expiration workers should lock each candidate and re-check its state before applying the transition.
Linked transactions require one completion authority
Financial products sometimes represent one customer action through two linked records. For example, a withdrawal request may be fulfilled through a marketplace transfer.
The dangerous design lets either record complete independently. That can duplicate notifications, ledger mutations, or payouts.
Instead, define:
- which transaction owns the customer-facing lifecycle;
- which one represents fulfillment;
- which transition completes the linked record;
- how retries detect that completion already occurred; and
- how cancellation and disputes propagate.
Preserve timestamps for meaningful transitions
updated_at is not enough. Store timestamps that answer operational questions:
- when was the order accepted?
- when did the customer mark it paid?
- when did processing start?
- when was it completed?
- when did it expire?
These timestamps support support investigations, service-level monitoring, dispute handling, and future analytics.
The design principle
A transaction status is a compressed representation of business truth. It should change only through named transitions that enforce prerequisites and coordinate their financial effects.
When the state machine is explicit, asynchronous providers, human operators, background jobs, and staff tools can all participate without silently contradicting one another.
This structure also makes failures easier to reason about. Instead of asking which endpoint last edited a status string, engineers can inspect a defined transition, its prerequisites, and the side effects that should have occurred with it. That clarity is valuable during development, operations, and reconciliation.
Top comments (0)