Transaction lifecycle
A transaction's status reflects where the money is. Most paths are short and terminal — pending → paid → done. Refunds extend the chain.
States and transitions
State definitions
| State | What it means |
|---|---|
pending | Sale started; payment hasn't completed; inventory not yet decremented |
paid | Payment captured; inventory decremented; receipt generatable |
voided | Sale was cancelled before payment captured; no inventory impact |
refunded_partial | Some money returned; some line items "live" still |
refunded_full | All money returned; line-item refunds equal original |
Side effects of transitions
| Transition | Side effects |
|---|---|
* → pending | Insert transaction row; insert transaction_lines; commit-decrement inventory (qty_committed +=) |
pending → paid | Decrement inventory firmly (qty_on_hand -=, qty_committed -=); set stripe_pi_id; generate receipt |
pending → voided | Release inventory (qty_committed -=); no receipt |
paid → refunded_* | Insert child refund transaction; potentially return inventory (qty_on_hand +=); Stripe refund call |
Stripe-side states
The transaction status is independent of Stripe's PaymentIntent status, but they correlate:
| Helm status | Typical Stripe PI status |
|---|---|
pending | requires_payment_method or requires_action |
paid | succeeded |
voided | canceled (never reached succeeded) |
refunded_* | succeeded (still; refunds are separate entity in Stripe) |
The daily cron reconciles: any Helm-paid transaction without a stripe_pi_id is a bug; any Stripe succeeded PI without a corresponding Helm transaction is a bug.
Idempotency and retry
The Stripe call uses an idempotency key derived from transaction.id + attempt number. See ADR-0015.
A network blip during charge:
- Worker may retry inside the request (same idempotency key) → Stripe returns the original result if succeeded
- If retries exhaust, the Worker returns 503 → the UI offers a "Retry" button → operator click increments attempt → new idempotency key
Cash sales
For cash sales, payment_method='cash':
- No external API call; transaction goes pending → paid synchronously
tendered_centsandchange_centsrecorded- No idempotency concern (operation is local)
Refunds
A refund creates a child transactions row with kind='refund' and parent_transaction_id pointing to the original sale. The parent's status updates to refunded_partial or refunded_full based on cumulative refund amounts.
Stripe refunds are issued against the original PaymentIntent. Cash refunds are recorded as cash returned.
Audit
Every transition writes:
audit_events:action='transaction.created'|'transaction.paid'|'transaction.refunded'|'transaction.voided'audit_mutations: before/after snapshots
The audit chain captures who rang the sale, who refunded, when, and the line-item detail.
See also
- Transaction entity
- Service ticket entity — cashout from service tickets follows this lifecycle
- Slice 5 — Transactions & Payments
- Data flow diagrams
- ADR-0011: Stripe Terminal