Skip to content

feat(billing): plan limits, paid-through entitlement end and store-checked variants - #39

Merged
clbotdev merged 2 commits into
mainfrom
feat/billing-plan-limits
Nov 14, 2026
Merged

clbotdev merged 2 commits into
mainfrom
feat/billing-plan-limits

Conversation

@clbotdev

Copy link
Copy Markdown
Contributor

Why

CourseLit's plan tiers (Pro and Business) need per-plan allowances, and a security review of the plan found that the billing package grants paid access for longer than it should. A local smoke test with Lemon Squeezy also found that a variant from another store on the same account passes the catalog check, so checkout sells in that store and its webhooks are then ignored as foreign.

What

  • Plan limits. planLimits at createBilling, read with getLimit() and limits().
    • Every plan declares the same keys; in cloud mode the plans are exactly those of offers.
    • Values are non-negative safe integers or null (unlimited).
    • With no entitled plan the allowance is 0. An undeclared key throws limit_key_unknown, so a typo never reads as unlimited.
    • oss mode is unlimited. A billing read error throws (unlike hasFeature, which fails closed to false).
    • The package still never counts usage.
  • Paid access ends at paidThroughAt plus a grace period. active, trialing and past_due stop granting their plan once paidThroughAt plus entitlementGraceMs (default seven days) has passed. Before this, a lost renewal, failure or cancellation webhook left an entity entitled for good. A paid status with no paidThroughAt keeps today's behaviour.
  • Lemon Squeezy paused grants nothing. It maps to cancelled with no paid-through date; un-pausing moves it back to active, which the transitions already allow.
  • Lemon Squeezy variants must belong to the configured store. retrieveProduct reads the variant's product and rejects one from another store (provider_product_wrong_store).

README, architecture doc and a changeset (minor) are updated.

Behaviour change for existing consumers

SendLit, MediaLit and CourseLit get the stricter entitlement end on upgrade: a subscription whose paidThroughAt is more than seven days in the past stops counting as paid even if the provider still reports it as active. Normal renewals move paidThroughAt forward, so this only bites when webhooks were missed.

Test plan

  • packages/billing: 161 tests pass, including new ones for limits (allowance, unlimited, no plan, lapsed plan, unknown key, read error, oss, key and plan validation), the grace window, paused subscriptions and the wrong-store variant.
  • Whole repo: typecheck and every workspace's tests pass.
  • biome lint passes (pre-commit hook).

@clbotdev
clbotdev merged commit e974388 into main Nov 14, 2026
1 check passed
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