Skip to content

Pricing models - #781

Open
mitchelljkotler wants to merge 19 commits into
masterfrom
pricing-models
Open

Pricing models#781
mitchelljkotler wants to merge 19 commits into
masterfrom
pricing-models

Conversation

@mitchelljkotler

@mitchelljkotler mitchelljkotler commented Aug 26, 2026

Copy link
Copy Markdown
Member

This creates a new PlanPrice model to re-organize our plans using Stripe's price feature. Instead of offering multiple plans with the same functionality but different payment options, there is only one plan per functional plan with different PlanPrice's. This covers monthly vs annual charging, comped accounts, non-profit pricing, and special negotiated rates.

This is still a draft and not ready to merge, just wanted to get this up here.

@allanlasser
allanlasser temporarily deployed to squarelet-pi-pricing-mo-axc6qu August 26, 2026 14:01 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-axc6qu August 26, 2026 15:39 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-axc6qu August 27, 2026 13:03 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-axc6qu August 27, 2026 20:08 Inactive
@mitchelljkotler

mitchelljkotler commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Claude notes:

Additive schema plus Stripe plumbing; changes nobody's bill. PlanPrice, Plan.product/stripe_product_id, the ensure_stripe_* methods, the admin, and consolidate_stripe_products.

Highest-value checks: add a price through the admin and confirm it appears in Stripe with squarelet_variant metadata; try editing that price's amount and confirm it's refused; run consolidate_stripe_products twice and confirm the second run creates nothing.


I ran consolidate_stripe_products twice and it worked as expected. I also ensured that existing price's have read only fields for fields which shouldn't change.

@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-fyxwgw September 1, 2026 13:57 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-fyxwgw September 1, 2026 15:50 Inactive
@mitchelljkotler
mitchelljkotler temporarily deployed to squarelet-pi-pricing-mo-fyxwgw September 1, 2026 15:56 Inactive
@mitchelljkotler
mitchelljkotler marked this pull request as ready for review September 1, 2026 17:24
mitchelljkotler and others added 15 commits September 4, 2026 17:03
A Stripe Price with no `recurring` block cannot be a subscription item,
and access here is granted exclusively through subscription lines -- so a
one_time PlanPrice would bill correctly and grant nothing.  It was also
reachable from the admin dropdown, where saving one raised ValueError out
of create_price and returned a 500.

Nothing used it.  A plan that should charge once and stop already has a
mechanism: Plan.auto_renew=False creates an ordinary subscription with
cancel_at_period_end set, so the customer keeps access for the period
they paid for and the existing Resubscribe action renews it if they want.

A real one-off purchase -- a credit top-up, a setup fee -- needs its own
model, and the questions that come with it (expiry, refunds, whether it
grants entitlements at all) should shape that design rather than be
guessed at now.  Re-adding the choice then is one line.

Folded into 0081 rather than added as a new migration: nothing here is
deployed, and a new 0083 would collide with the rename migration on the
branch stacked above this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ATOMIC_REQUESTS wraps every admin request in a transaction, so the
warning this code already carries -- that Stripe objects cannot be rolled
back and so must not be created inside one -- applies to the admin path
it did not cover.  Creating a Price and then hitting any later error in
the request rolled the database back and left the Price behind, and the
obvious next move, clicking save again, made a second one.

ensure_stripe_price now looks for an existing Price before creating one,
keyed on what the price *is* -- plan, interval, label, code, amount,
currency -- rather than on the local row's primary key, which a rollback
discards.  A retry therefore adopts the orphan instead of duplicating it.
That covers the admin formset and supersede() as well, without moving
Stripe calls into on_commit, which would have hidden failures from
whoever clicked save.

supersede() no longer wraps its Stripe call in @transaction.atomic; only
the two database writes are atomic.  It had no callers, so this was a
trap set for whoever wired it up rather than a live fault.

consolidate_stripe_products treated any existing active row as finished,
so a row left blank by a failed Stripe call was skipped by every later
run while the command reported success -- the opposite of the "re-running
after a partial failure completes the remainder" its docstring promises.
It now completes such rows and reports them separately.

Adds the first tests for PlanPrice, including the rollback-and-retry case
that all of this turns on.  There were none, which is why this went
unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ensure_stripe_product had exactly the orphan problem ensure_stripe_price
was just fixed for, and its own docstring names it: "a Product that
exists in Stripe but whose ID was never persisted is an orphan nothing
can find again".  Saving the ID immediately does not prevent that, because
ATOMIC_REQUESTS makes every admin request a transaction that can roll the
save back afterwards.  It now looks the Product up by slug first and
adopts it.

