Problem
After working on #666, the codebase now has two parallel implementations of the same concept:
Subscription.update_on is referenced by a branch in the restore_organization task that advances it's update_on value monthly and broadcasts cache invalidations to clients.
EntitlementGrant.update_on is referenced by a second branch in restore_organization that does the same exact thing.
Both Subscription and EntitlementGrant are sources that confer entitlements on organizations. Both refresh monthly. The cadence is hard-coded in both places, and the serializer has to tie-break across them ("pick the soonest update_on date among all entitlements").
EntitlementGrant was designed to stand as a sibling to Subscription, but suffers from the same faulty assumption: that refresh cadence belongs to the source. It doesn't. Resources (monthly requests, AI credits, etc.) and their refresh cadence are properties of an entitlement.
A plan and a grant are sources; the entitlement is the contract.
Proposal
This is a rough sketch — details may change during shaping and implementation.
- Move cadence to
Entitlement. Add refresh_interval = DurationField() (default 1 month) and an optional refresh_anchor for billing-cycle alignment.
- Unify the "grant" abstraction. Every source that confers an entitlement (subscriptions, explicit/rule-based grants, or other future sources like coupons) become a row in a join model that remembers the
update_on date. This model joins organizations with an entitlement and the source (subscription, grant) that confers the entitlement.
- Collapse
restore_organization to one loop over join rows whose update_on has passed, advancing by the entitlement's refresh_interval. Instead of storing update_on on each organization then finding each entitlement to update, we store the information on the relationship between the org and its entitlement. This is more efficient and allows for more flexibility in how frequently entitlements refresh.
- Move per-org resource counters (like
Organization.max_users and similar) onto entitlements, so they can be updated and adjusted as orgs change plans.
Subscription remains as a record and manager for billing-specific logic — Stripe sub_id, cancel_at_period_end, etc. — but no longer locks in the refresh cadence.
What this gets us
- We can create and offer entitlements that update outside of a monthly cadence. For example, we could offer AI Credits that refresh daily or hourly, instead of monthly.
- Resources from multiple sources compose naturally (a plan giving 100 docs + a grant giving +50 docs) instead of one shadowing the other.
- The serializer's "soonest
update_on" tie-break goes away — the date lives on the source row, not on the source model.
- One refresh task, one broadcast point and simpler reasoning about cadence with many fewer database requests per task run.
Costs + risks
- Existing
Subscription.update_on values need to migrate to the new join table.
- Existing use of
org.subscription.update_on or subscription.cancel-style logic needs review.
Problem
After working on #666, the codebase now has two parallel implementations of the same concept:
Subscription.update_onis referenced by a branch in therestore_organizationtask that advances it'supdate_onvalue monthly and broadcasts cache invalidations to clients.EntitlementGrant.update_onis referenced by a second branch inrestore_organizationthat does the same exact thing.Both
SubscriptionandEntitlementGrantare sources that confer entitlements on organizations. Both refresh monthly. The cadence is hard-coded in both places, and the serializer has to tie-break across them ("pick the soonestupdate_ondate among all entitlements").EntitlementGrantwas designed to stand as a sibling toSubscription, but suffers from the same faulty assumption: that refresh cadence belongs to the source. It doesn't. Resources (monthly requests, AI credits, etc.) and their refresh cadence are properties of an entitlement.A plan and a grant are sources; the entitlement is the contract.
Proposal
This is a rough sketch — details may change during shaping and implementation.
Entitlement. Addrefresh_interval = DurationField()(default 1 month) and an optionalrefresh_anchorfor billing-cycle alignment.update_ondate. This model joins organizations with an entitlement and the source (subscription, grant) that confers the entitlement.restore_organizationto one loop over join rows whoseupdate_onhas passed, advancing by the entitlement'srefresh_interval. Instead of storingupdate_onon each organization then finding each entitlement to update, we store the information on the relationship between the org and its entitlement. This is more efficient and allows for more flexibility in how frequently entitlements refresh.Organization.max_usersand similar) onto entitlements, so they can be updated and adjusted as orgs change plans.Subscriptionremains as a record and manager for billing-specific logic — Stripesub_id,cancel_at_period_end, etc. — but no longer locks in the refresh cadence.What this gets us
update_on" tie-break goes away — the date lives on the source row, not on the source model.Costs + risks
Subscription.update_onvalues need to migrate to the new join table.org.subscription.update_onorsubscription.cancel-style logic needs review.