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
Labels
Clear labels
accessibility
area/billing
area/catalog
area/discourse
area/domains
area/entitlements
area/fedwiki
area/identity
area/integrations
area/licensing
area/member-ui
area/meta
area/operator-ui
area/ops
area/testing
duplicate
good-first-issue
invalid
privacy
security
upstream
wontfix
A barrier for people using assistive technology or a keyboard alone.
The Stripe mirror, checkout, subscriptions, invoices, fulfillment.
Products, prices, plan ladders, purchasability.
The Discourse integration.
The domains registry, claims, placements, the certificate ask.
Entitlement sets, rules, grants, pools, provisioning.
The Federated Wiki integration and farm sync.
Sign-in, sessions, persons, organizations, workspaces, roles.
The provider registry, outbox and webhooks in general.
Licenses, the contributor agreement, SPDX headers.
Member pages.
The repository itself, its contributing guide, CI, the tracker and the workflow.
Operator pages, forms, lists, the design system.
Deployment, configuration, migrations, workflows, instance settings.
The test stack, screens, lint, walkthroughs.
Closed because another issue already covers it.
Small, self-contained, and explained enough to be a first contribution.
Closed because it is not a ticket for this repository.
Touches what a person's data reveals.
Touches authentication, authorization, secrets or data exposure.
Waits on another repository or project before it can move.
Closed because it will not be done, with the reason in the last comment.
kind
bug
The software does something other than what it promises; closed when it again does what it promises.
kind
debt
Code, tests or tooling to clean up with nothing visible changing; closed when they are cleaner.
kind
design
A question to settle before work can be defined; closed when the decision is written down.
kind
docs
Documentation that is wrong or missing; closed when it says the right thing.
kind
enhancement
Something the software does not do yet; closed when it does.
priority
critical
Blocks the active milestone or harms members now.
priority
high
Next in line inside the active milestone.
priority
low
Inside the active milestone, when nothing else is left.
priority
medium
Inside the active milestone, after the high ones.
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: wiki-cafe/member-console#177
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
core.subscription_scheduled_changesholds rows Stripe carries out (period end), which the reconcile ininternal/fulfillment/reconcile.gofollows, and rows only the console carries out (commitment boundary), which the sweep ininternal/fulfillment/sweep.gofires. 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.
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_endset, so a cancellation cleared while the subscription was past due, or whose update event never arrived, is carried out at its effective time. Aquantity_changerow 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 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.fireScheduledChangereturns no error in four cases where nothing was applied by this run:quantity_changerow and does nothing (line 108).The number goes out as
firedin 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_downgradeandquantity_changereadfired 1,lost_claimreadsfired 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 wrapserrwith%w(sweep.go:70,:200,:204,:208). When the read succeeds and the id is missing, or the subscription has no current item,erris 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.SwitchPlanandresolveLadderSubscriptionininternal/fulfillment/plan_change.go(lines 179, 743 and 766) have the same shape.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.