DEV Community

Daniel Parkson Tano
Daniel Parkson Tano

Posted on AI-assisted

Model Payment Statuses as a State Machine

Turning scattered payment updates into explicit, enforceable lifecycle rules.

A payment status is not an arbitrary label. It represents a position in a controlled financial lifecycle.

Real transactions can be pending, processing, completed, failed, expired, reversed, or refunded. Problems appear when API handlers, background workers, webhooks, and staff tools can assign these values independently.

Define the valid transitions instead:

TRANSITIONS = {
    "pending": {"processing", "failed", "expired"},
    "processing": {"completed", "failed"},
    "failed": {"refunded"},
    "completed": set(),
    "refunded": set(),
}
Enter fullscreen mode Exit fullscreen mode

Route every change through one domain service. Inside a database transaction, lock the payment row, validate the transition, save the provider evidence, and apply any ledger effect exactly once. Emit notifications only after the database commit succeeds.

Terminal states need special protection. A delayed processing event must never move a completed payment backward. Likewise, two simultaneous success signals must not produce two credits.

State machines make those rules explicit, testable, and shared by every entry point. In payment infrastructure, that is far safer than treating status as a writable string.

Centralize transitions instead of centralizing status writes

A common first improvement is to create a helper that updates the status field. That reduces duplication, but it does not yet protect the lifecycle. The service must understand the meaning of each transition and the work attached to it.

Moving from processing to completed may release a hold, post a ledger entry, update a linked order, and schedule a notification. Moving from processing to failed may refund the sender. These are domain operations, not model assignments.

The transition service should accept evidence, not trust callers to supply a final state blindly. A provider adapter can normalize its response into a small internal event containing the provider reference, normalized status, event time, and sanitized payload. The transition layer then decides whether that evidence is newer, valid, and financially actionable.

Protect transitions from concurrency

Imagine a polling worker and webhook handler receiving success at nearly the same time. Both read processing; both decide to complete; both attempt to credit the recipient. A state-machine table alone does not prevent this race.

Use a database transaction and row-level lock. After acquiring the lock, re-read the current state and check whether the financial effect already exists. Back the ledger reference with a unique database constraint. Application checks explain intent; database constraints provide the final concurrency guarantee.

Keep transition history

The current state answers where the payment is now. It does not explain how it got there. Record transition history with the previous state, next state, source, actor, provider evidence, and timestamp.

This history makes support and reconciliation substantially easier. It also reveals operational patterns such as transactions repeatedly moving into manual review or providers producing late events.

Test the lifecycle as a graph

Useful tests cover more than successful examples. Verify every permitted edge, every rejected edge, terminal-state protection, duplicate success signals, late failure events, and concurrent completion attempts.

The strongest invariant is simple: regardless of event order or retry count, the transaction reaches one valid state and every financial effect occurs at most once.

Implementation checklist

Before shipping a lifecycle, document every state and transition, identify the service responsible for each edge, define terminal states, and specify the ledger effect attached to every financial transition. Add database constraints for unique effects and structured transition logs for investigation. Review all writers of the status field; ideally, none should remain outside the transition service.

Monitor invalid transition attempts as well. They often reveal late provider events, duplicate jobs, or an endpoint bypassing the domain layer. A state machine is valuable not only because it permits valid movement, but because it makes invalid movement visible.

The result is a payment lifecycle that remains understandable under retries, concurrency, and operational intervention.

Top comments (0)