Step 2c — sell subscriptions against PlanPrice - #798
Draft
mitchelljkotler wants to merge 7 commits into
Draft
Conversation
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-zbx7tu
September 4, 2026 12:39
Inactive
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
from
September 5, 2026 20:02
3310f75 to
9b3db3d
Compare
mitchelljkotler
temporarily deployed
to
squarelet-pi-pricing-mo-zbx7tu
September 5, 2026 20:02
Inactive
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
from
September 8, 2026 20:07
9b3db3d to
68e5e08
Compare
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
from
September 8, 2026 20:19
68e5e08 to
d560949
Compare
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
from
September 8, 2026 20:43
d560949 to
7374c48
Compare
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
2 times, most recently
from
September 9, 2026 16:31
bb61c4d to
ba489ae
Compare
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
from
September 9, 2026 17:45
ba489ae to
c3330a4
Compare
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
4 times, most recently
from
September 9, 2026 20:34
41fc06b to
f751524
Compare
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
from
September 9, 2026 22:09
f751524 to
6c7692d
Compare
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
2 times, most recently
from
September 10, 2026 02:42
4e979a1 to
e145e2d
Compare
The plan a customer picks is not necessarily the plan they end up on. Annual and nonprofit are separate Plan rows today -- you select sunlight-essential-annual, or tick a box and get substituted onto sunlight-nonprofit-essential -- and neither variant gets a PlanPrice. Prices live on the canonical tier, with the difference carried by the price's interval and label. So a purchase resolves through LEGACY_PLAN_MAP to a canonical plan *and* its price, and records both. That makes a new subscription identical to a migrated one, which is what lets the migration pass over it as a genuine no-op rather than approximately one. Where nothing resolves -- any plan until consolidate_stripe_products has run, and any slug the map does not cover -- it falls back to the legacy plan and legacy Stripe id, which is what it would have been anyway. LEGACY_PLAN_MAP moves out of the migration command into its own module, because two callers now need it and they want different things from it. The migration asks where an existing subscription lands, and comped is a legitimate answer. A purchase asks what this costs a new customer, where comped never is -- that would be handing out a free subscription. `resolve_target(allow_comped=...)` is that distinction. Negotiated (code) prices are deliberately *not* blocked: reaching one requires being granted the private plan it belongs to, and that grant is the authorisation. They are simply never picked by default. stripe_items() sends the line's Stripe Price, falling back to the legacy id; Subscription.free decides from plan_price.amount rather than Plan.base_price, so a subscription mixing a paid and a comped line no longer names a Price that does not exist; and stripe_modify() takes a proration argument, which 2d needs and could not previously express. The is_nonprofit checkbox keeps working as it does today -- self-reported, honour system, Sunlight only -- it just now selects a label rather than a different plan row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2c resolved purchases through LEGACY_PLAN_MAP, but that table only ever described legacy rows being consolidated away. None of the six public plans were in it, so every new signup fell through to the legacy plan and legacy Stripe id -- safe, but it meant the step did nothing for the customers it exists for. Adds the six public slugs and the four nonprofit variants. Annual is a separate Plan row rather than an interval today, so `-annual` maps onto the canonical tier with interval="annual", which is what the migration does with them too. The nonprofit variants are not public -- the checkbox substitutes one in - so a purchase arrives already on the variant slug and the map turns it back into a label. Every target verified against the price matrix. The tests pin the slugs rather than the behaviour, on purpose: an unmapped slug falls back silently, which is correct while nothing is set up and indistinguishable from a missing entry once it is. Adding a purchasable plan without a price now fails here. Slugs taken from the live public plan list; the nonprofit ones are derived the same way `Plan.nonprofit_variant_slug` derives them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`start()` decided the subscription's interval and collection method from `plan.annual` before resolving the price, then recorded a price whose own interval came from the mapping. Nothing forces those to agree. They do agree today -- the four nonprofit-annual rows are all flagged annual -- so this was latent rather than live. But the row a customer picks is not always the row they end up on: the form substitutes a nonprofit variant in, and annual is a separate Plan row rather than an interval. Had a flag disagreed with the mapping, an annual price would have landed on a subscription recorded as monthly, joining the wrong invoice and choosing the wrong collection method, while Stripe billed yearly regardless. The shape now follows the price, falling back to the flag only when nothing resolves. The test sets the flag deliberately wrong to prove the mapping wins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_plan_data() found the nonprofit rate by string-substituting the slug and reading base_price off a separate sunlight-nonprofit-* Plan. That is the blocker on deleting those rows -- the page would raise DoesNotExist for every Sunlight plan the moment they went -- and it left the displayed figure and the charged one free to drift, since 2c had already moved billing onto the nonprofit *label* of the canonical plan. Both now resolve the same way, through the canonical plan a purchase would land on. It falls back to the variant row while no PlanPrice exists, which is the window between this shipping and consolidate_stripe_products being run -- without it the discount would simply vanish from the page for the duration. Drops nonprofit_price_per_user, which nothing read: the TypeScript uses only nonprofit_base_price and has_nonprofit_variant. Worth noting it could not have been sourced from PlanPrice anyway, which carries no per-unit rate -- and the nonprofit tiers do discount blocks as well as the base, so a page that showed per-user pricing would have needed somewhere else to get it. 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. Two seeded-data traps, both the same underlying one. PlanFactory get-or-creates on *name* while slug is an AutoSlugField, so asking for a slug the migration-seeded data already holds under another name quietly yields `<slug>-2`. Anything resolving by slug then finds the seeded row instead of the one the test just set up. And a seeded plan arrives with real entitlements attached, which the grant check reads -- so tests that never mention entitlements were comparing a bare legacy plan against a fully entitled canonical one. Fixtures now adopt the seeded row rather than making a second one, and clear its entitlements; the tests that care attach their own.
The item split added `is_free` reading `plan.free`; this branch added a second definition reading `plan_price.amount` and falling back to the plan. Both survived, and the later one silently won -- which happened to be the plan-only version, so a line with a paid price on a plan whose legacy base_price is zero was dropped from what Stripe is told to bill. The price-aware one is a superset, so it is the one that stays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch bills against PlanPrice, so `stripe_items` sends `stripe_price_id` where it used to send the plan's legacy Stripe id. `sync_stripe_item_ids` matches Stripe's response against what was sent, so it has to read the same field - otherwise it matches nothing, writes back no ids, and the self-heal quietly stops working on exactly the release that still needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0151rwxtvLg1vKEiz7XsJfzf
mitchelljkotler
force-pushed
the
pricing-models-signup-price
branch
from
September 10, 2026 17:48
e145e2d to
c48190d
Compare
mitchelljkotler
removed this pull request from stack #810
September 10, 2026 17:51
mitchelljkotler
added this pull request to stack #812
September 10, 2026 17:51
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.
Stacks on #784.
Switches the sign-up flow onto
PlanPricebefore the migration, not after. That's what lets #800 be a single run rather than a sweep that has to be repeated — every subscription taken out after this ships already has a price recorded, so the migration has genuinely nothing to do for it.Nothing existing changes. Current subscribers keep billing their legacy Stripe Plan until #800 runs.
What changed
SubscriptionItem.objects.resolve_purchase(plan, nonprofit)— returns(canonical_plan, plan_price)for a plan a customer picked. The plan they pick is not always the plan they end up on: annual and nonprofit are separatePlanrows today, and both collapse onto a canonical tier where the difference is carried by the price'sintervalandlabel.plan_mapping.pynow covers the ten purchasable slugs as well as the legacy ones. One table drives both the sign-up flow and the migration, so a new subscription is recorded exactly as a migrated one is.resolve_target(allow_comped=False)is what stops a self-service purchase resolving to a comped price.The billing shape follows the resolved price, not
plan.annual. Trusting the flag would let an annual price land on a subscription recorded as monthly — which groups it onto the wrong invoice and picks the wrong collection method. This is the subtle one.get_plan_data()reads the nonprofit rate fromPlanPricerather than string-substituting the slug and readingbase_priceoff a separatesunlight-nonprofit-*Plan. Display and billing now resolve the same way, so the figure shown is the figure sold — and it unblocks archiving those rows in #806. Falls back to the variant row while noPlanPriceexists.add_subscription()takesnonprofit, threaded through from the form's checkbox.Degrades safely
resolve_purchasereturns(plan, None)when there's nothing to resolve to — which is every plan untilconsolidate_stripe_productshas run on production, and any plan the mapping doesn't cover. That leaves the subscription on the legacy plan and legacy Stripe id, exactly as it would have been. So this is safe to ship before that command runs; it just does nothing useful yet.QA
Before the consolidation command has run, everything below should behave exactly as it does on master — that's the fallback path, and it's worth confirming first.
After
consolidate_stripe_productshas run on the preview deploy:SubscriptionItemhas aplan_priceatorganization / monthly / standard, andplanpoints at the canonical Organization row. Check Stripe: the subscription bills the new Price id, notsquarelet_plan_organization.Subscription.intervalisannual— not monthly. This is the bug the third bullet above prevents; if it's wrong the line lands on the wrong invoice group.label="nonprofit", and the amount charged is the nonprofit rate.compedprice.Worth checking in the admin afterwards: every subscription created during QA has a non-null
plan_price. That is the property #800 depends on.🤖 Generated with Claude Code
https://claude.ai/code/session_0151rwxtvLg1vKEiz7XsJfzf