Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .changeset/billing-plan-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@codelitdev/billing": minor
---

Add plan limits, end every paid status at its paid-through date, and check that Lemon Squeezy variants belong to the configured store.

- `planLimits` at `createBilling`, read with `getLimit()` and `limits()`. Every plan declares the same keys; `null` is unlimited; an undeclared key throws; `oss` mode is unlimited; a read error throws.
- `active`, `trialing` and `past_due` now stop granting their plan once `paidThroughAt` plus `entitlementGraceMs` (default seven days) has passed, so a lost provider webhook can't keep an entity entitled for good.
- Lemon Squeezy `paused` subscriptions no longer grant a plan (they map to `cancelled` and resume to `active`).
- Lemon Squeezy `retrieveProduct` rejects a variant from another store on the same account (`provider_product_wrong_store`). Checkout would otherwise sell in that store and its webhooks would be ignored.
26 changes: 25 additions & 1 deletion packages/billing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ The engine is mechanical. It will not infer product policy from plan names.
| Trial eligibility | `trialDays` on checkout (0 if ineligible). Claims, HMAC, school trials stay in your tables |
| Upgrade vs downgrade | `startPlanChange({ effectiveAt, prorationMode })`. The package does not rank Pro/Business |
| What a plan unlocks | `planFeatures` at `createBilling`, read with `hasFeature()`; anything finer is your entitlements resolver |
| What a plan allows | `planLimits` at `createBilling`, read with `getLimit()` / `limits()`; you count usage |
| Entity lifecycle | `getBillableEntityBillingBlockers` / `getPayerBillingResponsibilities`; you choose the user-facing error |
| Product side effects | `afterProjection` on the **engine transaction** (`store.getTransaction() ?? db`) |

Expand Down Expand Up @@ -353,7 +354,7 @@ await db.transaction(async (tx) => {
});
```

`commercialState` is the source of truth for paid access. It re-checks the clock, so a scheduled cancellation stops counting as paid once `paidThroughAt` passes, even before the provider's final event or `runDeadlineBatch`. Decide what a plan unlocks from it; do not copy paid status into product tables and re-derive it.
`commercialState` is the source of truth for paid access. It re-checks the clock, so a scheduled cancellation stops counting as paid once `paidThroughAt` passes, even before the provider's final event or `runDeadlineBatch`. Every other paid status (`active`, `trialing`, `past_due`) stops counting once `paidThroughAt` plus `entitlementGraceMs` (default seven days) has passed, so a lost renewal, failure or cancellation webhook can't leave an entity entitled for good. A paid status with no `paidThroughAt` stays entitled. Decide what a plan unlocks from it; do not copy paid status into product tables and re-derive it.

### Features on plans

Expand All @@ -375,6 +376,29 @@ if (await billing.hasFeature(schoolId, "learner_sso")) {
- `oss` mode: always true. `planFeatures` is ignored.
- Any error reading state returns false (fail closed). Like `commercialState`, it takes `{ transaction }` to read inside an open transaction.

### Limits on plans

Declare the numeric allowances each paid plan includes with `planLimits`, then read them with `getLimit` or `limits`:

```ts
const billing = createBilling({
// ...
planLimits: {
pro: { marketing_emails_month: 10_000, storage_bytes: 20 * GB },
business: { marketing_emails_month: 50_000, storage_bytes: null }, // null: unlimited
},
});

