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
9 changes: 9 additions & 0 deletions .changeset/billing-plan-features.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@codelitdev/billing": minor
---

`createBilling` takes `planFeatures`, the feature keys each paid plan includes (for example `{ business: ["learner_sso"] }`), and the engine gains `hasFeature(billableEntityId, feature)`. In `cloud` mode it is true only when the entity's entitled paid plan (the same rules as `commercialState().activePaidPlan`) includes the feature. In `oss` mode it is always true. An unknown feature, no paid plan, or any error reading state is false. In `cloud` mode a `planFeatures` key with no offer throws `plan_features_invalid`.

**Product changes for this release**

- Declare `planFeatures` and gate features with `billing.hasFeature(...)` instead of keeping a separate plan-to-feature map. Plans do not inherit, so list a feature on every plan that includes it.
10 changes: 10 additions & 0 deletions .changeset/domain-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@codelitdev/platform": minor
---

Adds custom-domain verification to the root export: `normalizeHostname`, `isApexHostname`, `issueOwnershipToken`, `createDnsResolver` and `createDomainVerification` (`ownershipRecord`, `verifyOwnership`, `verifyRouting`). It keeps the behaviour FrontLit and CourseLit each carry today: the TXT value is the bare token, tokens are 16 hex bytes, CNAME chains are followed with loop protection, and an apex may use CNAME flattening or A records that match a configured set. Lookups go through an injectable resolver that can pin trusted DNS servers and times out after 5 seconds by default. The package now depends on `tldts` for public-suffix handling.

**Product changes for this release**

- FrontLit (`domains/queries.ts`) and CourseLit (`school-hosts.ts`, `school-host.ts`) can replace their copies with this. Apex/`www` pairing and one-domain-per-school stay in the apps.
- `normalizeHostname` replaces CourseLit's `normalizeCustomHostname`. It also rejects public suffixes and IP literals by default, and `isApexHostname` now uses the public suffix list rather than counting labels.
25 changes: 16 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 21 additions & 1 deletion packages/billing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The engine is mechanical. It will not infer product policy from plan names.
| Paid offers | `offers` + `requiredOfferKeys` at `createBilling`; exact set, no `"free"` in `planIds` |
| 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 | Your entitlements resolver. `commercialState()` is paid snapshot only |
| What a plan unlocks | `planFeatures` at `createBilling`, read with `hasFeature()`; anything finer is your entitlements resolver |
| 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 @@ -355,6 +355,26 @@ 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.

### Features on plans

Declare which feature keys each paid plan includes with `planFeatures`, then gate with `hasFeature`:

```ts
const billing = createBilling({
// ...
planFeatures: { business: ["learner_sso"] },
});

if (await billing.hasFeature(schoolId, "learner_sso")) {
// the school's entitled paid plan includes the feature
}
```

