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).
| Layer | Import 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">.
| Export | Definition |
|---|---|
isLiveBillingCustomer(customer: Pick<BillingCustomer, "deletedAt">) | deletedAt === null |
isLiveBillingCustomer({ deletedAt: null }); // => true
isLiveBillingCustomer({ deletedAt: new Date("2026-03-01T00:00:00Z") }); // => false: a tombstone, the next checkout mints a new customerApplication
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.
| Method | Signature | Description |
|---|---|---|
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
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,
});
}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
}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.
| Method | Signature | Description |
|---|---|---|
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.
| Export | Description |
|---|---|
BillingCustomerWhere | { subjectId?, providerCustomerId?, deletedAt? }; deletedAt: { isNull: true } selects the live bindings |
BillingCustomerQuery | empty(), forSubject(subjectId), withFilters(where) |
BillingCustomerCriteria | tombstonedForSubject(subjectId), liveWithProviderCustomer(providerCustomerId), where, and |
BillingCustomerProjections | entity(), 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.
| Method | Signature | Description |
|---|---|---|
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
| Error | Code | HTTP | Thrown by |
|---|---|---|---|
NotFoundError | PLAN_PRICE_NOT_FOUND | 404 | startCheckout |
ValidationError | PLAN_PRICE_NOT_ON_SALE | 422 | startCheckout |
ConflictError | SUBSCRIPTION_ALREADY_ACTIVE | 409 | startCheckout |
NotFoundError | PLAN_GRID_NOT_FOUND | 404 | openPortal |
NotFoundError | BILLING_CUSTOMER_NOT_FOUND | 404 | openPortal |
ConflictError | PLAN_GRID_PORTAL_NOT_CONFIGURED | 409 | openPortal |
The HTTP statuses are those @workspace/api-contract assigns at the API boundary; see API errors.
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.
Ads
Table dressing of a served deal, the campaigns and the owner's own visuals that pay for it, and the upload flow of those visuals.