Skip to content

Step 2d — migrate every subscription in one run - #800

Draft
mitchelljkotler wants to merge 7 commits into
pricing-models-signup-pricefrom
pricing-models-migration
Draft

Step 2d — migrate every subscription in one run#800
mitchelljkotler wants to merge 7 commits into
pricing-models-signup-pricefrom
pricing-models-migration

Conversation

@mitchelljkotler

Copy link
Copy Markdown
Member

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.

@allanlasser
allanlasser temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 3, 2026 17:38 Inactive
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-migration branch from eac0d42 to 1c63e7c Compare September 4, 2026 12:38
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 4, 2026 12:39 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 4, 2026 14:25 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 4, 2026 14:44 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 4, 2026 15:33 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 4, 2026 16:36 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 4, 2026 17:35 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 4, 2026 17:53 Inactive
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-migration branch from ed3c69e to d07cac5 Compare September 4, 2026 18:25
@mitchelljkotler mitchelljkotler changed the title Bring the backfill command above the split Step 2d — migrate every subscription in one run Sep 4, 2026
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 4, 2026 18:25 Inactive
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-migration branch from d07cac5 to 96af2f8 Compare September 5, 2026 20:02
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-7fqpzw September 5, 2026 20:02 Inactive
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-migration branch 2 times, most recently from 15ead5f to e761032 Compare September 8, 2026 20:19
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-migration branch from e761032 to ab39036 Compare September 8, 2026 20:43
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-migration branch from ab39036 to 9295243 Compare September 9, 2026 15:26
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-migration branch from 9295243 to 7e3e34e Compare September 9, 2026 16:31
mitchelljkotler and others added 7 commits September 9, 2026 13:26
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
mitchelljkotler force-pushed the pricing-models-migration branch from 7e3e34e to 66d7880 Compare September 9, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants