Bridge Training

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.

A Subscription is one account's paid subscription, mirrored from the payment provider: the catalogue price it bought, the provider ids it is reconciled by, and the billing window [currentPeriodStart, currentPeriodEnd). There is one row per provider subscription; a subject may hold several, one per grid of plans, and their entitlements add up. There are no seats. The reference is the price, not the plan: the price already names its plan, and it is the price the subscriber is grandfathered on.

The module never writes the mirror from a user action. The provider's events are normalised by the payment adapter (normaliseStripeSubscription in @workspace/payment/server) and applied by the reconciliation service, which also writes the entitlement grants the subscription buys. The read side serves the API (the subject's own subscriptions) and the billing service; the admin has its own read model and stats.

LayerImport path
Domain@workspace/training/domain/subscriptions
Application@workspace/training/application/subscriptions

The services take no ServiceContext: the subject is an explicit subjectId, and the reconciliation runs from a webhook that carries no caller.

Domain

Subscription

Prop

Type

NewSubscription picks every field but id, createdAt, updatedAt. SubscriptionEntitlingState is Pick<Subscription, "status" | "currentPeriodStart" | "currentPeriodEnd">: the fields that decide whether the subscription buys anything at an instant.

SubscriptionProviderEvent

A provider event already normalised by its adapter; the domain never sees a provider object. It carries the provider's own price id, since that is what a webhook names, and occurredAt, when the provider emitted it, the only instant two events about the same window may be ordered by.

Prop

Type

Statuses and guards

ExportDefinition
SUBSCRIPTION_STATUSESincomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid, paused (SubscriptionStatus)
ENTITLING_SUBSCRIPTION_STATUSEStrialing, active, past_due: the statuses that still buy entitlements. past_due keeps them while dunning retries
isEntitlingSubscriptionStatus(status)Membership in the list above
isWithinBillingPeriod(subscription, at)currentPeriodStart <= at < currentPeriodEnd
isSubscriptionEntitling(subscription: SubscriptionEntitlingState, at)Entitling status and at inside the window
subscriptions.ts
ENTITLING_SUBSCRIPTION_STATUSES; // => ["trialing", "active", "past_due"]
isEntitlingSubscriptionStatus("past_due"); // => true: entitlements stay while dunning retries
isEntitlingSubscriptionStatus("canceled"); // => false

const held = {
  status: "active" as const,
  currentPeriodStart: MARCH_1,
  currentPeriodEnd: APRIL_1,
};
isWithinBillingPeriod(held, APRIL_1); // => false: the window is [start, end)
isSubscriptionEntitling(held, MARCH_10); // => true
isSubscriptionEntitling({ ...held, status: "unpaid" }, MARCH_10); // => false

Application

SubscriptionsService

createSubscriptionsService(deps: SubscriptionsServiceDeps): SubscriptionsService, with deps = { subscriptionsRead: Pick<SubscriptionsReadRepository, "findMany">; clock? }. The read side of the mirror: listForSubject is a SubscriptionQuery for the subject, narrowed to a grid when asked. Its repository is injected already bound to the caller's transaction; it performs no write.

MethodSignatureDescription
listForSubject(subjectId: string, scope?: { gridId?: string }) => Promise<Subscription[]>Every subscription of the subject, newest first; with a grid, that grid's only
getForSubjectInGrid(subjectId: string, gridId: string) => Promise<Subscription | null>The subscription the subject holds in a grid: the newest one still entitling, else the newest one; null when it never subscribed there
subscriptions.ts
export function heldInGrid(
  subscriptionsRead: SubscriptionsReadRepository,
  subjectId: string,
  gridId: string
): Promise<Subscription | null> {
  const service = createSubscriptionsService({ subscriptionsRead });
  // The newest entitling subscription of the grid, else the newest one, else null.
  return service.getForSubjectInGrid(subjectId, gridId);
}

SubscriptionReconciliationService

createSubscriptionReconciliationService(deps: SubscriptionReconciliationServiceDeps): SubscriptionReconciliationService, with deps = { reconciliation: SubscriptionReconciliationUnitOfWork; clock? }. Turns one normalised provider event into the mirror row and the grants it buys. It owns its transaction boundary: the mirror and the grants are written on the owner connection, outside any request-scoped transaction, because a provider webhook carries no JWT.