- Keys are plan ids from `offers`; in `cloud` mode a key with no offer throws `plan_features_invalid` at composition. Plans do not inherit: list a feature on every plan that includes it.
- `cloud` mode: true only when `commercialState(...).activePaidPlan` includes the feature, so grace and cancellation rules are exactly those of `activePaidPlan`. No paid plan, or an unknown feature, is false.
- `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.

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
117 changes: 117 additions & 0 deletions packages/billing/src/testing/plan-features.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from "bun:test";
import { frozenClock } from "../core/clock.js";
import { MemoryBillingStore } from "../persistence/memory.js";
import { MemoryAuthorizationPort } from "../ports/authorization.js";
import { createBilling } from "../workflows/engine.js";
import { entity, grant, payer, REFERENCE_OFFERS } from "./fixtures.js";
import { createWorkflowHarness } from "./workflow-harness.js";

const planFeatures = { business: ["learner_sso"], pro: ["custom_domain"] };

async function subscribe(
harness: ReturnType<typeof createWorkflowHarness>,
offerKey: string,
entityId: string,
) {
const { billing, fake, authorization, now } = harness;
const workspace = entity(entityId);
const actor = payer();
const token = grant("checkout", workspace.id, actor.id, now);
authorization.issue(token);
const checkout = await billing.startCheckout({
grant: token,
entity: workspace,
payer: actor,
offerKey,
catalogRevision: 1,
returnUrl: "https://app.test/billing",
});
const snapshot = await fake.simulatePayment(
checkout.attempt.providerCheckoutSessionId!,
);
await billing.projectSnapshot(snapshot, {
checkoutAttemptId: checkout.attempt.attemptId,
});
return workspace;
}

describe("hasFeature", () => {
it("is true only for features of the entitled paid plan", async () => {
const harness = createWorkflowHarness(undefined, { planFeatures });
const business = await subscribe(harness, "business_month", "ws_business");
const pro = await subscribe(harness, "pro_month", "ws_pro");
const { billing } = harness;
expect(await billing.hasFeature(business.id, "learner_sso")).toBe(true);
expect(await billing.hasFeature(business.id, "custom_domain")).toBe(false);
expect(await billing.hasFeature(pro.id, "learner_sso")).toBe(false);
expect(await billing.hasFeature(pro.id, "custom_domain")).toBe(true);
});

it("is false for unknown features, free entities and prototype keys", async () => {
const harness = createWorkflowHarness(undefined, { planFeatures });
const business = await subscribe(harness, "business_month", "ws_business");
const { billing } = harness;
expect(await billing.hasFeature(business.id, "nope")).toBe(false);
expect(await billing.hasFeature(business.id, "constructor")).toBe(false);
expect(await billing.hasFeature("ws_free", "learner_sso")).toBe(false);
});

it("is false when no planFeatures are declared", async () => {
const harness = createWorkflowHarness();
const business = await subscribe(harness, "business_month", "ws_business");
expect(await harness.billing.hasFeature(business.id, "learner_sso")).toBe(false);
});

it("follows the same entitlement semantics as activePaidPlan", async () => {
const harness = createWorkflowHarness(undefined, { planFeatures });
const business = await subscribe(harness, "business_month", "ws_business");
const { billing, store } = harness;
const subscription = store.subscriptions[0]!;
// Cancelled at period end and the period has passed: no entitlement.
subscription.cancelAtPeriodEnd = true;
subscription.paidThroughAt = new Date(harness.now.getTime() - 1);
expect((await billing.commercialState(business.id)).activePaidPlan).toBeNull();
expect(await billing.hasFeature(business.id, "learner_sso")).toBe(false);
// Cancelled at period end but still inside the paid period: entitled.
subscription.paidThroughAt = new Date(harness.now.getTime() + 86_400_000);
expect((await billing.commercialState(business.id)).activePaidPlan).toBe(
"business",
);
expect(await billing.hasFeature(business.id, "learner_sso")).toBe(true);
});

it("fails closed when state cannot be read", async () => {
const harness = createWorkflowHarness(undefined, { planFeatures });
const business = await subscribe(harness, "business_month", "ws_business");
harness.store.findPlanState = () => {
throw new Error("db down");
};
expect(await harness.billing.hasFeature(business.id, "learner_sso")).toBe(false);
});

it("is true for every feature 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: [],
});
expect(await billing.hasFeature("any_entity", "learner_sso")).toBe(true);
expect(await billing.hasFeature("any_entity", "anything_else")).toBe(true);
});

it("rejects planFeatures naming a plan that has no offer", () => {
expect(() =>
createWorkflowHarness(undefined, { planFeatures: { gold: ["x"] } }),
).toThrow(/plan_features_invalid/);
expect(REFERENCE_OFFERS.some((offer) => offer.plan === "gold")).toBe(false);
});
});
4 changes: 4 additions & 0 deletions packages/billing/src/testing/workflow-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function createBillingFrom(
extras: {
authorization?: MemoryAuthorizationPort;
audit?: MemoryAuditHook;
planFeatures?: Record<string, readonly string[]>;
} = {},
) {
return createBilling({
Expand All @@ -29,6 +30,7 @@ export function createBillingFrom(
requestedRevision: offers[0]!.revision,
requiredOfferKeys: [...COURSELIT_OFFER_KEYS],
offers,
planFeatures: extras.planFeatures,
returnUrlValidator: (url) => url.startsWith("https://app.test/"),
});
}
Expand All @@ -38,6 +40,7 @@ export function createWorkflowHarness(
options: {
seedLocalCatalog?: boolean;
offers?: BillingOffer[];
planFeatures?: Record<string, readonly string[]>;
} = {},
) {
const clock = frozenClock(now);
Expand Down Expand Up @@ -67,6 +70,7 @@ export function createWorkflowHarness(
requestedRevision: offers[0]!.revision,
requiredOfferKeys: [...COURSELIT_OFFER_KEYS],
offers,
planFeatures: options.planFeatures,
returnUrlValidator: (url) => url.startsWith("https://app.test/"),
});
return {
Expand Down
45 changes: 45 additions & 0 deletions packages/billing/src/workflows/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export type CreateBillingOptions = {
requestedRevision: number | null;
requiredOfferKeys: string[];
offers?: BillingOffer[];
/**
* Feature keys each paid plan includes, e.g. `{ business: ["learner_sso"] }`.
* Cloud mode requires every key to be a plan of `offers`. Read with
* `hasFeature`; `oss` mode ignores it and grants every feature.
*/
planFeatures?: Record<string, readonly string[]>;
webhookRetrieveCurrent?: boolean;
/** How long work may sit before `runReconciliationBatch` treats it as stuck. */
reconciliation?: {
Expand Down Expand Up @@ -161,6 +167,24 @@ export function createBilling(options: CreateBillingOptions) {
) {
throw new BillingCompositionError("cloud_billing_configuration_incomplete");
}
if (options.planFeatures !== undefined) {
const plans = new Set((options.offers ?? []).map((offer) => offer.plan));
for (const [plan, features] of Object.entries(options.planFeatures)) {
if (
!Array.isArray(features) ||
features.some((feature) => typeof feature !== "string" || feature === "") ||
(options.mode === "cloud" && !plans.has(plan))
) {
throw new BillingCompositionError("plan_features_invalid");
}
}
}
const planFeatures = new Map<string, ReadonlySet<string>>(
Object.entries(options.planFeatures ?? {}).map(([plan, features]) => [
plan,
new Set(features),
]),
);
const requestedOffers =
options.mode === "cloud"
? validateCatalog({
Expand Down Expand Up @@ -2519,6 +2543,26 @@ export function createBilling(options: CreateBillingOptions) {
};
}

/**
* Whether the entity's currently entitled paid plan includes `feature`.
* Uses the same entitlement rules as `commercialState().activePaidPlan`.
* `oss` mode grants every feature; any read error answers false.
*/
async function hasFeature(
entityId: string,
feature: string,
readOptions: { transaction?: unknown } = {},
): Promise<boolean> {
if (options.mode === "oss") return true;
try {
const { activePaidPlan } = await commercialState(entityId, readOptions);
if (!activePaidPlan) return false;
return planFeatures.get(activePaidPlan)?.has(feature) ?? false;
} catch {
return false;
}
}

/** Stops a subscription whose paid access has lapsed from granting entitlement. */
async function releaseLapsedEntitlement(subscriptionId: string, actorId: string) {
await store.withTransaction(() =>
Expand Down Expand Up @@ -2846,6 +2890,7 @@ export function createBilling(options: CreateBillingOptions) {
getBillableEntityBillingBlockers,
getPayerBillingResponsibilities,
commercialState,
hasFeature,
publicCatalog,
recordRequestedCatalog,
verifyRequestedCatalog,
Expand Down
Loading
Loading