A scheduled change is marked applied before Stripe carries it out, and is never retried #177

Open
opened 2026-10-06 21:24:47 +00:00 by cgalo5758 · 3 comments
Owner

core.subscription_scheduled_changes holds rows Stripe carries out (period end), which the reconcile in internal/fulfillment/reconcile.go follows, and rows only the console carries out (commitment boundary), which the sweep in internal/fulfillment/sweep.go fires. The sweep marks a row applied, then calls Stripe. If the call fails, no later sweep retries it, and a failed cancellation leaves the member billed. Nothing writes commitment-boundary rows yet; minimum terms will (#17, #38).

"Applied" is also written for changes that never happened: when a subscription ends, every still-scheduled row is marked applied, downgrades included. And one pending change per subscription is kept by the writers only, with no unique index.

Options: a firing and a failed state with a retry budget; applied only after Stripe confirms; or not storing period-end moves, which Stripe holds.

Done when: the billing model states who owns each kind of row and its states, a failed firing is retried or recorded, an ended subscription supersedes unapplied downgrades, and a partial unique index holds one pending change.

`core.subscription_scheduled_changes` holds rows Stripe carries out (period end), which the reconcile in `internal/fulfillment/reconcile.go` follows, and rows only the console carries out (commitment boundary), which the sweep in `internal/fulfillment/sweep.go` fires. The sweep marks a row applied, then calls Stripe. If the call fails, no later sweep retries it, and a failed cancellation leaves the member billed. Nothing writes commitment-boundary rows yet; minimum terms will (#17, #38). "Applied" is also written for changes that never happened: when a subscription ends, every still-scheduled row is marked applied, downgrades included. And one pending change per subscription is kept by the writers only, with no unique index. Options: a firing and a failed state with a retry budget; applied only after Stripe confirms; or not storing period-end moves, which Stripe holds. Done when: the billing model states who owns each kind of row and its states, a failed firing is retried or recorded, an ended subscription supersedes unapplied downgrades, and a partial unique index holds one pending change.
cgalo5758 added the
kind
design
area/billing
labels 2026-10-06 21:24:47 +00:00
Author
Owner

Two more paths of the same claim-before-act shape. A due period-end cancellation is cancelled at Stripe without checking whether Stripe still has cancel_at_period_end set, so a cancellation cleared while the subscription was past due, or whose update event never arrived, is carried out at its effective time. A quantity_change row is marked applied and nothing else happens; nothing writes such rows today. Both are now pinned by the sweep's trace tests, so a fix changes those goldens on purpose.

Two more paths of the same claim-before-act shape. A due period-end cancellation is cancelled at Stripe without checking whether Stripe still has `cancel_at_period_end` set, so a cancellation cleared while the subscription was past due, or whose update event never arrived, is carried out at its effective time. A `quantity_change` row is marked applied and nothing else happens; nothing writes such rows today. Both are now pinned by the sweep's trace tests, so a fix changes those goldens on purpose.
Author
Owner

Two things the sweep reports about a run, which the fix here should settle along with the states.

The count is not a count of applied changes. SweepDueScheduledChanges (internal/fulfillment/sweep.go:49-58) counts every row whose firing returned no error. fireScheduledChange returns no error in four cases where nothing was applied by this run:

  • It skips a row whose Stripe ids the current key cannot reach (line 81). The row stays scheduled.
  • It reconciles a period-end downgrade that Stripe has not carried out yet (line 90). The row stays scheduled.
  • It loses the claim because a webhook, another sweep or an earlier row's reconcile already applied the row (line 96).
  • It claims a quantity_change row and does nothing (line 108).

The number goes out as fired in the workflow's completion log (internal/workflows/billing/sweep_workflow.go:26) and in the activity's result in Temporal. A row that stays unreachable is reported as fired on every run, hourly by default, and a downgrade waiting for Stripe's event is reported as fired on every run until the event lands. The sweep's trace goldens record today's numbers (internal/fulfillment/testdata/sweep_trace/: unreachable_item, stale_downgrade and quantity_change read fired 1, lost_claim reads fired 2), so a fix changes them on purpose. Once firing and failed states exist, the sweep can return one count per outcome.

Four firing errors print a nil. Each of these joins a failed read and a missing Stripe id in one condition, if err != nil || !mapping.StripeSubscriptionID.Valid, and wraps err with %w (sweep.go:70, :200, :204, :208). When the read succeeds and the id is missing, or the subscription has no current item, err is nil and the logged error ends in %!w(<nil>). No writer stores a mapping without its Stripe id today, and the last three are reached only by a commitment-boundary plan switch, so this is latent. A recorded failure would store this text, though. Splitting each condition into two returns fixes it: the read's error wrapped, and a plain error naming the missing id. SwitchPlan and resolveLadderSubscription in internal/fulfillment/plan_change.go (lines 179, 743 and 766) have the same shape.

Two things the sweep reports about a run, which the fix here should settle along with the states. **The count is not a count of applied changes.** `SweepDueScheduledChanges` (`internal/fulfillment/sweep.go:49-58`) counts every row whose firing returned no error. `fireScheduledChange` returns no error in four cases where nothing was applied by this run: - It skips a row whose Stripe ids the current key cannot reach (line 81). The row stays scheduled. - It reconciles a period-end downgrade that Stripe has not carried out yet (line 90). The row stays scheduled. - It loses the claim because a webhook, another sweep or an earlier row's reconcile already applied the row (line 96). - It claims a `quantity_change` row and does nothing (line 108). The number goes out as `fired` in the workflow's completion log (`internal/workflows/billing/sweep_workflow.go:26`) and in the activity's result in Temporal. A row that stays unreachable is reported as fired on every run, hourly by default, and a downgrade waiting for Stripe's event is reported as fired on every run until the event lands. The sweep's trace goldens record today's numbers (`internal/fulfillment/testdata/sweep_trace/`: `unreachable_item`, `stale_downgrade` and `quantity_change` read `fired 1`, `lost_claim` reads `fired 2`), so a fix changes them on purpose. Once firing and failed states exist, the sweep can return one count per outcome. **Four firing errors print a nil.** Each of these joins a failed read and a missing Stripe id in one condition, `if err != nil || !mapping.StripeSubscriptionID.Valid`, and wraps `err` with `%w` (`sweep.go:70`, `:200`, `:204`, `:208`). When the read succeeds and the id is missing, or the subscription has no current item, `err` is nil and the logged error ends in `%!w(<nil>)`. No writer stores a mapping without its Stripe id today, and the last three are reached only by a commitment-boundary plan switch, so this is latent. A recorded failure would store this text, though. Splitting each condition into two returns fixes it: the read's error wrapped, and a plain error naming the missing id. `SwitchPlan` and `resolveLadderSubscription` in `internal/fulfillment/plan_change.go` (lines 179, 743 and 766) have the same shape.
Author
Owner

The fix for #295 gives the sweep's cancel and price update 20 seconds each inside the sweep's one-minute run, where the Stripe library's default allowed 80 seconds and two retries. A call that gets no answer and did not go through is therefore more frequent; the row it claimed stays applied and no later sweep fires it, as above. The same fix lets a member's plan change record its scheduled change after the request ends, so that change's row can stand beside a row a later request recorded meanwhile; a one-pending-change index has to let the later row stand rather than refuse the late one. It can also leave a cancellation at the period end that Stripe carries with no row in the console's record: when the call that sets it went through but its answer was lost, the subscription shows the cancellation, but the sweep has no row to fire if Stripe's event for the subscription's end is lost. Who owns that row belongs to this question.

The fix for #295 gives the sweep's cancel and price update 20 seconds each inside the sweep's one-minute run, where the Stripe library's default allowed 80 seconds and two retries. A call that gets no answer and did not go through is therefore more frequent; the row it claimed stays applied and no later sweep fires it, as above. The same fix lets a member's plan change record its scheduled change after the request ends, so that change's row can stand beside a row a later request recorded meanwhile; a one-pending-change index has to let the later row stand rather than refuse the late one. It can also leave a cancellation at the period end that Stripe carries with no row in the console's record: when the call that sets it went through but its answer was lost, the subscription shows the cancellation, but the sweep has no row to fire if Stripe's event for the subscription's end is lost. Who owns that row belongs to this question.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#177