const allowed = await billing.getLimit(schoolId, "storage_bytes"); // number | null
const all = await billing.limits(schoolId); // every key, for a usage screen
```

- Every plan must declare the same keys (`plan_limits_keys_differ`). In `cloud` mode the plans must be exactly the plans of `offers` (`plan_limits_plans_mismatch`). Values are non-negative safe integers or `null` (`plan_limits_invalid`).
- `cloud` mode: the allowance of `commercialState(...).activePaidPlan`; with no entitled plan, `0`. An undeclared key throws `limit_key_unknown`, so a typo never reads as unlimited.
- `oss` mode: every key is `null` (unlimited).
- Unlike `hasFeature`, a read error **throws**. Refuse new usage on an error; never take away what already exists.
- The package never counts usage. Count in your own tables and compare.

Compose those with your trial, Free plan, quotas, and `canManageBilling`. `projectionVersion` is a cache key; equivalent webhook replays update freshness only and do not increment it.

Before close / delete / owner demotion, call `getBillableEntityBillingBlockers` / `getPayerBillingResponsibilities` in the same transaction as the mutation. The package reports blockers; you decide the user-facing error.
Expand Down
2 changes: 1 addition & 1 deletion packages/billing/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The package does not provide:

- Product routes, ts-rest contracts, MCP tools, or UI.
- Authentication, membership, owner-role policy, CSRF, or recent-auth token issuance.
- Plan names, feature policy, quota dimensions, trial/grace duration, or availability.
- Plan names, feature policy, quota dimensions, trial/grace duration, or availability. (The application may pass feature keys and limit numbers per plan, `planFeatures` and `planLimits`; the package resolves them against the entitled plan but never counts usage.)
- Product-specific schema extensions, entitlement-adjustment tables, migrations, backfills, retention, or deletion policy.
- Automatic DDL during package installation, import, API startup, or worker startup.
- A shared billing database or hosted billing service.
Expand Down
1 change: 1 addition & 0 deletions packages/billing/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export {
} from "./reconciliation-job.js";
export {
type CanonicalSubscription,
DEFAULT_ENTITLEMENT_GRACE_MS,
PAID_STATUSES,
retainsPaidEntitlement,
type SubscriptionSnapshot,
Expand Down
53 changes: 51 additions & 2 deletions packages/billing/src/core/subscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,59 @@ const future = new Date("2026-07-01T00:00:00.000Z");
const past = new Date("2026-05-01T00:00:00.000Z");

describe("retainsPaidEntitlement", () => {
it("keeps paid statuses", () => {
it("keeps paid statuses inside the paid period and the grace after it", () => {
for (const status of ["active", "trialing", "past_due"] as const) {
expect(
retainsPaidEntitlement(
{ status, cancelAtPeriodEnd: false, paidThroughAt: future },
now,
),
).toBe(true);
// Six days past the paid-through date: still inside the default seven-day grace.
const sixDaysAgo = new Date(now.getTime() - 6 * 86_400_000);
expect(
retainsPaidEntitlement(
{ status, cancelAtPeriodEnd: false, paidThroughAt: sixDaysAgo },
now,
),
).toBe(true);
}
});

it("ends every paid status once the grace after paidThroughAt has passed", () => {
// A lost renewal, failure or cancellation webhook can't keep access forever.
for (const status of ["active", "trialing", "past_due"] as const) {
expect(
retainsPaidEntitlement(
{ status, cancelAtPeriodEnd: false, paidThroughAt: past },
now,
),
).toBe(false);
}
const eightDaysAgo = new Date(now.getTime() - 8 * 86_400_000);
expect(
retainsPaidEntitlement(
{ status: "active", cancelAtPeriodEnd: false, paidThroughAt: eightDaysAgo },
now,
),
).toBe(false);
});

it("uses the grace it is given", () => {
const anHourAgo = new Date(now.getTime() - 3_600_000);
const paid = {
status: "past_due" as const,
cancelAtPeriodEnd: false,
paidThroughAt: anHourAgo,
};
expect(retainsPaidEntitlement(paid, now, 0)).toBe(false);
expect(retainsPaidEntitlement(paid, now, 2 * 3_600_000)).toBe(true);
});

it("keeps a paid status the provider gave no paid-through date", () => {
expect(
retainsPaidEntitlement(
{ status: "active", cancelAtPeriodEnd: false, paidThroughAt: past },
{ status: "active", cancelAtPeriodEnd: false, paidThroughAt: null },
now,
),
).toBe(true);
Expand Down
17 changes: 16 additions & 1 deletion packages/billing/src/core/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,22 @@ export const PAID_STATUSES = new Set<CanonicalSubscriptionStatus>([
"past_due",
]);

/** How long a paid status keeps entitlement past `paidThroughAt` by default. */
export const DEFAULT_ENTITLEMENT_GRACE_MS = 7 * 24 * 60 * 60 * 1000;

/**
* Whether a subscription still grants its paid plan. Every paid status ends at
* `paidThroughAt` plus `graceMs`, so a lost renewal, failure or cancellation
* webhook can't leave an entity entitled for good. A paid status with no
* `paidThroughAt` stays entitled: the provider gave no date to end it at.
*/
export function retainsPaidEntitlement(
snapshot: Pick<
SubscriptionSnapshot,
"status" | "cancelAtPeriodEnd" | "paidThroughAt"
>,
now: Date,
graceMs: number = DEFAULT_ENTITLEMENT_GRACE_MS,
): boolean {
// A cancellation scheduled for the period end stops paid access once the
// period has passed, even if the provider's final event has not arrived.
Expand All @@ -85,7 +95,12 @@ export function retainsPaidEntitlement(
) {
return false;
}
if (PAID_STATUSES.has(snapshot.status)) return true;
if (PAID_STATUSES.has(snapshot.status)) {
return (
!snapshot.paidThroughAt ||
snapshot.paidThroughAt.getTime() + graceMs > now.getTime()
);
}
return (
snapshot.status === "cancelled" &&
snapshot.cancelAtPeriodEnd &&
Expand Down
48 changes: 47 additions & 1 deletion packages/billing/src/providers/lemonsqueezy/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ describe("lemon squeezy adapter", () => {
expect(mapLemonSqueezyStatus("on_trial")).toBe("trialing");
expect(mapLemonSqueezyStatus("active")).toBe("active");
expect(mapLemonSqueezyStatus("past_due")).toBe("past_due");
expect(mapLemonSqueezyStatus("paused")).toBe("past_due");
// Paused collects no payment, so it grants no plan.
expect(mapLemonSqueezyStatus("paused")).toBe("cancelled");
expect(mapLemonSqueezyStatus("cancelled")).toBe("cancelled");
expect(mapLemonSqueezyStatus("unpaid")).toBe("cancelled");
expect(mapLemonSqueezyStatus("expired")).toBe("expired");
Expand Down Expand Up @@ -182,9 +183,13 @@ describe("lemon squeezy adapter", () => {
interval: "month",
interval_count: 1,
price: 1000,
product_id: 5,
},
},
},
"GET /products/5": {
data: { id: "5", type: "products", attributes: { store_id: 123 } },
},
"GET /stores/123": {
data: { id: "123", type: "stores", attributes: { currency: "usd" } },
},
Expand Down Expand Up @@ -245,6 +250,47 @@ describe("lemon squeezy adapter", () => {
expect(other.foreign).toBe(true);
});

it("rejects a variant from another store on the same account", async () => {
// Checkout would sell in that store, and its webhooks would be ignored here.
const attempt = adapter({
"GET /variants/78": {
data: {
id: "78",
type: "variants",
attributes: {
is_subscription: true,
interval: "month",
interval_count: 1,
price: 1000,
product_id: 6,
},
},
},
"GET /products/6": {
data: { id: "6", type: "products", attributes: { store_id: 999 } },
},
"GET /stores/123": {
data: { id: "123", type: "stores", attributes: { currency: "usd" } },
},
}).retrieveProduct("78");
await expect(attempt).rejects.toThrow("provider_product_wrong_store");
});

it("reads a paused subscription as granting nothing", async () => {
const snapshot = await adapter({
"GET /subscriptions/5": {
data: {
id: "5",
type: "subscriptions",
attributes: { ...subscriptionAttributes, status: "paused" },
},
},
}).retrieveSubscription("5");
expect(snapshot.status).toBe("cancelled");
expect(snapshot.paidThroughAt).toBeNull();
expect(snapshot.cancelAtPeriodEnd).toBe(false);
});

it("rejects a bad signature", async () => {
const request = signed({ meta: { event_name: "x" }, data: {} });
request.headers["x-signature"] = "0".repeat(64);
Expand Down
8 changes: 8 additions & 0 deletions packages/billing/src/providers/lemonsqueezy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,14 @@ export class LemonSqueezyBillingProvider implements BillingProviderAdapter {
if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
throw new BillingProviderError("invalid", "provider_product_amount_invalid");
}
// A variant id is global: one from another store on the same account
// would sell there, and its webhooks would be ignored here as foreign.
const product = await this.read(
`/products/${encodeURIComponent(String(attributes.product_id ?? ""))}`,
);
if (String(product.data.attributes.store_id) !== this.storeId) {
throw new BillingProviderError("invalid", "provider_product_wrong_store");
}
return {
provider: this.provider,
providerProductId: String(variant.data.id),
Expand Down
6 changes: 4 additions & 2 deletions packages/billing/src/providers/lemonsqueezy/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ export function mapLemonSqueezyStatus(status: string): CanonicalSubscriptionStat
return "trialing";
case "active":
return "active";
case "paused":
case "past_due":
return "past_due";
// A paused subscription collects no payment, so it grants no plan. Lemon
// Squeezy moves it back to `active` when it resumes.
case "paused":
case "unpaid":
case "cancelled":
return "cancelled";
Expand All @@ -43,7 +45,7 @@ export function normalizeLemonSqueezySubscription(
const endsAt = dateOrNull(attributes.ends_at);
const trialEndsAt = dateOrNull(attributes.trial_ends_at);
const paidThroughAt =
status === "unpaid"
status === "unpaid" || status === "paused"
? null
: scheduledEnd
? endsAt
Expand Down
Loading
Loading