Step 2d — migrate every subscription in one run - #800
Draft
mitchelljkotler wants to merge 7 commits into
Draft
Conversation
mitchelljkotler
force-pushed
the
pricing-models-migration
branch
from
September 4, 2026 12:38
eac0d42 to
1c63e7c
Compare
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-7fqpzw
September 4, 2026 12:39
Inactive
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-7fqpzw
September 4, 2026 14:25
Inactive
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-7fqpzw
September 4, 2026 14:44
Inactive
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-7fqpzw
September 4, 2026 15:33
Inactive
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-7fqpzw
September 4, 2026 16:36
Inactive
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-7fqpzw
September 4, 2026 17:35
Inactive
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-7fqpzw
September 4, 2026 17:53
Inactive
mitchelljkotler
force-pushed
the
pricing-models-migration
branch
from
September 4, 2026 18:25
ed3c69e to
d07cac5
Compare
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-7fqpzw
September 4, 2026 18:25
Inactive
6 tasks
mitchelljkotler
force-pushed
the
pricing-models-migration
branch
from
September 5, 2026 20:02
d07cac5 to
96af2f8
Compare
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-7fqpzw
September 5, 2026 20:02
Inactive
7 tasks
mitchelljkotler
force-pushed
the
pricing-models-migration
branch
2 times, most recently
from
September 8, 2026 20:19
15ead5f to
e761032
Compare
mitchelljkotler
force-pushed
the
pricing-models-migration
branch
from
September 8, 2026 20:43
e761032 to
ab39036
Compare
mitchelljkotler
force-pushed
the
pricing-models-migration
branch
from
September 9, 2026 15:26
ab39036 to
9295243
Compare
mitchelljkotler
force-pushed
the
pricing-models-migration
branch
from
September 9, 2026 16:31
9295243 to
7e3e34e
Compare
Unchanged from where it sat below the split, apart from being at its post-split form -- so the diff that follows shows only the work of turning it into the single migration run. It moved because that command needs the split. The transition is now one pass over every subscription: set plan_price, repoint plan, decompose per-user subscribers into a base line plus a usage pack, and push the result to Stripe with proration suppressed. Adding a pack line needs SubscriptionItem and stripe_modify, neither of which exists below the split, so the command cannot live there. What is here is still the local-only half and must not be run: it sets plan_price without switching Stripe, which is exactly the half-state the single-run design exists to avoid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stripe_item_id was read in three places and written in none. It is the id `stripe_items(include_ids=True)` sends so Stripe updates a line in place -- and the spec omits it when blank, which is how you ask Stripe to *add* a line instead. So any line Stripe created for us grew a duplicate on every subsequent modify, and the customer was billed for all of them. Nothing has tripped this yet because nothing modifies a subscription twice. The migration does: it is deliberately re-runnable, with no "done" marker to skip a subscription whose local half committed and whose Stripe half did not, so a second pass over an already-migrated subscriber has to be a no-op rather than a second pack line. Matching on the Price id is what makes the write-back safe -- a subscription cannot hold the same Price twice, so the correspondence is one-to-one. Note stripe_sub["items"] rather than .items: a StripeObject is dict-like, and attribute access reaches the dict method instead of the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes step 2d. The command set plan_price and repointed plan; it now also decomposes per-user subscribers into a tier line plus usage packs and pushes the whole result to Stripe with proration suppressed. These were two steps while the decomposition was triggered by each subscriber's renewal and spread over a year. As a single bulk run the split stopped earning its keep -- the switchover calls modify on every subscription anyway, so adding a pack line is the same API call, and nobody passes through a state where they are billed wrongly. Four things this had to get right that a local-only backfill did not: - Not one transaction. Stripe cannot be rolled back, so a run that failed part way and discarded the local record of what Stripe had already done would be the worst available outcome. Each subscription commits alone; a failure is reported and the rest continue. - No "done" marker. Selecting on plan_price__isnull=True would strand exactly the rows most needing a second attempt. Every line is reprocessed and modify is a no-op once the items match. - proration_behavior="none" suppresses the mid-cycle adjustment, not the next invoice, so the bill survives only if the amounts genuinely agree. Each subscriber's bill is recomputed both ways and a mismatch is refused rather than assumed away. - Blocks are not one pack. A block granted MuckRock requests *and* DocumentCloud credits, so how many packs it becomes is a per-plan fact: Sunlight blocks cost two packs' worth and become two, Organization blocks cost one and give up the DocumentCloud half -- 37 credits used across all twelve affected subscribers for all time. That last check subsumes the two special cases the plan had been carrying separately. Nonprofit rows and legacy Sunlight Basic both charge less per block than a standard pack; rather than name them, the command refuses anyone whose arithmetic does not come out. Nonprofit and legacy-basic decompose onto one pack rather than two and reconcile exactly. LEGACY_PLAN_MAP now lives only in plan_mapping, where the sign-up flow already read it -- the command had been carrying a second copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Production has twelve organizations holding blocks over their minimum and every one is on Organization or Organization (Annual). The nine Sunlight entries I had written were inference from the price matrix, not observation -- nothing exercises them, and dead config that reads as authoritative is worse than absent config that fails loudly. Nobody can join the twelve either: the purchase flow hardcodes minimum_users, so self-service cannot sell a block at all. If a block-holder ever does appear on another plan the preflight refuses to run until it is listed, which is the behaviour that makes trimming safe. Also pins the twelve real quantities in a test that checks each one's bill against the actual PRICE_MATRIX. Both plans reconcile exactly -- $100 + $10/blk monthly, $1,200 + $120/blk annual -- so this is what stands between a wrong number in the matrix and a dry run that migrates nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mitch caught this: most organizations sit exactly on their minimum, at quantity 5 on a plan with minimum_users 5, and the command left the quantity alone because there were no blocks to decompose. That would have billed them $500 a month instead of $100. The cause is that the two price shapes are not the same kind of object. make_stripe_plan builds a group plan as a *graduated tiered* price -- a flat base for everything up to minimum_users, then a rate per block above it -- so quantity never multiplied the base. Every PlanPrice is per_unit, so it does. Any group line left at its block count therefore bills that many tiers. So the tier line drops to quantity 1 whether or not it holds blocks, and the bill check prices both sides as Stripe would, quantity included. The check had compared a unit amount against a total, which is why it passed. Non-group plans keep their quantity: they were already per_unit at base_price, so quantity was always a multiplier and setting it to 1 would cut the bill rather than preserve it. blocks_held returns 0 for them too -- subtracting minimum_users from a plan that never had an included tier invents blocks that were never sold. Comped group lines keep their block count, since nothing bills and the entitlement is still computed from it; dropping them to 1 without a pack would be a quota cut today. The run now names them, because step 2e cannot transform a line that still carries blocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consolidation repoints a line at a different plan, and a plan is where entitlements hang -- so a subscription can keep billing exactly the same money while what it grants moves underneath it. Nothing checked that. The sharp case is a comped organization on a custom plan whose own entitlement is flat, repointed onto Organization's, which scales. It still carries a block count nobody has looked at in years, so at quantity 30 it would have gone from 50 requests to 50 + 25*10 = 300 -- silently, at this step, without moving a cent. So the same treatment the bill already got. Each subscriber's grant is totalled before repointing and after, per client and resource key rather than per entitlement, since swapping one plan's entitlement rows for another's is the whole operation. A mismatch is refused with both numbers printed. Where the change is deliberate -- Beta 5 -> 20 requests, InsideClimate 15 -> 50, Education Plan 0 -> 50 -- it is recorded in EXPECTED_GRANT_CHANGES with the reason and reported instead. Comped lines now decompose like everyone else, onto the comped pack prices added here at $0. That is what lets their quantity drop to 1 without cutting the grant, and it costs nothing: a comped subscription reaches Stripe at no point, and a zero amount keeps is_free true so a later start() cannot bill it. Nothing self-service can reach these -- packs are non-public plans, there is no UI to add one, and the comped label is what the plan permission rule refuses on. With that, every non-pack line ends at quantity 1, which is the entitlement shape migration's precondition; the leftover report now names exactly what that step would refuse on rather than a narrower set. grants_old also counts flat base_ keys with no per-unit partner. Pairing on _per_user alone made the custom comped entitlements invisible to the arithmetic meant to protect them. No nonprofit packs: their blocks decompose onto one standard pack instead of two, and if a nonprofit subscriber ever does hold blocks the grant check refuses rather than quietly dropping the DocumentCloud half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They passed on a reused test database and failed on a fresh one, which is the shape CI runs. A migration-seeded plan arrives with real entitlements attached, and the grant check reads them -- so tests that never mention entitlements were comparing a bare legacy plan against a fully entitled canonical one, and decomposition onto a seeded pack showed up as a grant appearing out of nowhere. The fixtures now clear entitlements on any row they adopt; the tests that care attach their own. The group-pricing cases also say for_groups=True explicitly. A seeded `professional` is for_groups=False, so blocks_held read zero and the decomposition tests were exercising nothing.
mitchelljkotler
force-pushed
the
pricing-models-migration
branch
from
September 9, 2026 17:45
7e3e34e to
66d7880
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Unchanged from where it sat below the split, apart from being at its post-split form -- so the diff that follows shows only the work of turning it into the single migration run.
It moved because that command needs the split. The transition is now one pass over every subscription: set plan_price, repoint plan, decompose per-user subscribers into a base line plus a usage pack, and push the result to Stripe with proration suppressed. Adding a pack line needs SubscriptionItem and stripe_modify, neither of which exists below the split, so the command cannot live there.
What is here is still the local-only half and must not be run: it sets plan_price without switching Stripe, which is exactly the half-state the single-run design exists to avoid.