Stage 7 · Master
Phase 9 — Payment Service
Transactions
Use ranked transitions so a delayed provider event can never move a payment backward after later truth is already recorded.
Out-of-Order Webhooks Force a Monotonic Status Model
Payment gateways do not promise that delivery order equals event chronology. A captured event can land, your local state moves forward, and then a delayed authorized event can arrive later because a previous retry was stuck behind a network issue. If your handler blindly runs UPDATE payments SET status = $1 WHERE id = $2, stale information can overwrite newer truth and make downstream systems believe money moved backward in time.
| Status | Rank | Why this rank matters |
|---|---|---|
| created | 0 | A local row exists but the provider has not accepted an initiation yet. |
| initiated | 1 | The provider order exists but no funds have been authorized. |
| authorized | 2 | Funds are reserved and may still progress to capture. |
| captured | 3 | Money has moved successfully and later stale pre-capture events must not win. |
| failed | 4 | Terminal failure for flows that never reached capture. |
| refunded | 5 | A later terminal state that supersedes successful capture. |
The inbox from lesson 2 only says a provider event body is new to you. It does not say that the event represents newer business truth than the payment row already stores. Rank checking is the second guardrail.
State Ranks Make Allowed Progress Visible
The model is deliberately monotonic. Equal-rank transitions are ignored as duplicates, lower-rank transitions are treated as stale, and only a strictly higher rank may update the row. This is also where tenant isolation stays non-negotiable: the guarded update still includes org_id in its WHERE clause so a cross-tenant payment UUID cannot be transitioned accidentally.
Guarded Updates Accept Forward Motion and Drop Stale Transitions
package payment
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/rs/zerolog"
)
type Status string
const (
PaymentStatusCreated Status = "created"
PaymentStatusInitiated Status = "initiated"
PaymentStatusAuthorized Status = "authorized"
PaymentStatusCaptured Status = "captured"
PaymentStatusFailed Status = "failed"
PaymentStatusRefunded Status = "refunded"
)
var statusRanks = map[Status]int16{
PaymentStatusCreated: 0,
PaymentStatusInitiated: 1,
PaymentStatusAuthorized: 2,
PaymentStatusCaptured: 3,
PaymentStatusFailed: 4,
PaymentStatusRefunded: 5,
}
type TransitionStore struct {
logger zerolog.Logger
}
// TransitionStore satisfies the paymentTransitioner interface the webhook
// handler declared last lesson, so wiring it in the composition root replaces
// the temporary stub without touching webhook.go.
var _ paymentTransitioner = (*TransitionStore)(nil)
// NewTransitionStore stores logger by value, the way every zerolog.Logger is
// passed in this platform. There is no nil-pointer default to guard against
// here the way there was for *slog.Logger — a caller with no specific logger
// to hand in simply passes zerolog/log's preconfigured global log.Logger.
func NewTransitionStore(logger zerolog.Logger) *TransitionStore {
return &TransitionStore{logger: logger}
}
func RankFor(status Status) (int16, error) {
rank, ok := statusRanks[status]
if !ok {
return 0, fmt.Errorf("payment: unknown status %q", status)
}
return rank, nil
}
func (s *TransitionStore) Apply(
ctx context.Context,
tx pgx.Tx,
orgID uuid.UUID,
paymentID uuid.UUID,
next Status,
providerEventID string,
) (bool, error) {
rank, err := RankFor(next)
if err != nil {
return false, err
}
var updatedID uuid.UUID
err = tx.QueryRow(ctx,
`UPDATE payments
SET status = $1,
status_rank = $2,
last_provider_event_id = $5,
last_event_at = now(),
updated_at = now()
WHERE org_id = $3
AND id = $4
AND status_rank < $2
RETURNING id`,
next,
rank,
orgID,
paymentID,
providerEventID,
).Scan(&updatedID)
if errors.Is(err, pgx.ErrNoRows) {
s.logger.Info().
Str("payment_id", paymentID.String()).
Str("org_id", orgID.String()).
Str("next_status", string(next)).
Str("provider_event_id", providerEventID).
Msg("dropping stale or duplicate payment transition")
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func IsTerminal(status Status) bool {
return status == PaymentStatusFailed || status == PaymentStatusRefunded
}Rows affected is the whole verdict. One updated row means forward progress; zero rows means the event was equal-rank or regressive and must be logged then dropped.
Inbox Deduplication and Rank Checks Protect Different Failure Modes
The inbox says "this provider event body is new to us". The rank guard says "even if this event body is new, does it represent newer state than what the payment already stores?" Those are different questions. A delayed authorized event after a captured event will often be new to the inbox because the event ID is different, but it should still lose the guarded update and disappear into logs as a stale transition rather than corrupting the row.
- Failure mode: an authorized webhook that arrives after capture must not regress the row back to authorized.
- Security concern: include org_id in the guarded UPDATE so a payment UUID from another tenant cannot be transitioned by mistake.
- Verification target: check for zero rows updated on stale transitions and log them instead of surfacing them as 500s.
Applied exercise
Add charged_back without breaking monotonicity
A provider can emit a chargeback weeks after capture, and finance wants the payment record to represent that terminal loss without reopening earlier transitions like authorized or failed.
- Choose a rank for charged_back relative to captured, failed, and refunded and justify the ordering in one paragraph.
- Update the transition table and the status progression diagram to include the new state.
- List at least two stale-event edge cases involving charged_back, such as a late authorized webhook or a duplicate refund notification.
- Describe whether reconciliation is allowed to move a captured row to charged_back directly or must wait for a provider event.
Deliverable
A revised status model with ranks, updated transition rules, and a short note explaining how chargebacks interact with existing terminal states.
Completion checks
- Your chosen rank prevents any lower-precedence state from overwriting charged_back once it lands.
- You explain how stale pre-chargeback events are safely dropped.
- The proposal still routes all updates through the guarded transition path rather than ad hoc SQL.
Why is `UPDATE payments SET status = $1 WHERE id = $2` unsafe for gateway-driven payment state changes?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.