MethodSignatureDescription
applyProviderEvent(event: SubscriptionProviderEvent) => Promise<ApplyProviderEventResult>Mirrors the row, ends the windows the event replaces at the switch, and writes the grants for the quota windows it buys from there. Idempotent and tolerant of out-of-order delivery. Throws NotFoundError (PLAN_PRICE_NOT_FOUND) for a price the catalogue does not hold

How one event is applied:

  1. Stale events are ignored. An event about a window older than the stored one, or a same-window event the provider emitted before the one already applied, answers ignored_stale. So does an event the guarded upsert refused to apply.
  2. The price is resolved. The provider price id resolves to the catalogue price and the plan whose preset the grant carries. A price change inside one tier is not a plan change: the preset is the same, nothing is torn down.
  3. The customer is bound. A subscription created outside the checkout (from the provider's dashboard) names a customer the subject may not be bound to yet; the event binds it. A binding the provider deleted is replaced only by another customer, never revived by a late event about the dead one.
  4. The switch instant is clamped. The event takes effect at the provider's emission instant, never at our processing time, clamped into the billing window it describes.
  5. Windows are closed. At most one grant of a subscription covers any instant, since credits sum across active grants. A window that served before the switch is ended there; one that had not opened yet is revoked. On an entitling same-plan event the grants already serving the window are kept.
  6. Grants are opened. The billing window is tiled into monthly quota windows (splitBillingWindowIntoQuotaMonths): one grant for a monthly price, twelve for a yearly one, each carrying the preset as a per-month allowance, so a yearly subscriber is never handed the year's allowance at once. A grant opened at a mid-month switch inherits the count of the grant it cuts (findReplacedGrant, then counters.carry): an upgrade never resets the month, a downgrade can leave the subject over its new cap until the month rolls over. The credits of that first month are prorated like the price (prorateLimitsForSwitch).

ApplyProviderEventOutcome says what happened to the served window: created, renewed, plan_changed, ended, unchanged (which may still have closed strays), ignored_stale.

Prop

Type

Two pure decisions are exported beside the factory:

FunctionSignatureDescription
deriveGrantsForSubscription(subscription: BillingWindow, preset: PlanPreset, validFrom?: Date, replaced?: PlanPreset | null) => NewEntitlementGrant[]The grants a subscription buys for one billing window, one per quota month, source: "subscription". Months closed by validFrom (the period start unless the plan changed mid-window) are left out; the month it falls in opens there while keeping its own quotaPeriodStart, its credits prorated against replaced when given. Nothing here lowers a limit
isStaleProviderEvent(stored: Pick<Subscription, "currentPeriodStart" | "providerEventAt">, event: SubscriptionProviderEvent) => booleanOlder window, or same window emitted before the applied one

BillingWindow is Pick<Subscription, "id" | "subjectId" | "currentPeriodStart" | "currentPeriodEnd">.

subscriptions.ts
const preset: PlanPreset = {
  limits: { diffusions_per_period: 350, collections_per_user_max: 100 },
  features: new Set(["custom_branding"]),
};
const yearly = {
  id: "sub-1",
  subjectId: "account-1",
  currentPeriodStart: MARCH_1,
  currentPeriodEnd: new Date("2027-03-01T00:00:00Z"),
};
const grants = deriveGrantsForSubscription(yearly, preset);
grants.length; // => 12: one grant per quota month, each carrying the preset
grants[0]?.validUntil; // => 2026-04-01
grants[0]?.source; // => "subscription"
grants[0]?.quotaPeriodStart; // => 2026-03-01

// A plan change on 10 March: the March grant opens there, credits prorated against the replaced preset.
const switched = deriveGrantsForSubscription(yearly, preset, MARCH_10, {
  limits: { diffusions_per_period: 100 },
  features: new Set(),
});
switched[0]?.validFrom; // => 2026-03-10
switched[0]?.quotaPeriodStart; // => 2026-03-01: the same quota month continues
subscriptions.ts
const stored = { currentPeriodStart: MARCH_1, providerEventAt: MARCH_10 };
const event: SubscriptionProviderEvent = {
  subjectId: "account-1",
  providerCustomerId: "cus_123",
  providerSubscriptionId: "sub_123",
  providerPriceId: "price_123",
  status: "active",
  currentPeriodStart: MARCH_1,
  currentPeriodEnd: APRIL_1,
  cancelAtPeriodEnd: false,
  occurredAt: new Date("2026-03-05T00:00:00Z"),
};
isStaleProviderEvent(stored, event); // => true: same window, emitted before the applied one
isStaleProviderEvent(stored, { ...event, occurredAt: new Date("2026-03-12T00:00:00Z") }); // => false
subscriptions.ts
export async function onProviderEvent(
  reconciliation: SubscriptionReconciliationUnitOfWork,
  normalised: SubscriptionProviderEvent
) {
  const service = createSubscriptionReconciliationService({ reconciliation });
  const result = await service.applyProviderEvent(normalised);
  result.outcome; // => "created" | "renewed" | "plan_changed" | "ended" | "unchanged" | "ignored_stale"
  result.grants; // => the grants serving the window from the switch on, oldest first
  result.endedGrantIds; // => the windows this event closed
  return result;
}

SubscriptionsAdminService

createSubscriptionsAdminService(deps: SubscriptionsAdminServiceDeps): SubscriptionsAdminService, with deps = { subscriptionsRead: Pick<SubscriptionsReadRepository, "getById" | "findManyPaginated" | "aggregate">; grants: Pick<EntitlementsRepository, "listGrants">; plans: Pick<PlansCatalogueReader, "listGrids"> }. The admin's view: the paginated list, one subscription with the grants it produced, and the stats page. Active plans, MRR and churn are folded per grid from grouped repository reads, so the arithmetic is one pure function and never re-implemented in SQL. Nothing here writes.

MethodSignatureDescription
list({ page, pageSize, filters?: SubscriptionsAdminFilters }) => Promise<PaginatedSubscriptionAdminRows>Newest first; filters on subjectId and status[]; the page is at least 1, the page size between 1 and 100
getById(id: string) => Promise<SubscriptionAdminDetail>The row with the grants it produced (listSubscriptionGrants). Throws NotFoundError (SUBSCRIPTION_NOT_FOUND)
stats(now: Date) => Promise<SubscriptionsStats>The month of now (UTC), every grid of the catalogue in its own order, empty ones included

Result shapes:

TypeFields
SubscriptionAdminRow{ subscription; price; plan; account: { id, email, displayName } }
SubscriptionAdminDetailSubscriptionAdminRow & { grants: EntitlementGrant[] }
PlanSubscriptionStats{ planId; count; mrrCents }; count includes trials
SubscriptionsChurn{ canceled; base; rate: number | null }; rate is null when nobody was entitling on the first of the month
GridSubscriptionsStats{ grid: PlanGrid; activeByPlan: PlanSubscriptionStats[]; activeSubjects; mrrCents; churn }; activeSubjects counts each subject once
SubscriptionsStats{ at: Date; month: TimeWindow; grids: GridSubscriptionsStats[] }

Pure folds, exported for the stats page tests and any other presenter:

FunctionSignatureDescription
foldEntitlingGroups(groups: EntitlingPriceGroup[]) => { activeByPlan; mrrCents }Sums monthlyAmountCents × count per plan; a trial buys entitlements but no revenue yet, so it counts in count only
churnRate(canceled: number, base: number) => number | nullcanceled / base, null on an empty base
foldStatsByGrid({ grids, groups, subjects, churn }) => GridSubscriptionsStats[]Each grid from its own groups and churn; a grid absent from the reads is all zeros
subscriptions.ts
export async function monthlyFigures(
  subscriptionsRead: SubscriptionsReadRepository,
  grantsReader: Pick<EntitlementsRepository, "listGrants">,
  plans: Pick<PlansCatalogueReader, "listGrids">,
  now: Date
) {
  const admin = createSubscriptionsAdminService({
    subscriptionsRead,
    grants: grantsReader,
    plans,
  });
  const stats = await admin.stats(now);
  return stats.grids.map(({ grid, activeSubjects, mrrCents, churn }) => ({
    grid: grid.gridKey,
    activeSubjects,
    mrrCents,
    churnRate: churn.rate, // => null when nobody was entitling on the first of the month
  }));
}

foldEntitlingGroups([
  { planId: "pro", gridId: "teachers", billingInterval: "month", intervalCount: 1, priceCents: 1900, status: "active", count: 10 },
  { planId: "pro", gridId: "teachers", billingInterval: "year", intervalCount: 1, priceCents: 19_000, status: "active", count: 2 },
  { planId: "pro", gridId: "teachers", billingInterval: "month", intervalCount: 1, priceCents: 1900, status: "trialing", count: 3 },
]);
// => { activeByPlan: [{ planId: "pro", count: 15, mrrCents: 22_166 }], mrrCents: 22_166 }: trials count, pay nothing
churnRate(3, 60); // => 0.05
churnRate(0, 0); // => null

SubscriptionTerminationService

createSubscriptionTerminationService(deps: SubscriptionTerminationServiceDeps): SubscriptionTerminationService, with deps = { subscriptions: Pick<SubscriptionsService, "listForSubject">; provider: Pick<BillingProvider, "cancelSubscription"> }. Cancels at the provider every subscription a subject still holds, in every grid, when the subject goes away. It writes nothing locally: the provider reports each cancellation as an event, and the reconciliation ends the mirrored row and its grants from it.

MethodSignatureDescription
cancelAllForSubject(subjectId: string) => Promise<string[]>Cancels each subscription not already canceled or incomplete_expired; answers their provider subscription ids
subscriptions.ts
export function closeAccountSubscriptions(
  subscriptions: Pick<SubscriptionsService, "listForSubject">,
  provider: Pick<BillingProvider, "cancelSubscription">,
  subjectId: string
): Promise<string[]> {
  const termination = createSubscriptionTerminationService({ subscriptions, provider });
  // Cancels at the provider every subscription not already ended; the provider's
  // events then end the mirrored rows and their grants through the reconciliation.
  return termination.cancelAllForSubject(subjectId);
}

Ports

SubscriptionsReadRepository

SubscriptionsReadRepository = ReadRepository<SubscriptionQuery>, the runtime and the admin reads alike. getById throws NotFoundError (SUBSCRIPTION_NOT_FOUND).

ExportDescription
SubscriptionWhere{ id?, subjectId?, providerSubscriptionId?, status?, gridId? }; gridId resolves through the price bought and the plan it sells
SubscriptionQueryempty(), withId, forSubject, inGrid(gridId?), withProviderSubscriptionId, withStatuses(statuses?), withPagination, withFilters; newest first
SubscriptionProjectionsentity(), the Subscription; adminRow(), the SubscriptionAdminRow joined to its price, plan and holder
SubscriptionAggregationsentitlingByPrice(at) (EntitlingPriceGroup[]), entitlingSubjectsByGrid(at) (EntitlingGridSubjects[], grids nobody holds absent), churnByGrid({ monthStart, nextMonthStart }) (ChurnCounts[], grids with neither count absent); each a grouped read proportional to the number of groups

listSubscriptionGrants(entitlements, subscription) answers the grants a subscription wrote, whatever their status, newest window first, from EntitlementsRepository.listGrants of its subject: a subscription never changes hands.

SubscriptionsRepository

MethodSignatureDescription
upsert(input: NewSubscription) => Promise<Subscription>One row per provider subscription: an event about a known subscription updates its row, a new provider subscription opens a row of its own. Never rolls the row backwards (an older window, or a same-window event emitted earlier) and never hands it to another subject; answers the row that stands

upsert is the port's only method and a ratified bespoke one, see Bespoke by design.

SubscriptionReconciliationUnitOfWork

run<T>(step: (tx: SubscriptionReconciliationTx) => Promise<T>): Promise<T> runs one reconciliation inside one database transaction: the mirrored row, the windows it closes, the grant it opens and the count that grant inherits commit or roll back together. SubscriptionReconciliationTx bundles the ports the step reads and writes:

FieldType
subscriptions, subscriptionsReadSubscriptionsRepository, Pick<SubscriptionsReadRepository, "findMany">: the stored row by provider id
plansRead, planPricesReadPlansReadRepository, PlanPricesReadRepository: the sold price with its tier, and the tier a stored row was on
entitlementsPick<EntitlementsRepository, "listGrants" | "createGrant" | "revokeGrant" | "endGrant">
countersPick<UsageCountersRepository, "carry">: hands the count of a grant cut mid-month to the one replacing it
customers, customersReadBillingCustomersRepository, BillingCustomersReadRepository: the step binds the event's customer through a BillingCustomersService built over them

Prop

Type

Errors

ErrorCodeThrown by
NotFoundErrorPLAN_PRICE_NOT_FOUNDapplyProviderEvent, for an event naming a price outside the catalogue
NotFoundErrorSUBSCRIPTION_NOT_FOUNDSubscriptionsAdminService.getById

Fixtures

@workspace/training/factories exports makeSubscription(faker, overrides): an active subscription inside its billing window, three days in.

subscriptions.ts
export function fixtures(faker: Faker) {
  // An active subscription three days into its billing window.
  return makeSubscription(faker, { cancelAtPeriodEnd: true });
}