From 9c87a660d970a24c75d7e1f0fa4d8fba00767729 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 14 Nov 2026 20:25:16 +0530 Subject: [PATCH 1/2] feat(billing): plan limits, paid-through entitlement end and store-checked variants --- .changeset/billing-plan-limits.md | 10 ++ packages/billing/README.md | 26 +++- packages/billing/docs/architecture.md | 2 +- packages/billing/src/core/index.ts | 1 + .../billing/src/core/subscription.test.ts | 37 ++++- packages/billing/src/core/subscription.ts | 17 ++- .../src/providers/lemonsqueezy/index.test.ts | 48 ++++++- .../src/providers/lemonsqueezy/index.ts | 8 ++ .../src/providers/lemonsqueezy/normalize.ts | 6 +- .../billing/src/testing/plan-features.test.ts | 128 +++++++++++++++++ .../billing/src/testing/workflow-harness.ts | 8 ++ packages/billing/src/workflows/engine.ts | 129 ++++++++++++++++-- 12 files changed, 404 insertions(+), 16 deletions(-) create mode 100644 .changeset/billing-plan-limits.md diff --git a/.changeset/billing-plan-limits.md b/.changeset/billing-plan-limits.md new file mode 100644 index 0000000..0384087 --- /dev/null +++ b/.changeset/billing-plan-limits.md @@ -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. diff --git a/packages/billing/README.md b/packages/billing/README.md index 1f3e528..74b2724 100644 --- a/packages/billing/README.md +++ b/packages/billing/README.md @@ -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`) | @@ -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 @@ -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. diff --git a/packages/billing/docs/architecture.md b/packages/billing/docs/architecture.md index bc09307..dde82de 100644 --- a/packages/billing/docs/architecture.md +++ b/packages/billing/docs/architecture.md @@ -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. diff --git a/packages/billing/src/core/index.ts b/packages/billing/src/core/index.ts index 7664ed6..c8912d2 100644 --- a/packages/billing/src/core/index.ts +++ b/packages/billing/src/core/index.ts @@ -61,6 +61,7 @@ export { } from "./reconciliation-job.js"; export { type CanonicalSubscription, + DEFAULT_ENTITLEMENT_GRACE_MS, PAID_STATUSES, retainsPaidEntitlement, type SubscriptionSnapshot, diff --git a/packages/billing/src/core/subscription.test.ts b/packages/billing/src/core/subscription.test.ts index cbeedc5..1bad704 100644 --- a/packages/billing/src/core/subscription.test.ts +++ b/packages/billing/src/core/subscription.test.ts @@ -6,12 +6,45 @@ 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: past }, + { 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: null }, now), ).toBe(true); }); diff --git a/packages/billing/src/core/subscription.ts b/packages/billing/src/core/subscription.ts index 525e14c..8bc0cbb 100644 --- a/packages/billing/src/core/subscription.ts +++ b/packages/billing/src/core/subscription.ts @@ -69,12 +69,22 @@ export const PAID_STATUSES = new Set([ "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. @@ -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 && diff --git a/packages/billing/src/providers/lemonsqueezy/index.test.ts b/packages/billing/src/providers/lemonsqueezy/index.test.ts index a6b5a65..add0915 100644 --- a/packages/billing/src/providers/lemonsqueezy/index.test.ts +++ b/packages/billing/src/providers/lemonsqueezy/index.test.ts @@ -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"); @@ -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" } }, }, @@ -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); diff --git a/packages/billing/src/providers/lemonsqueezy/index.ts b/packages/billing/src/providers/lemonsqueezy/index.ts index 843a106..4c5d4f3 100644 --- a/packages/billing/src/providers/lemonsqueezy/index.ts +++ b/packages/billing/src/providers/lemonsqueezy/index.ts @@ -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), diff --git a/packages/billing/src/providers/lemonsqueezy/normalize.ts b/packages/billing/src/providers/lemonsqueezy/normalize.ts index b7b2781..701acbd 100644 --- a/packages/billing/src/providers/lemonsqueezy/normalize.ts +++ b/packages/billing/src/providers/lemonsqueezy/normalize.ts @@ -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"; @@ -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 diff --git a/packages/billing/src/testing/plan-features.test.ts b/packages/billing/src/testing/plan-features.test.ts index 1795b34..5393569 100644 --- a/packages/billing/src/testing/plan-features.test.ts +++ b/packages/billing/src/testing/plan-features.test.ts @@ -115,3 +115,131 @@ describe("hasFeature", () => { expect(REFERENCE_OFFERS.some((offer) => offer.plan === "gold")).toBe(false); }); }); + +const planLimits = { + pro: { marketing_emails_month: 10_000, storage_bytes: 20 }, + business: { marketing_emails_month: 50_000, storage_bytes: null }, +}; + +describe("getLimit and limits", () => { + it("answer the entitled plan's allowance, with null as unlimited", async () => { + const harness = createWorkflowHarness(undefined, { planLimits }); + const business = await subscribe(harness, "business_month", "ws_business"); + const pro = await subscribe(harness, "pro_month", "ws_pro"); + const { billing } = harness; + expect(await billing.getLimit(pro.id, "marketing_emails_month")).toBe(10_000); + expect(await billing.getLimit(pro.id, "storage_bytes")).toBe(20); + expect(await billing.getLimit(business.id, "storage_bytes")).toBeNull(); + expect(await billing.limits(business.id)).toEqual({ + marketing_emails_month: 50_000, + storage_bytes: null, + }); + }); + + it("answer 0 for an entity with no entitled plan", async () => { + const harness = createWorkflowHarness(undefined, { planLimits }); + expect(await harness.billing.getLimit("ws_free", "storage_bytes")).toBe(0); + expect(await harness.billing.limits("ws_free")).toEqual({ + marketing_emails_month: 0, + storage_bytes: 0, + }); + }); + + it("answer 0 once paid access has lapsed", async () => { + const harness = createWorkflowHarness(undefined, { planLimits }); + const pro = await subscribe(harness, "pro_month", "ws_pro"); + const subscription = harness.store.subscriptions[0]!; + subscription.cancelAtPeriodEnd = true; + subscription.paidThroughAt = new Date(harness.now.getTime() - 1); + expect(await harness.billing.getLimit(pro.id, "storage_bytes")).toBe(0); + }); + + it("throw on an undeclared key, so a typo never reads as unlimited", async () => { + const harness = createWorkflowHarness(undefined, { planLimits }); + const pro = await subscribe(harness, "pro_month", "ws_pro"); + await expect(harness.billing.getLimit(pro.id, "storage_byte")).rejects.toThrow( + /limit_key_unknown/, + ); + await expect(harness.billing.getLimit(pro.id, "constructor")).rejects.toThrow( + /limit_key_unknown/, + ); + }); + + it("throw when billing can't be read, instead of answering a number", async () => { + const harness = createWorkflowHarness(undefined, { planLimits }); + const pro = await subscribe(harness, "pro_month", "ws_pro"); + harness.store.findPlanState = () => { + throw new Error("db down"); + }; + await expect(harness.billing.getLimit(pro.id, "storage_bytes")).rejects.toThrow("db down"); + await expect(harness.billing.limits(pro.id)).rejects.toThrow("db down"); + }); + + it("are unlimited in oss mode, even when reads fail", async () => { + const store = new MemoryBillingStore(); + store.findPlanState = () => { + throw new Error("db down"); + }; + const billing = createBilling({ + database: store, + providers: [], + clock: frozenClock(new Date("2026-01-01T00:00:00.000Z")), + authorization: new MemoryAuthorizationPort(), + mode: "oss", + checkoutProvider: "", + requestedRevision: null, + requiredOfferKeys: [], + planLimits, + }); + expect(await billing.getLimit("any_entity", "storage_bytes")).toBeNull(); + expect(await billing.limits("any_entity")).toEqual({ + marketing_emails_month: null, + storage_bytes: null, + }); + }); + + it("reject plans that declare different keys", () => { + expect(() => + createWorkflowHarness(undefined, { + planLimits: { pro: { storage_bytes: 1 }, business: { storage_bytes: 2, seats: 3 } }, + }), + ).toThrow(/plan_limits_keys_differ/); + }); + + it("reject a cloud catalog plan that has no limits, or limits for a plan with no offer", () => { + expect(() => + createWorkflowHarness(undefined, { planLimits: { pro: { storage_bytes: 1 } } }), + ).toThrow(/plan_limits_plans_mismatch/); + expect(() => + createWorkflowHarness(undefined, { + planLimits: { ...planLimits, gold: { marketing_emails_month: 1, storage_bytes: 1 } }, + }), + ).toThrow(/plan_limits_plans_mismatch/); + }); + + it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])("reject the value %p", (value) => { + expect(() => + createWorkflowHarness(undefined, { + planLimits: { pro: { storage_bytes: value }, business: { storage_bytes: 1 } }, + }), + ).toThrow(/plan_limits_invalid/); + }); +}); + +describe("entitlementGraceMs", () => { + it("ends paid access that long after paidThroughAt", async () => { + const harness = createWorkflowHarness(undefined, { planFeatures, entitlementGraceMs: 0 }); + const business = await subscribe(harness, "business_month", "ws_business"); + const subscription = harness.store.subscriptions[0]!; + subscription.paidThroughAt = new Date(harness.now.getTime() - 1); + expect(await harness.billing.hasFeature(business.id, "learner_sso")).toBe(false); + }); + + it("rejects a negative or fractional grace", () => { + for (const entitlementGraceMs of [-1, 0.5]) { + expect(() => createWorkflowHarness(undefined, { entitlementGraceMs })).toThrow( + /entitlement_grace_invalid/, + ); + } + }); +}); diff --git a/packages/billing/src/testing/workflow-harness.ts b/packages/billing/src/testing/workflow-harness.ts index 569b15f..8b95109 100644 --- a/packages/billing/src/testing/workflow-harness.ts +++ b/packages/billing/src/testing/workflow-harness.ts @@ -17,6 +17,8 @@ export function createBillingFrom( authorization?: MemoryAuthorizationPort; audit?: MemoryAuditHook; planFeatures?: Record; + planLimits?: Record>>; + entitlementGraceMs?: number; } = {}, ) { return createBilling({ @@ -31,6 +33,8 @@ export function createBillingFrom( requiredOfferKeys: [...COURSELIT_OFFER_KEYS], offers, planFeatures: extras.planFeatures, + planLimits: extras.planLimits, + entitlementGraceMs: extras.entitlementGraceMs, returnUrlValidator: (url) => url.startsWith("https://app.test/"), }); } @@ -41,6 +45,8 @@ export function createWorkflowHarness( seedLocalCatalog?: boolean; offers?: BillingOffer[]; planFeatures?: Record; + planLimits?: Record>>; + entitlementGraceMs?: number; } = {}, ) { const clock = frozenClock(now); @@ -71,6 +77,8 @@ export function createWorkflowHarness( requiredOfferKeys: [...COURSELIT_OFFER_KEYS], offers, planFeatures: options.planFeatures, + planLimits: options.planLimits, + entitlementGraceMs: options.entitlementGraceMs, returnUrlValidator: (url) => url.startsWith("https://app.test/"), }); return { diff --git a/packages/billing/src/workflows/engine.ts b/packages/billing/src/workflows/engine.ts index b228322..0f21494 100644 --- a/packages/billing/src/workflows/engine.ts +++ b/packages/billing/src/workflows/engine.ts @@ -22,7 +22,10 @@ import type { SubscriptionSnapshot, VerifiedWebhookEnvelope, } from "../core/subscription.js"; -import { retainsPaidEntitlement } from "../core/subscription.js"; +import { + DEFAULT_ENTITLEMENT_GRACE_MS, + retainsPaidEntitlement, +} from "../core/subscription.js"; import { decideCheckoutTransition, decideCustomerTransition, @@ -102,6 +105,20 @@ export type CreateBillingOptions = { * `hasFeature`; `oss` mode ignores it and grants every feature. */ planFeatures?: Record; + /** + * Numeric allowances each paid plan includes, e.g. + * `{ pro: { storage_bytes: 20e9 }, business: { storage_bytes: null } }` + * (`null` is unlimited). Every plan must declare the same keys, and in cloud + * mode every plan of `offers` must be listed. Read with `getLimit`/`limits`; + * `oss` mode answers unlimited. The package never counts usage: the + * application compares its own counts against these numbers. + */ + planLimits?: Record>>; + /** + * How long a paid subscription stays entitled past its `paidThroughAt` + * (default seven days). Bounds the damage of a lost provider webhook. + */ + entitlementGraceMs?: number; webhookRetrieveCurrent?: boolean; /** How long work may sit before `runReconciliationBatch` treats it as stuck. */ reconciliation?: { @@ -179,6 +196,14 @@ export function createBilling(options: CreateBillingOptions) { } } } + const entitlementGraceMs = options.entitlementGraceMs ?? DEFAULT_ENTITLEMENT_GRACE_MS; + if (!Number.isSafeInteger(entitlementGraceMs) || entitlementGraceMs < 0) { + throw new BillingCompositionError("entitlement_grace_invalid"); + } + const planLimits = validatePlanLimits(options); + const limitKeys = new Set( + Object.values(planLimits).flatMap((limits) => Object.keys(limits)), + ); const planFeatures = new Map>( Object.entries(options.planFeatures ?? {}).map(([plan, features]) => [ plan, @@ -770,7 +795,7 @@ export function createBilling(options: CreateBillingOptions) { }); const existingSub = await store.findEntitlementSubscription(input.entity.id); - if (existingSub && retainsPaidEntitlement(existingSub, clock.now())) { + if (existingSub && retainsPaidEntitlement(existingSub, clock.now(), entitlementGraceMs)) { throw new BillingWorkflowError("active_subscription_exists"); } if (existingSub) { @@ -789,7 +814,7 @@ export function createBilling(options: CreateBillingOptions) { const snapshot = await callProvider(() => adapter.retrieveSubscription(session.subscriptionId!), ); - if (retainsPaidEntitlement(snapshot, clock.now())) { + if (retainsPaidEntitlement(snapshot, clock.now(), entitlementGraceMs)) { await projectSnapshot(snapshot, { checkoutAttemptId: resumable.attemptId, }); @@ -1790,7 +1815,7 @@ export function createBilling(options: CreateBillingOptions) { ? pendingChange.targetOfferKey : (existing?.offerKey ?? attempt?.offerKey ?? price.offerKey); const now = clock.now(); - const paid = retainsPaidEntitlement(snapshot, now); + const paid = retainsPaidEntitlement(snapshot, now, entitlementGraceMs); const currentSource = await store.findEntitlementSubscription(billableEntityId); if (paid && currentSource && currentSource.id !== existing?.id) { throw new BillingWorkflowError("operation_quarantined"); @@ -2441,7 +2466,7 @@ export function createBilling(options: CreateBillingOptions) { if (subscriptions.some((sub) => !["cancelled", "expired"].includes(sub.status))) { blockers.push("nonterminal_subscription"); } - if (subscriptions.some((sub) => retainsPaidEntitlement(sub, now))) { + if (subscriptions.some((sub) => retainsPaidEntitlement(sub, now, entitlementGraceMs))) { blockers.push("future_paid_entitlement"); } if (await store.findLiveCheckout(entity.id)) { @@ -2467,7 +2492,7 @@ export function createBilling(options: CreateBillingOptions) { if (row.provider !== customer.provider) return false; return ( !["cancelled", "expired"].includes(row.status) || - retainsPaidEntitlement(row, now) + retainsPaidEntitlement(row, now, entitlementGraceMs) ); }) .map((row) => row.id), @@ -2525,7 +2550,7 @@ export function createBilling(options: CreateBillingOptions) { : undefined; // Re-check the clock: paid access may have lapsed since the last projection. const sub = - pointed?.isEntitlementSource && retainsPaidEntitlement(pointed, clock.now()) + pointed?.isEntitlementSource && retainsPaidEntitlement(pointed, clock.now(), entitlementGraceMs) ? pointed : undefined; return { @@ -2563,6 +2588,44 @@ export function createBilling(options: CreateBillingOptions) { } } + /** + * The allowance of `key` on the entity's entitled paid plan: a number, or + * `null` for unlimited. `oss` mode is always unlimited. With no entitled + * plan the allowance is 0. An undeclared key throws, so a typo can never read + * as unlimited. A billing read error throws too: callers refuse new usage + * and keep what already exists. + */ + async function getLimit( + entityId: string, + key: string, + readOptions: { transaction?: unknown } = {}, + ): Promise { + if (options.mode === "oss") return null; + if (!limitKeys.has(key)) throw new BillingCompositionError("limit_key_unknown"); + const { activePaidPlan } = await commercialState(entityId, readOptions); + if (!activePaidPlan) return 0; + const value = planLimits[activePaidPlan]?.[key]; + return value === undefined ? 0 : value; + } + + /** Every declared limit for the entity, for a usage screen. */ + async function limits( + entityId: string, + readOptions: { transaction?: unknown } = {}, + ): Promise> { + const result: Record = {}; + if (options.mode === "oss") { + for (const key of limitKeys) result[key] = null; + return result; + } + const { activePaidPlan } = await commercialState(entityId, readOptions); + for (const key of limitKeys) { + const value = activePaidPlan ? planLimits[activePaidPlan]?.[key] : undefined; + result[key] = value === undefined ? 0 : value; + } + return result; + } + /** Stops a subscription whose paid access has lapsed from granting entitlement. */ async function releaseLapsedEntitlement(subscriptionId: string, actorId: string) { await store.withTransaction(() => @@ -2577,7 +2640,7 @@ export function createBilling(options: CreateBillingOptions) { const subscription = await store.findSubscriptionById(subscriptionId); if ( !subscription?.isEntitlementSource || - retainsPaidEntitlement(subscription, clock.now()) + retainsPaidEntitlement(subscription, clock.now(), entitlementGraceMs) ) { return; } @@ -2891,6 +2954,8 @@ export function createBilling(options: CreateBillingOptions) { getPayerBillingResponsibilities, commercialState, hasFeature, + getLimit, + limits, publicCatalog, recordRequestedCatalog, verifyRequestedCatalog, @@ -2905,6 +2970,54 @@ export function createBilling(options: CreateBillingOptions) { export type BillingEngine = ReturnType; +/** + * Checks `planLimits`: plain objects of non-negative safe integers or `null`, + * the same keys on every plan, and in cloud mode exactly the plans of + * `offers`. Returns a prototype-free copy so keys like `constructor` can't + * resolve to anything. + */ +function validatePlanLimits( + options: Pick, +): Record>> { + const result: Record>> = + Object.create(null); + if (options.planLimits === undefined) return result; + const entries = Object.entries(options.planLimits); + let keys: string | undefined; + for (const [plan, values] of entries) { + if (typeof values !== "object" || values === null || Array.isArray(values)) { + throw new BillingCompositionError("plan_limits_invalid"); + } + const copy: Record = Object.create(null); + for (const [key, value] of Object.entries(values)) { + if ( + key === "" || + (value !== null && (!Number.isSafeInteger(value) || value < 0)) + ) { + throw new BillingCompositionError("plan_limits_invalid"); + } + copy[key] = value; + } + const signature = Object.keys(copy).sort().join("\u0000"); + if (keys !== undefined && keys !== signature) { + throw new BillingCompositionError("plan_limits_keys_differ"); + } + keys = signature; + result[plan] = copy; + } + if (options.mode === "cloud") { + const plans = new Set((options.offers ?? []).map((offer) => offer.plan)); + const declared = new Set(entries.map(([plan]) => plan)); + if ( + [...declared].some((plan) => !plans.has(plan)) || + [...plans].some((plan) => !declared.has(plan)) + ) { + throw new BillingCompositionError("plan_limits_plans_mismatch"); + } + } + return result; +} + /** * Provider idempotency key for a cancellation. It includes the subscription's * provider version so that cancelling again after a resume is a new request, From 4e93a838f32b29ec8952c80b5bc39e7c8e7f9fdc Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 14 Nov 2026 20:32:33 +0530 Subject: [PATCH 2/2] style(billing): format plan-limits changes --- .../billing/src/core/subscription.test.ts | 26 +++++++++++--- .../billing/src/testing/plan-features.test.ts | 36 +++++++++++++------ packages/billing/src/workflows/engine.ts | 17 ++++++--- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/packages/billing/src/core/subscription.test.ts b/packages/billing/src/core/subscription.test.ts index 1bad704..a058f10 100644 --- a/packages/billing/src/core/subscription.test.ts +++ b/packages/billing/src/core/subscription.test.ts @@ -9,12 +9,18 @@ describe("retainsPaidEntitlement", () => { 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), + 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), + retainsPaidEntitlement( + { status, cancelAtPeriodEnd: false, paidThroughAt: sixDaysAgo }, + now, + ), ).toBe(true); } }); @@ -23,7 +29,10 @@ describe("retainsPaidEntitlement", () => { // 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), + retainsPaidEntitlement( + { status, cancelAtPeriodEnd: false, paidThroughAt: past }, + now, + ), ).toBe(false); } const eightDaysAgo = new Date(now.getTime() - 8 * 86_400_000); @@ -37,14 +46,21 @@ describe("retainsPaidEntitlement", () => { 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 }; + 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: null }, now), + retainsPaidEntitlement( + { status: "active", cancelAtPeriodEnd: false, paidThroughAt: null }, + now, + ), ).toBe(true); }); diff --git a/packages/billing/src/testing/plan-features.test.ts b/packages/billing/src/testing/plan-features.test.ts index 5393569..f3bfe91 100644 --- a/packages/billing/src/testing/plan-features.test.ts +++ b/packages/billing/src/testing/plan-features.test.ts @@ -171,7 +171,9 @@ describe("getLimit and limits", () => { harness.store.findPlanState = () => { throw new Error("db down"); }; - await expect(harness.billing.getLimit(pro.id, "storage_bytes")).rejects.toThrow("db down"); + await expect(harness.billing.getLimit(pro.id, "storage_bytes")).rejects.toThrow( + "db down", + ); await expect(harness.billing.limits(pro.id)).rejects.toThrow("db down"); }); @@ -201,7 +203,10 @@ describe("getLimit and limits", () => { it("reject plans that declare different keys", () => { expect(() => createWorkflowHarness(undefined, { - planLimits: { pro: { storage_bytes: 1 }, business: { storage_bytes: 2, seats: 3 } }, + planLimits: { + pro: { storage_bytes: 1 }, + business: { storage_bytes: 2, seats: 3 }, + }, }), ).toThrow(/plan_limits_keys_differ/); }); @@ -212,23 +217,32 @@ describe("getLimit and limits", () => { ).toThrow(/plan_limits_plans_mismatch/); expect(() => createWorkflowHarness(undefined, { - planLimits: { ...planLimits, gold: { marketing_emails_month: 1, storage_bytes: 1 } }, + planLimits: { + ...planLimits, + gold: { marketing_emails_month: 1, storage_bytes: 1 }, + }, }), ).toThrow(/plan_limits_plans_mismatch/); }); - it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])("reject the value %p", (value) => { - expect(() => - createWorkflowHarness(undefined, { - planLimits: { pro: { storage_bytes: value }, business: { storage_bytes: 1 } }, - }), - ).toThrow(/plan_limits_invalid/); - }); + it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "reject the value %p", + (value) => { + expect(() => + createWorkflowHarness(undefined, { + planLimits: { pro: { storage_bytes: value }, business: { storage_bytes: 1 } }, + }), + ).toThrow(/plan_limits_invalid/); + }, + ); }); describe("entitlementGraceMs", () => { it("ends paid access that long after paidThroughAt", async () => { - const harness = createWorkflowHarness(undefined, { planFeatures, entitlementGraceMs: 0 }); + const harness = createWorkflowHarness(undefined, { + planFeatures, + entitlementGraceMs: 0, + }); const business = await subscribe(harness, "business_month", "ws_business"); const subscription = harness.store.subscriptions[0]!; subscription.paidThroughAt = new Date(harness.now.getTime() - 1); diff --git a/packages/billing/src/workflows/engine.ts b/packages/billing/src/workflows/engine.ts index 0f21494..2d1ae34 100644 --- a/packages/billing/src/workflows/engine.ts +++ b/packages/billing/src/workflows/engine.ts @@ -795,7 +795,10 @@ export function createBilling(options: CreateBillingOptions) { }); const existingSub = await store.findEntitlementSubscription(input.entity.id); - if (existingSub && retainsPaidEntitlement(existingSub, clock.now(), entitlementGraceMs)) { + if ( + existingSub && + retainsPaidEntitlement(existingSub, clock.now(), entitlementGraceMs) + ) { throw new BillingWorkflowError("active_subscription_exists"); } if (existingSub) { @@ -2466,7 +2469,9 @@ export function createBilling(options: CreateBillingOptions) { if (subscriptions.some((sub) => !["cancelled", "expired"].includes(sub.status))) { blockers.push("nonterminal_subscription"); } - if (subscriptions.some((sub) => retainsPaidEntitlement(sub, now, entitlementGraceMs))) { + if ( + subscriptions.some((sub) => retainsPaidEntitlement(sub, now, entitlementGraceMs)) + ) { blockers.push("future_paid_entitlement"); } if (await store.findLiveCheckout(entity.id)) { @@ -2550,7 +2555,8 @@ export function createBilling(options: CreateBillingOptions) { : undefined; // Re-check the clock: paid access may have lapsed since the last projection. const sub = - pointed?.isEntitlementSource && retainsPaidEntitlement(pointed, clock.now(), entitlementGraceMs) + pointed?.isEntitlementSource && + retainsPaidEntitlement(pointed, clock.now(), entitlementGraceMs) ? pointed : undefined; return { @@ -2979,8 +2985,9 @@ export type BillingEngine = ReturnType; function validatePlanLimits( options: Pick, ): Record>> { - const result: Record>> = - Object.create(null); + const result: Record>> = Object.create( + null, + ); if (options.planLimits === undefined) return result; const entries = Object.entries(options.planLimits); let keys: string | undefined;