Skip to content

Step 2c — sell subscriptions against PlanPrice - #798

Draft
mitchelljkotler wants to merge 7 commits into
pricing-models-item-splitfrom
pricing-models-signup-price
Draft

Step 2c — sell subscriptions against PlanPrice#798
mitchelljkotler wants to merge 7 commits into
pricing-models-item-splitfrom
pricing-models-signup-price

Conversation

@mitchelljkotler

@mitchelljkotler mitchelljkotler commented Sep 3, 2026

Copy link
Copy Markdown
Member

Stacks on #784.

Switches the sign-up flow onto PlanPrice before 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 separate Plan rows today, and both collapse onto a canonical tier where the difference is carried by the price's interval and label.

plan_mapping.py now 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 from PlanPrice rather than string-substituting the slug and reading base_price off a separate sunlight-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 no PlanPrice exists.

add_subscription() takes nonprofit, threaded through from the form's checkbox.

Degrades safely

resolve_purchase returns (plan, None) when there's nothing to resolve to — which is every plan until consolidate_stripe_products has 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_products has run on the preview deploy:

  • Buy an Organization plan (monthly). The new SubscriptionItem has a plan_price at organization / monthly / standard, and plan points at the canonical Organization row. Check Stripe: the subscription bills the new Price id, not squarelet_plan_organization.
  • Buy an annual plan. The parent Subscription.interval is annualnot monthly. This is the bug the third bullet above prevents; if it's wrong the line lands on the wrong invoice group.
  • Buy a Sunlight plan with the nonprofit box ticked. The price recorded is label="nonprofit", and the amount charged is the nonprofit rate.
  • The Sunlight plan page shows the nonprofit price, and the number matches what ticking the box actually charges.
  • Buy two plans of the same interval for one organization. Both lines land on one subscription and one invoice (that's Cancel one plan of several, not the whole subscription #784's behaviour, worth re-confirming here).
  • Buy an annual and a monthly plan for one organization. Two separate subscriptions, two invoices — Stripe requires one interval per subscription.
  • A comped organization is unaffected — no self-service purchase should ever resolve to a comped price.

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

@allanlasser
allanlasser temporarily deployed to squarelet-pi-pricing-mo-fidtev September 3, 2026 17:03 Inactive
@allanlasser
allanlasser temporarily deployed to squarelet-pi-pricing-mo-zbx7tu September 3, 2026 17:38 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-zbx7tu September 4, 2026 12:39 Inactive
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-signup-price branch from 3310f75 to 9b3db3d Compare September 5, 2026 20:02
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-zbx7tu September 5, 2026 20:02 Inactive
@mitchelljkotler mitchelljkotler changed the title Pricing models signup price Step 2c — sell subscriptions against PlanPrice Sep 8, 2026
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-signup-price branch from 9b3db3d to 68e5e08 Compare September 8, 2026 20:07
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-signup-price branch from 68e5e08 to d560949 Compare September 8, 2026 20:19
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-signup-price branch from d560949 to 7374c48 Compare September 8, 2026 20:43
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-signup-price branch 2 times, most recently from bb61c4d to ba489ae Compare September 9, 2026 16:31
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-signup-price branch from ba489ae to c3330a4 Compare September 9, 2026 17:45
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-signup-price branch 4 times, most recently from 41fc06b to f751524 Compare September 9, 2026 20:34
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-signup-price branch from f751524 to 6c7692d Compare September 9, 2026 22:09
@mitchelljkotler
mitchelljkotler force-pushed the pricing-models-signup-price branch 2 times, most recently from 4e979a1 to e145e2d Compare September 10, 2026 02:42
mitchelljkotler and others added 7 commits September 10, 2026 13:26
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
mitchelljkotler force-pushed the pricing-models-signup-price branch from e145e2d to c48190d Compare September 10, 2026 17:48
@mitchelljkotler
mitchelljkotler removed this pull request from stack #810 September 10, 2026 17:51
@mitchelljkotler
mitchelljkotler added this pull request to stack #812 September 10, 2026 17:51
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