Bridge Training

Billing

The binding between an account and its payment-provider customer, and the service that opens the hosted checkout and portal.

The billing module is the thin seam between the product and the payment provider's hosted surfaces. A BillingCustomer binds an account to the customer record the provider holds for it. The BillingService opens a hosted checkout for a catalogue price and the portal of an existing customer. It never touches the subscription mirror: that arrives through the provider's events (Subscriptions).

Consumers: the API (the checkout and portal endpoints), the subscription reconciliation (binding the customer an event names) and the payment adapter's customer webhook (tombstoning a deleted customer).

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

The service takes no ServiceContext: the subject and its billing email are explicit input fields.

Domain

BillingCustomer

Bound once, before the first checkout, so every hosted session of a subject reuses the same provider customer: a checkout that let the provider mint a customer on the fly would leave one subject with several of them and lose the way back from a provider event to the account. A customer deleted at the provider leaves a tombstone rather than no row: the binding is dead, and the next checkout must mint a new customer instead of reviving the dead id from the subscription mirror.

Prop

Type

NewBillingCustomer is Pick<BillingCustomer, "subjectId" | "providerCustomerId">.

ExportDefinition
isLiveBillingCustomer(customer: Pick<BillingCustomer, "deletedAt">)deletedAt === null
billing.ts
isLiveBillingCustomer({ deletedAt: null }); // => true
isLiveBillingCustomer({ deletedAt: new Date("2026-03-01T00:00:00Z") }); // => false: a tombstone, the next checkout mints a new customer

Application

BillingService

createBillingService(deps: BillingServiceDeps): BillingService, with deps = { plans: Pick<PlansCatalogueReader, "getPriceById" | "getById" | "getGridByKey">; subscriptions: Pick<SubscriptionsService, "listForSubject">; customers: Pick<BillingCustomersService, "getForSubject" | "bind">; provider: BillingProvider; clock? }. It writes exactly one thing, the binding between the subject and its provider customer, and writes it before the first checkout. Nothing here owns a transaction.

A subject may hold one subscription per grid of plans. Inside a grid a subscriber changes plan in the portal, never through a second checkout: two live subscriptions of one grid would double-credit it, so the checkout refuses while the subject's subscription in the grid of the price still entitles, a subscription set to cancel at period end included. The portal opens on the grid's own configuration, so a switch never leaves the grid; a grid without one has no portal, since the provider's default would offer every product.

MethodSignatureDescription
startCheckout(input: StartCheckoutInput) => Promise<{ url: string }>A hosted checkout for one on-sale price, on the subject's provider customer, created and bound first when the subject has none or holds a tombstone. Passes the catalogue trial through. Throws NotFoundError (PLAN_PRICE_NOT_FOUND), ValidationError (PLAN_PRICE_NOT_ON_SALE) when the plan or the price is archived or the price has no provider id, ConflictError (SUBSCRIPTION_ALREADY_ACTIVE)
openPortal(input: OpenPortalInput) => Promise<{ url: string }>The provider's portal of one grid for the subject's customer. Never creates a customer: nothing to manage is an error. Throws NotFoundError (PLAN_GRID_NOT_FOUND, BILLING_CUSTOMER_NOT_FOUND), ConflictError (PLAN_GRID_PORTAL_NOT_CONFIGURED)

Two checkouts racing for a subject without a customer may both create one at the provider; the binding keeps the first and both sessions open on it. The other provider customer stays unused there, which costs nothing.

Prop

Type

Prop

Type

billing.ts
export type BillingPorts = {
  plans: PlansServiceDeps;
  subscriptionsRead: SubscriptionsReadRepository;
  customers: BillingCustomersRepository;
  customersRead: BillingCustomersReadRepository;
  provider: BillingProvider; // createStripeBillingProvider from @workspace/payment/server
};

export function billingService(ports: BillingPorts) {
  return createBillingService({
    plans: createPlansService(ports.plans),
    subscriptions: createSubscriptionsService(ports),
    customers: createBillingCustomersService(ports),
    provider: ports.provider,
  });
}
billing.ts
export async function checkout(
  ports: BillingPorts,
  input: StartCheckoutInput
): Promise<string> {
  // Binds a provider customer first if the subject has none, then opens the
  // hosted checkout on the price. Throws while a subscription in the price's
  // grid still entitles: that change belongs to the portal.
  const { url } = await billingService(ports).startCheckout(input);
  return url; // => the hosted checkout URL to redirect the browser to
}
billing.ts
export async function portal(
  ports: BillingPorts,
  input: OpenPortalInput
): Promise<string> {
  // The grid's own portal configuration bounds plan switches to its plans.
  const { url } = await billingService(ports).openPortal(input);
  return url;
}

