Plans
The catalogue of plans: grids, tiers and their entitlement preset, prices per billing interval, and the services that read and change it on both sides of the payment provider.
The plans module models the catalogue. A PlanGrid groups the plans sold to one audience (teachers, learners) with its own order. A Plan is a tier of a grid: its identity (planKey), its labels, its position (rank) and the entitlement preset a subscription to it grants. A PlanPrice is how a plan is paid for: one row per billing interval the tier is sold on. A plan says what the subscriber gets; a price says how it is paid for. What a subscription to a plan produces is covered in Subscriptions and Entitlements.
Consumers: the admin (the whole catalogue, archived rows included, and every write), the API (the on-sale listing of a grid, the checkout), the subscription reconciliation (resolving a provider price id to the plan whose preset it buys) and the payment adapter.
| Layer | Import path |
|---|---|
| Domain | @workspace/training/domain/plans |
| Application | @workspace/training/application/plans |
The services take no ServiceContext: the catalogue is global, not owned by a subject.
Domain
PlanGrid
A catalogue of plans sold to one audience. A plan's rank is unique inside its grid only. A subject may hold one subscription per grid, and their entitlements add up. A subscription reaches its grid through its price and the plan that price sells.
Prop
Type
PlanGridPatch is Partial<Pick<PlanGrid, "providerPortalConfigurationId">>: the key is the identity the apps ask for, only the grid's settings are ever patched.
Plan
Prop
Type
PlanPreset is the sparse preset, only the keys a plan sells: { limits: GrantLimits; features: ReadonlySet<FeatureKey> }. It is structurally the shape a grant contributes, so resolution reuses the merge rules of the entitlements module. Unlimited is Number.POSITIVE_INFINITY.
NewPlan picks planKey, gridId, labels, rank, preset, providerProductId, status, purchasable. PlanPatch is Partial<Omit<NewPlan, "planKey" | "gridId">>: the key is the identity and the grid is fixed, neither is ever patched.
| Export | Definition |
|---|---|
PLAN_KEY_PATTERN | /^[a-z0-9]+(?:-[a-z0-9]+)*$/: lowercase alphanumeric words joined by single hyphens, e.g. pro or sur-devis |
PLAN_STATUSES | ["active", "archived"] (PlanStatus). archived leaves the catalogue but keeps serving its subscribers |
PLAN_CURRENCIES | ["EUR"] (PlanCurrency) |
isPlanKeyFormat(value: string) | Boolean, tests PLAN_KEY_PATTERN |
isPlanStatus(value: string) | Type guard to PlanStatus |
isPlanCurrency(value: string) | Type guard to PlanCurrency |
isPlanOnSale(plan: Pick<Plan, "status">) | status === "active" |
PlanPrice
How a plan is paid for: the billing interval, the displayed TTC amount in integer cents, an optional trial, and the provider's price id. One plan has one price row per interval it is sold on, which is how the same tier is sold monthly and yearly without duplicating the row or its preset. A price is immutable once subscribers hold it: changing what a plan costs archives the row and adds another, so a subscriber keeps the price it bought (grandfathering) and the history stays readable.
Prop
Type
NewPlanPrice picks planId, billingInterval, intervalCount, priceCents, currency, providerPriceId, trialDays, status. PlanPricePatch is Partial<Pick<NewPlanPrice, "providerPriceId" | "status">>: what a price costs never changes.
| Export | Definition |
|---|---|
BILLING_INTERVALS | ["month", "year"] (BillingInterval); intervalCount multiplies it |
isBillingInterval(value: string) | Type guard to BillingInterval |
isPlanPriceOnSale(price: Pick<PlanPrice, "status">) | status === "active" |
monthlyAmountCents(price) | What one charge is worth per month, rounded to the cent: the MRR unit. Takes priceCents, intervalCount, billingInterval |
PlanWithPrices | Plan & { prices: PlanPrice[] }: a catalogue entry, the tier and the prices it is currently sold at |
PlanPriceWithPlan | { price: PlanPrice; plan: Plan }: what a provider price id resolves to |
isPlanKeyFormat("pro"); // => true
isPlanKeyFormat("sur-devis"); // => true
isPlanKeyFormat("Pro Plus"); // => false: lowercase slug only
isPlanOnSale({ status: "archived" }); // => false: listed nowhere, still serving its subscribers
isPlanPriceOnSale({ status: "active" }); // => true
monthlyAmountCents({ priceCents: 11_900, intervalCount: 1, billingInterval: "year" }); // => 992
monthlyAmountCents({ priceCents: 2700, intervalCount: 3, billingInterval: "month" }); // => 900Application
PlansService
createPlansService(deps: PlansServiceDeps): PlansService, with deps = { planGrids, planGridsRead, plans, plansRead, planPrices, planPricesRead }, the canonical pairs of the catalogue's three tables. Reads the catalogue and validates every write to it. Validation is hand-rolled so the domain stays schema-free; the storage layer re-validates the preset JSON at its own boundary.
| Method | Signature | Description |
|---|---|---|
listGrids | () => Promise<PlanGrid[]> | Every grid, oldest first |
getGridById | (id: string) => Promise<PlanGrid | null> | |
getGridByKey | (gridKey: string) => Promise<PlanGrid | null> | |
updateGrid | (id: string, patch: PlanGridPatch) => Promise<PlanGrid> | Sets the grid's provider portal configuration, trimmed; blank clears it to the provider's default. Throws ValidationError (empty_patch) when the patch names nothing, NotFoundError (PLAN_GRID_NOT_FOUND) |
listOnSale | (gridKey: string) => Promise<PlanWithPrices[]> | One grid's active plans ascending by rank with their active prices; archived tiers excluded. Throws NotFoundError (PLAN_GRID_NOT_FOUND) |
listAll | () => Promise<PlanWithPrices[]> | The whole catalogue by grid then rank, archived tiers and prices included: the admin view |
getById | (id: string) => Promise<Plan | null> | |
getByPlanKey | (planKey: string) => Promise<Plan | null> | Resolves archived plans too, so a grandfathered subscription still renews |
getByProviderProductId | (providerProductId: string) => Promise<Plan | null> | The tier a provider product sells, archived or not; null for a product outside the catalogue |
listPrices | (planId: string) => Promise<PlanPrice[]> | Every price of a plan, archived ones included |
getPriceById | (id: string) => Promise<PlanPrice | null> | |
getPriceByProviderPriceId | (providerPriceId: string) => Promise<PlanPrice | null> | Drift detection: the price a provider id names, without its plan |
getByProviderPriceId | (providerPriceId: string) => Promise<PlanPriceWithPlan | null> | The price a provider event names, with the plan whose preset it buys; archived rows resolve too |
create | (input: NewPlan) => Promise<Plan> | Validates (see below), trims labels and provider id, then checks the grid exists (unknown_grid), the key is free (duplicate_plan_key) and the rank is free in the grid, archived plans included (duplicate_rank) |
update | (id: string, patch: PlanPatch) => Promise<Plan> | Throws NotFoundError (PLAN_NOT_FOUND); rejects an empty patch, a rank held by another plan, and putting a plan with an empty preset back on sale |
swapRanks | (id: string, otherId: string) => Promise<void> | Exchanges the ranks of two plans of one grid through a temporary free rank. Rejects the same plan (same_plan) and two grids (different_grid); throws NotFoundError for either unknown plan |
createPrice | (input: NewPlanPrice) => Promise<PlanPrice> | Throws NotFoundError (PLAN_NOT_FOUND); an active price refuses a second live price on an interval the plan already sells (duplicate_live_interval) |
updatePrice | (id: string, patch: PlanPricePatch) => Promise<PlanPrice> | Only the provider id and the status move. Throws NotFoundError (PLAN_PRICE_NOT_FOUND); putting a price back on sale beside a live one on its interval is refused |
PlansCatalogueReader is the catalogue as a consumer that may not change it sees it: Pick<PlansService, "listGrids" | "getGridById" | "getGridByKey" | "listOnSale" | "getById" | "getByPlanKey" | "getByProviderProductId" | "listPrices" | "getPriceById" | "getPriceByProviderPriceId" | "getByProviderPriceId">. It is named in the module so a consumer never re-lists the method names.
Two validators are exported for previews:
| Function | Rejects with VALIDATION_ERROR when |
|---|---|
validatePlanInput(input: NewPlan) | The key is not a slug (invalid_plan_key), no label or a blank label (empty_labels, blank_label), unknown status, a non-integer or negative rank (invalid_rank), a preset with an unknown limit or feature key (unknown_limit_key, unknown_feature_key), a negative or NaN limit (invalid_limit), or an empty preset on a plan that is on sale (empty_preset). Number.POSITIVE_INFINITY stays legal |
validatePlanPriceInput(input: NewPlanPrice) | Unknown interval (invalid_billing_interval), an interval count below 1 (invalid_interval_count), a non-integer or negative amount (invalid_price), unknown currency (invalid_currency), a trial below one day (invalid_trial_days), unknown status |
export function teachersCatalogue(
repositories: PlansServiceDeps
): Promise<PlanWithPrices[]> {
const catalogue = createPlansService(repositories);
// Active plans of the grid ascending by rank, each with its active prices.
// Throws NotFoundError(PLAN_GRID_NOT_FOUND) for an unknown grid key.
return catalogue.listOnSale("teachers");
}export async function createTier(
repositories: PlansServiceDeps,
input: NewPlan
) {
const catalogue = createPlansService(repositories);
const plan = await catalogue.create(input);
// Sell it monthly; the provider price id is bound later by the pricing service.
const price = await catalogue.createPrice({
planId: plan.id,
billingInterval: "month",
intervalCount: 1,
priceCents: 1900,
currency: "EUR",
providerPriceId: null,
trialDays: 14,
status: "active",
});
return { plan, price };
}PlanPricingService
createPlanPricingService(deps: PlanPricingServiceDeps): PlanPricingService, with deps = { plans: Pick<PlansService, "getById" | "listPrices" | "getPriceById" | "createPrice" | "updatePrice">; catalogue: CatalogueProvider; logger? }. Changing what a plan costs is one action across two systems: the provider price is created under the plan's product, the previous live price of the interval is archived, the new row is written, and the previous provider price is archived last. A price is immutable on both sides, so there is no update, only this replacement.
Nothing here owns a transaction, so the writes after the provider create are compensated by hand: a failure archives the provider price just created and puts the previous row back on sale. The database is the source of truth once its writes land; a failure archiving the previous provider price is logged (provider price left active), and the provider's price events flag the drift.
| Method | Signature | Description |
|---|---|---|
publishPrice | (input: PublishPlanPriceInput) => Promise<PlanPrice> | Sells the plan at a new price on an interval, retiring the price it replaces. Validates before any provider call. Throws NotFoundError (PLAN_NOT_FOUND) and ValidationError (missing_provider_product) when the plan has no provider product yet |
archivePrice | (priceId: string) => Promise<PlanPrice> | Stops selling a price on both sides; its subscribers keep it. Skips the provider when the row has no provider price |
PublishPlanPriceInput picks planId, billingInterval, intervalCount, priceCents, currency, trialDays from NewPlanPrice.
export function changePrice(
repositories: PlansServiceDeps,
catalogue: CatalogueProvider,
input: PublishPlanPriceInput
) {
const pricing = createPlanPricingService({
plans: createPlansService(repositories),
catalogue,
});
// Creates the provider price under the plan's product, archives the previous
// live price of the interval on both sides, and stores the new row.
return pricing.publishPrice(input);
}Ports
The catalogue repositories
Each of the catalogue's three tables binds a canonical pair, read side Pick<ReadRepository<…>, "findMany">. Reads are status-blind unless the query filters on status, so an archived tier or price still resolves for the subscribers it grandfathers. Every lookup the service offers (by id, key, rank, provider product, provider price) is a query, not a port method.
| Port | Binds | Notes |
|---|---|---|
PlanGridsReadRepository | PlanGridQuery | empty(), withId, withGridKey; oldest first, then by key. Projection PlanGridProjections.entity() |
PlanGridsRepository | update of WriteRepository<PlanGridCriteria, PlanGridPatch, never> | Grids are seeded by migration; only their portal configuration is patched |
PlansReadRepository | PlanQuery | empty(), withId, withPlanKey, inGrid, withRank, withProviderProductId, onSale(); by grid then rank. Projections PlanProjections.entity(), withPrices() (every price) and withOnSalePrices() (the active ones), each price list cheapest window first |
PlansRepository | update of WriteRepository<PlanCriteria, PlanPatch, NewPlan>, plus writeOne(input: NewPlan): Promise<{ planId }> | The table refuses a taken key or a taken rank in the grid |
PlanPricesReadRepository | PlanPriceQuery | empty(), withId, forPlan, withProviderPriceId; cheapest first, then oldest. Projections PlanPriceProjections.entity() and withPlan(), the price with the tier it sells in one join |
PlanPricesRepository | update of WriteRepository<PlanPriceCriteria, PlanPricePatch, NewPlanPrice>, plus writeOne(input: NewPlanPrice): Promise<{ planPriceId }> | The table refuses a second live price on one interval of a plan |
Every plan and price update stamps updatedAt. The service answers the written row by reading it back after writeOne or update.
CatalogueProvider
The provider side of the price catalogue. The adapter owns every provider object; the domain only ever sees provider ids. @workspace/payment/server implements it with createStripeCatalogueProvider.
Prop
Type
| Method | Signature | Description |
|---|---|---|
createPrice | (input: CreateCataloguePriceInput) => Promise<{ providerPriceId: string }> | Creates the price under the plan's provider product |
archivePrice | (providerPriceId: string) => Promise<void> | Stops selling the price at the provider; existing subscribers keep it |
Errors
| Error | Code | Thrown by |
|---|---|---|
ValidationError | VALIDATION_ERROR | Every write of PlansService and publishPrice; context.field and context.reason name the rule |
NotFoundError | PLAN_NOT_FOUND | update, swapRanks, createPrice, publishPrice |
NotFoundError | PLAN_GRID_NOT_FOUND | updateGrid, listOnSale |
NotFoundError | PLAN_PRICE_NOT_FOUND | updatePrice |
Fixtures
@workspace/training/factories exports makePlanGrid, makePlan, makeNewPlan, makePlanPrice, makeNewPlanPrice and makePlanWithPrices, all (faker, overrides). makeNewPlan gives the key a random suffix so parallel tests never collide; makeNewPlanPrice is a monthly price before the provider price exists.
export function fixtures(faker: Faker) {
const grid = makePlanGrid(faker, { gridKey: "teachers" });
const newPlan = makeNewPlan(faker, { gridId: grid.id, rank: 1 });
const sold = makePlanWithPrices(faker, { gridId: grid.id });
const newPrice = makeNewPlanPrice(faker, { planId: sold.id, billingInterval: "year" });
return { grid, newPlan, sold, newPrice };
}Entitlements
What a subject may do and how much, computed from defaults and dated grants; flow quotas counted per grant, the diffusion debit, and the degraded mode of resources above a stock ceiling.
Subscriptions
The mirror of the payment provider's subscriptions, the reconciliation that turns a provider event into the entitlement grants a subscription buys, the admin figures, and the termination when an account goes away.