Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0ef43c5
Bring the backfill command above the split
mitchelljkotler Sep 2, 2026
d552fc6
Record Stripe's id for each subscription line
mitchelljkotler Sep 4, 2026
de7adfb
Migrate every subscription in one run, Stripe included
mitchelljkotler Sep 4, 2026
e5b0462
Scope the decomposition to the plans that actually have block-holders
mitchelljkotler Sep 4, 2026
6e28970
Stop quantity multiplying the tier price
mitchelljkotler Sep 4, 2026
ce001e5
Check what the organization receives, not only what it pays
mitchelljkotler Sep 4, 2026
5b8889c
Stop these tests depending on whether the database was flushed
mitchelljkotler Sep 5, 2026
ad07b3f
Identify a line on Stripe before repointing it
mitchelljkotler Sep 18, 2026
4ad9294
Treat a migrated line as done on a re-run
mitchelljkotler Sep 18, 2026
64379da
Preflight the comped packs as well as the standard ones
mitchelljkotler Sep 18, 2026
7577d72
Let the grant check understand decomposition
mitchelljkotler Sep 18, 2026
b57e6ed
Preflight comped block-holders whose blocks grant something
mitchelljkotler Sep 18, 2026
9277316
Move both Custom CRP organizations to standard Organization
mitchelljkotler Sep 18, 2026
09efd66
Map Flexible Users (Annual Invoice) to comped Organization, for now
mitchelljkotler Sep 18, 2026
65de4a0
Map DocumentCloud Premium; leave the Election Cohort alone
mitchelljkotler Sep 21, 2026
f5a90cc
Seed manufactured subscribers to rehearse the migration without produ…
mitchelljkotler Sep 21, 2026
4c30a20
Make the post-run reports honest on a dry run
mitchelljkotler Sep 21, 2026
69ef9e0
Print the grant note under the org it is about
mitchelljkotler Sep 21, 2026
62d56f8
Stop deferring sunlight-premium-annual
mitchelljkotler Sep 21, 2026
a202f0f
Only require the target prices the pending lines resolve to
mitchelljkotler Sep 21, 2026
b78c4d8
Report every line that blocks the entitlement shape migration
mitchelljkotler Sep 22, 2026
dc5a900
Seed the per-unit line at quantity 1, like production
mitchelljkotler Sep 22, 2026
a600b43
Give the election cohort a coded price at both cadences
mitchelljkotler Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions squarelet/organizations/entitlement_shape.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""How an entitlement's resources turn into a quantity of something.

Two formulas live here, because during the migration both are true at
once: clients evaluate the first today and the second after they switch,
and the whole point of the entitlement shape migration is to reach a state
where they agree. The pricing migration needs the first as well, to check
that repointing a subscription at a different plan has not changed what
its organization receives.
"""

PER_USER_SUFFIX = "_per_user"
BASE_PREFIX = "base_"


def scaling_pairs(resources):
"""The (per-unit key, flat key) pairs this entitlement scales on.

`requests_per_user` pairs with `base_requests`, and so on. An
entitlement with no such pair - Sunlight's research hours, a bare
`feature_level` - does not scale with quantity at all.
"""
pairs = []
for key in sorted(resources):
if not key.endswith(PER_USER_SUFFIX):
continue
base_key = f"{BASE_PREFIX}{key[: -len(PER_USER_SUFFIX)]}"
if base_key in resources:
pairs.append((key, base_key))
return pairs


def base_keys(resources):
"""Every `base_*` key, whether or not it has a per-unit partner.

A flat entitlement - `{"base_requests": 50, "minimum_users": 5}` with no
`requests_per_user` at all - still grants 50, and is exactly the shape
the custom comped plans use. Pairing on `_per_user` alone would make
those invisible to the arithmetic that is supposed to protect them.
"""
return sorted(key for key in resources if key.startswith(BASE_PREFIX))


def grants_old(resources, quantity):
"""What a client grants today, per resource key.

`base + max(quantity - minimum_users, 0) * per_user`, with an absent
per-unit key read as zero - which is how the formula behaves anyway.
"""
minimum = resources.get("minimum_users", 0)
return {
base_key: resources[base_key]
+ max(quantity - minimum, 0)
* resources.get(f"{base_key[len(BASE_PREFIX):]}{PER_USER_SUFFIX}", 0)
for base_key in base_keys(resources)
}


def grants_new(resources, quantity):
"""What a client will grant once it switches: `base * quantity`."""
return {
base_key: resources[base_key] * quantity for base_key in base_keys(resources)
}


def grant_old(resources, quantity):
"""`grants_old` totalled - convenient when there is only one key."""
return sum(grants_old(resources, quantity).values())


def grant_new(resources, quantity):
"""`grants_new` totalled - convenient when there is only one key."""
return sum(grants_new(resources, quantity).values())


def reshape(resources, *, is_pack):
"""The target shape, in which both formulas give the same number.

`minimum_users = 1` and `per_user = base` makes
`base + max(q - 1, 0) * base` identically `base * q` for every q >= 1.

A pack starts inverted - its value lives in `per_user` with `base` at
zero, because that is the only shape the *current* formula can scale -
so the value moves the other way. Applying the tier transform to a
pack would set `per_user = 0` and grant nothing at all, which is the
trap migration 0082 wrote itself a note about.
"""
new = dict(resources)
pairs = scaling_pairs(resources)
if not pairs:
return new
for per_user_key, base_key in pairs:
if is_pack:
new[base_key] = resources[per_user_key]
else:
new[per_user_key] = resources[base_key]
new["minimum_users"] = 1
return new
Loading
Loading