BillingCustomersService

createBillingCustomersService(deps: BillingCustomersServiceDeps): BillingCustomersService, with deps = { customers: BillingCustomersRepository; customersRead: BillingCustomersReadRepository }. The binding between a subject and its provider customer, composed over the canonical surface. Nothing here owns a transaction: the reconciliation builds it over the repositories of its own unit of work.

MethodSignatureDescription
getForSubject(subjectId: string) => Promise<BillingCustomer | null>The subject's binding, tombstoned or not; null when it never had one
bind(input: NewBillingCustomer) => Promise<BillingCustomer>Binds the customer and answers the binding that stands: one customer per subject, a live binding wins over a later one, a tombstoned binding is replaced
markDeleted(providerCustomerId: string, deletedAt: Date) => Promise<BillingCustomer | null>Tombstones the binding holding this provider customer, at the instant the provider reported the deletion. Null when no live binding holds it

bind is an update of the subject's tombstoned binding (the new customer, deletedAt cleared, createdAt restamped), then, when none matched, a writeOne that keeps a subject already bound, then a read of the binding that stands. Two binds racing for one subject both read the first insert back; a bind racing a tombstone replacement waits on the row lock, matches nothing once the replacement commits, and reads it back.

Ports

BillingCustomersReadRepository and BillingCustomersRepository

BillingCustomersReadRepository = Pick<ReadRepository<BillingCustomerQuery>, "findMany">; BillingCustomersRepository picks update from WriteRepository<BillingCustomerCriteria, BillingCustomerPatch, NewBillingCustomer> and adds writeOne(input: NewBillingCustomer): Promise<void>, an insert that keeps a subject already bound. The ids update answers are subject ids.

ExportDescription
BillingCustomerWhere{ subjectId?, providerCustomerId?, deletedAt? }; deletedAt: { isNull: true } selects the live bindings
BillingCustomerQueryempty(), forSubject(subjectId), withFilters(where)
BillingCustomerCriteriatombstonedForSubject(subjectId), liveWithProviderCustomer(providerCustomerId), where, and
BillingCustomerProjectionsentity(), the BillingCustomer
BillingCustomerPatch{ providerCustomerId?, deletedAt? }; patching the provider customer restamps createdAt

BillingProvider

The hosted payment surfaces the domain hands a subject to, and the immediate cancellation of a subscription when its holder goes away. The adapter owns every provider object; the domain only ever sees provider ids and the URL a session opens at. Implemented by @workspace/payment/server: createStripeBillingProvider (Stripe Checkout, Billing Portal and subscription cancellation), beside createStripeCatalogueProvider for the CatalogueProvider port.

MethodSignatureDescription
createCustomer(input: CreateBillingCustomerInput) => Promise<{ providerCustomerId: string }>input = { subjectId, email }
createCheckoutSession(input: CreateCheckoutSessionInput) => Promise<HostedBillingSession>
createPortalSession(input: CreatePortalSessionInput) => Promise<HostedBillingSession>
cancelSubscription(providerSubscriptionId: string) => Promise<void>Ends the subscription now, without proration; one already ended is left as is

HostedBillingSession is { url: string }: the URL the browser is sent to.

Prop

Type

Prop

Type

Errors

ErrorCodeHTTPThrown by
NotFoundErrorPLAN_PRICE_NOT_FOUND404startCheckout
ValidationErrorPLAN_PRICE_NOT_ON_SALE422startCheckout
ConflictErrorSUBSCRIPTION_ALREADY_ACTIVE409startCheckout
NotFoundErrorPLAN_GRID_NOT_FOUND404openPortal
NotFoundErrorBILLING_CUSTOMER_NOT_FOUND404openPortal
ConflictErrorPLAN_GRID_PORTAL_NOT_CONFIGURED409openPortal

The HTTP statuses are those @workspace/api-contract assigns at the API boundary; see API errors.