Nothing stopped an admin editing the amount, currency or interval of a
PlanPrice that already has a Stripe Price.  Stripe will not change a
Price, so the row would quote one figure while subscribers kept being
billed another -- the same silent divergence between what is displayed
and what Stripe charges that this project already has one live bug from.
clean() now rejects those three fields once a Price exists and points at
supersede.  label and code stay editable: they are local classification
and change nothing Stripe charges.

Reversing 0082 raised a bare ProtectedError once
consolidate_stripe_products had run, because PlanPrice.plan is PROTECT.
It now says so, and says why dropping those rows automatically would be
wrong: they point at live Stripe Prices that reversing cannot delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pack grants AI credits; only the slug still said pages, left over
from when DocumentCloud billed by page.  Slugs are effectively permanent
once they reach production, and this one has not, so it is free to fix
now: 0082 has never been applied outside development.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Run the tier and pack migrations first" is misleading when they have
already run.  0082 resolves each pack's OIDC client by finding which
client's entitlements carry its resource key, and skips the pack when no
client carries it or when more than one does -- so the migration can
report success having created only some of the packs, and the first sign
of it is this command failing with advice that does not apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pack's resources are per-unit rather than flat -- base_requests 0,
minimum_users 0, requests_per_user 10 -- so what the client actually
grants is 10 x quantity.  Squarelet's half of that is emitting `resources`
untouched and `quantity` beside it, and nothing asserted on either.  A
refactor that dropped quantity would leave every pack granting its base
once, which is zero, and every existing test would still pass.

Built from factories rather than the migration's packs on purpose: a
transactional test flushes migration seed data, so anything relying on it
is empty by the time it runs.

Verified the tests fail when quantity is hardcoded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It still said partner pricing was deliberately absent and that such deals
ride on a standard or nonprofit price with a coupon.  That was true before
the `code` field existed; it is not now.  Stripe has no negative coupon,
so an above-list negotiated rate cannot be expressed as a discount on one
-- which is why `code` exists, and why below-list negotiated rates use it
too rather than being handled a second way.  Coupons are kept for
time-limited promotions.

The `code` field's own help_text already said this, so the class docstring
was contradicting the field directly beneath it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mitchelljkotler and others added 4 commits September 4, 2026 17:03
Both pointed at a document a reviewer does not have, and both used its
step numbering - "Step 2d", "Step 3c", "Step 3e", "Step 3h" - which means
nothing outside it.  One of those references was in a CommandError, so it
reached operators too.

Replaced with what the reader actually needs: why the price matrix is
hardcoded, why negotiated rates are prices rather than coupons, and what
the pack resources shape is for.  The pack comment now spells out the trap
in full rather than gesturing at the step that will hit it - flattening a
pack the way a tier is flattened leaves it granting nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.get()` does not exist on a StripeObject.  It is dict-*like* but not a
dict, so `product.metadata.get(...)` raises AttributeError -- which is
what running consolidate_stripe_products against staging hit, on the very
first lookup.  A product carrying no metadata at all raises too, on the
attribute access itself.

The codebase already knew this: get_current_period_end carries a comment
saying `.get` is not available on newer Stripe typed objects and uses
bracket notation instead.  I wrote both of these lookups anyway.

Metadata is now converted with `.to_dict()` before reading, tolerating
both an absent `metadata` and objects created outside squarelet.

The reason no test caught it is that every existing test mocks the plan
service wholesale, so the provider methods never ran.  Added tests that
exercise them against real StripeObject values built with
`construct_from`, which reproduce the staging traceback exactly when the
fix is reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model already refused edits to amount, currency and interval once a
Stripe Price exists, but the admin still presented them as editable -- so
the way you found out was by typing a new value and having the form reject
it.  A field that cannot be changed should not invite the attempt.

They are now `disabled` on a row that already has a Stripe Price.  That is
`disabled` rather than the admin's `readonly_fields` because the decision
has to be made per row: an inline's get_readonly_fields hook receives the
*parent* object, never the individual instance, so readonly there could
only mean "lock the amount on every price, including newly added ones".

Both are equally safe against a tampered POST -- a readonly field is
excluded from the form entirely, and a disabled one keeps its initial
value -- so that is not what decides between them.

`label` and `code` stay editable; they are local classification and change
nothing Stripe charges.  The model's clean() stays as the backstop for
anything not going through the admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections to the previous commit.

The rule was "locked once a Stripe Price exists", which left comped rows
editable because they never get one.  A comped row has no *Stripe*
counterpart but is still what an organization was granted, and changing
what it says it costs makes it describe something else.  Only a brand-new
row is editable now; anything saved is locked.

And a disabled input still reads as editable at a glance, which defeats
the point of not offering the edit.  The locked fields now render with the
admin's own readonly markup and class, so they look like what they are.

Still a form-level lock rather than `readonly_fields`, because it has to
vary per row: an inline's hook sees only the parent object, so readonly
there would lock the empty row being added as well.  label, code and
active stay editable - they are what supersede is driven by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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