Skip to main content

Transaction lifecycle

A transaction's status reflects where the money is. Most paths are short and terminal — pending → paid → done. Refunds extend the chain.

Drafted from planning · v0.1

States and transitions

State definitions

StateWhat it means
pendingSale started; payment hasn't completed; inventory not yet decremented
paidPayment captured; inventory decremented; receipt generatable
voidedSale was cancelled before payment captured; no inventory impact
refunded_partialSome money returned; some line items "live" still
refunded_fullAll money returned; line-item refunds equal original

Side effects of transitions

TransitionSide effects
* → pendingInsert transaction row; insert transaction_lines; commit-decrement inventory (qty_committed +=)
pending → paidDecrement inventory firmly (qty_on_hand -=, qty_committed -=); set stripe_pi_id; generate receipt
pending → voidedRelease 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 statusTypical Stripe PI status
pendingrequires_payment_method or requires_action
paidsucceeded
voidedcanceled (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_cents and change_cents recorded
  • 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