A member's downgrade is lost when a reconcile releases the old schedule the downgrade has just reused #219
Open
opened 2026-10-12 02:10:30 +00:00 by cgalo5758
·
0 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#219
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.
What happens
A downgrade scheduled while a reconcile of the same subscription is finishing can be dropped at Stripe. The member is told the change is scheduled, and the plan reads "moves to Basic on" the renewal date. Shortly after, that line is gone, and the subscription renews on the higher tier.
A downgrade waits for the renewal.
downgradeAtRenewal(internal/fulfillment/plan_change.go:339) attaches a Stripe subscription schedule with two phases, the current price until the period ends and the lower price after it, and writes the console price it was made from into the schedule's metadata (scheduled_from_price_id). When the subscription already has a schedule attached, it does not make a new one: it reads the attached schedule (line 355) and rewrites its phases and metadata (lines 396-397).The reconcile,
ReconcileSubscriptionininternal/fulfillment/reconcile.go, brings the console's records in line with Stripe after each subscription event and from the billing sweep. It releases a schedule the console attached once the subscription no longer carries the price in the schedule's metadata, because that downgrade landed or the member moved since (findSpentSchedule, line 892). It reads the subscription and its schedule once, at the start (line 66), decides from that copy, commits, and then releases the schedule by id (line 926). It does not read the schedule again before the release. The release runs after the commit, outside the subscription's lock, and a downgrade takes no lock.So a downgrade that rewrites the attached schedule after the reconcile's read and before its release is released with it. Stripe detaches the schedule, and the lower price never starts. The downgrade's row in
core.subscription_scheduled_changesis then superseded: by the same reconcile if it had already listed the row, or by the next one, which finds no schedule attached.The window opens only while a spent schedule is still attached: after an earlier downgrade lands at renewal, until a reconcile releases its schedule; and after an upgrade whose own release of the schedule failed, which
plan_change.go:315-324leaves to the reconcile. It lasts one reconcile, from its read of the subscription to its release call.What should happen
A downgrade the console confirmed stays scheduled at Stripe until it lands or the member changes it, and a reconcile releases only a schedule that holds nothing new.
downgradeAtRenewalreleases the attached schedule, taking one that is already released as done, and then creates a new one from the subscription, the way the cancellation releases the attached schedule before it sets its own change (line 585). Stripe makes a schedule from a subscription only when none is attached, so the release comes first. A reconcile holding the old id can then release only a schedule that is spent. It costs one more Stripe call per downgrade, and changing the target of a pending downgrade replaces its schedule instead of rewriting it.The first closes the race and is the one to take. A test can show it with the mock Stripe backend in
internal/stripetest: itsHandlehook runs the downgrade when the reconcile's release call arrives, and the test checks that Stripe still holds the downgrade and its row stays scheduled. The mock answers every schedule call with one id today, so it needs a second id for the new schedule.Where
internal/fulfillment/plan_change.go(downgradeAtRenewal);internal/fulfillment/reconcile.go(ReconcileSubscription,findSpentSchedule,releaseSpentSchedule).Steps
Why it matters
The member is told a downgrade is scheduled, nothing tells them it was dropped, and they pay the higher tier for another period.
Related: #177 asks who owns each kind of scheduled-change row and what its states mean.