payable

Domain Model

The domain model is the set of TypeScript interfaces in src/domain/entities/. Entities are plain, fully readonly data contracts: they hold no methods and no behavior. Behavior lives in value objects, state machines, and the application layer. Persisted shapes and provider identifiers are part of the entity; invariants and transitions are enforced elsewhere.

Every entity field is declared readonly, so an entity instance is never mutated in place; state changes produce new records.

Shared building blocks

These mixins live in src/domain/entities/common.ts and are composed into the entities below.

TypeFieldsPurpose
TimestampscreatedAt: Date, updatedAt: DateCreation and last-update instants.
TenantScopedtenantId: string | nullMulti-tenant scoping. null means the record is not bound to a tenant.
StoredMoneyamount: number, currency: CurrencyCodePersisted money shape (minor units + currency code). See Value Objects for the Money behavior.
RecurringInterval'day' | 'week' | 'month' | 'year'Billing interval unit for recurring prices.
MetadataRecord<string, string>Free-form string key/value bag.

Monetary amounts on entities (total, amountPaid, amountDue, amount, unitAmount, refundedAmount) are plain number values expressed in minor units (cents for two-decimal currencies). They are never floats representing major units. The currency: CurrencyCode field on the same entity tells you how to interpret them. See Value Objects for the no-floats rule and the Money helper that wraps these stored amounts.

Entity reference diagram

erDiagram
  CUSTOMER ||--o{ SUBSCRIPTION : has
  CUSTOMER ||--o{ INVOICE : billed
  CUSTOMER ||--o{ PAYMENT : pays
  CUSTOMER ||--o{ CUSTOMER_PROVIDER_BINDING : maps
  SUBSCRIPTION ||--o{ SUBSCRIPTION_ITEM : contains
  SUBSCRIPTION ||--o{ INVOICE : generates
  SUBSCRIPTION }o--|| PRICE : "priced by"
  SUBSCRIPTION_ITEM }o--|| PRICE : "priced by"
  PRODUCT ||--o{ PRICE : offers
  PAYMENT ||--o{ REFUND : "refunded by"

  CUSTOMER {
    string id PK
    string billableType
    string billableId
    string email
    string name
    string tenantId
  }
  CUSTOMER_PROVIDER_BINDING {
    string id PK
    string customerId FK
    string provider
    string providerCustomerId
  }
  SUBSCRIPTION {
    string id PK
    string customerId FK
    string status
    string priceId FK
    number quantity
    date trialEndsAt
    date endsAt
  }
  SUBSCRIPTION_ITEM {
    string id PK
    string subscriptionId FK
    string priceId FK
    number quantity
  }
  INVOICE {
    string id PK
    string customerId FK
    string subscriptionId FK
    string status
    string currency
    number total
    number amountPaid
    number amountDue
  }
  PAYMENT {
    string id PK
    string customerId FK
    string status
    string currency
    number amount
    number refundedAmount
  }
  REFUND {
    string id PK
    string paymentId FK
    string status
    string currency
    number amount
  }
  PRODUCT {
    string id PK
    string name
    boolean active
  }
  PRICE {
    string id PK
    string productId FK
    string currency
    number unitAmount
    string interval
    boolean active
  }

Relationships are expressed by foreign-key string fields (customerId, subscriptionId, priceId, productId, paymentId). There are no embedded references; entities only carry the id of related records.

Customer

src/domain/entities/customer.entity.ts. Extends TenantScoped, Timestamps.

Purpose: represents one host-application billable independently from any provider account.

FieldTypeNotes
idstringLocal identifier.
billableTypestringHost-side type discriminator.
billableIdstringHost-side record id.
emailstringCustomer email.
namestring | nullOptional display name.
metadataMetadata | nullOptional string key/value bag.

Relationships: owns many CustomerProviderBinding, CustomerProviderSyncState, Subscription, Invoice, and Payment records. On Payment the link is customerId: string | null, so a payment can exist without a customer.

Invariant: (tenantId, billableType, billableId) identifies one logical customer.

Customer Provider Binding

src/domain/entities/customer-provider-binding.entity.ts. Extends Timestamps and inherits tenant ownership through its customer.

Purpose: maps one logical customer to one registered provider account.

FieldTypeNotes
idstringLocal identifier.
customerIdstringOwning logical customer.
providerstringRegistered provider key, such as stripe-eu; not the adapter class name.
providerCustomerIdstringCustomer identifier returned by that provider account.

Invariants: (customerId, provider) is unique, and (provider, providerCustomerId) is unique. The same opaque provider id may appear under different registered provider keys. Deleting a customer cascades to its bindings.

Customer Provider Sync State

src/domain/entities/customer-provider-sync-state.entity.ts. Extends TenantScoped, Timestamps.

Purpose: records synchronization attempts independently from provider bindings. No row means never attempted. pending, failed, synchronized, and reconciliation_required describe the durable lifecycle without storing provider messages or credentials.

The row is unique by (tenantId, customerId, provider). It stores attempts, lastAttemptedAt, synchronizedAt, a safe failureCode, and the provider customer id when known. Deleting the logical customer cascades to its sync states.

Subscription

src/domain/entities/subscription.entity.ts. Extends TenantScoped, Timestamps.

Purpose: a recurring billing agreement for a customer against a price.

FieldTypeNotes
idstringLocal identifier.
customerIdstringOwning customer.
namestringSubscription name/type.
providerstringBilling provider.
providerSubscriptionIdstring | nullSubscription id on the provider.
statusSubscriptionStatusOne of the values in subscription-status.
priceIdstring | nullPrimary price reference.
quantitynumberSeat/unit count.
trialEndsAtDate | nullTrial end instant.
endsAtDate | nullCancellation/grace-period end instant.
currentPeriodStartDate | nullCurrent billing period start.
currentPeriodEndDate | nullCurrent billing period end.

Relationships: belongs to one Customer; contains many SubscriptionItem; may generate Invoice records (Invoice.subscriptionId); references a Price via priceId.

Lifecycle: status is governed by the Subscription state machine. The date fields (trialEndsAt, endsAt) drive the lifecycle predicates below.

Subscription state predicates

src/domain/entities/subscription-state.ts. Three pure functions read a Subscription plus an explicit now: Date and return a boolean. They compare epoch milliseconds via getTime().

PredicateReturns true whenExact logic
onTrial(subscription, now)The trial is still running.trialEndsAt !== null && trialEndsAt.getTime() > now.getTime()
onGracePeriod(subscription, now)The subscription has a future end date (canceled but not yet expired).endsAt !== null && endsAt.getTime() > now.getTime()
subscriptionEnded(subscription, now)The end date has passed (or is exactly now).endsAt !== null && endsAt.getTime() <= now.getTime()

Notes:

  • onTrial uses a strict > comparison, so the exact trialEndsAt instant is no longer “on trial”.
  • onGracePeriod and subscriptionEnded are complementary across endsAt: with a non-null endsAt, exactly one is true for any given now (the boundary instant counts as ended, not grace).
  • All three return false when the relevant date is null.

Subscription Item

src/domain/entities/subscription-item.entity.ts. Extends Timestamps only (not tenant-scoped; it inherits tenancy through its parent subscription).

Purpose: a single priced line on a subscription, enabling multi-price subscriptions.

FieldTypeNotes
idstringLocal identifier.
subscriptionIdstringOwning subscription.
priceIdstringPrice for this line.
providerItemIdstring | nullItem id on the provider.
quantitynumberUnit count for this line.

Relationships: belongs to one Subscription; references one Price.

Invoice

src/domain/entities/invoice.entity.ts. Extends TenantScoped, Timestamps.

Purpose: a billing document for a customer, optionally tied to a subscription.

FieldTypeNotes
idstringLocal identifier.
customerIdstringBilled customer.
subscriptionIdstring | nullSource subscription, if any.
providerstringBilling provider.
providerInvoiceIdstring | nullInvoice id on the provider.
statusInvoiceStatusOne of the values in invoice-status.
currencyCurrencyCodeCurrency of the amounts below.
totalnumberInvoice total, minor units.
amountPaidnumberAmount paid so far, minor units.
amountDuenumberOutstanding amount, minor units.
numberstring | nullHuman-facing invoice number.
hostedInvoiceUrlstring | nullProvider-hosted invoice URL.
invoicePdfstring | nullPDF URL.

Relationships: belongs to one Customer; optionally belongs to one Subscription.

Lifecycle: status is governed by the Invoice state machine. Amount fields are minor-unit integers interpreted by currency.

Payment

src/domain/entities/payment.entity.ts. Extends TenantScoped, Timestamps.

Purpose: a charge against a provider, optionally attributed to a customer.

FieldTypeNotes
idstringLocal identifier.
customerIdstring | nullCustomer, if known.
providerstringBilling provider.
providerPaymentIdstring | nullPayment id on the provider.
statusPaymentStatusOne of the values in payment-status.
currencyCurrencyCodeCurrency of the amounts below.
amountnumberCharge amount, minor units.
refundedAmountnumberTotal refunded so far, minor units.
referencestring | nullExternal reference.
descriptionstring | nullFree-text description.

Relationships: optionally belongs to one Customer; refunded by many Refund records (each references paymentId).

Lifecycle: status is governed by the Payment state machine. refundedAmount tracks cumulative refunds; the partially_refunded and refunded payment states correspond to partial vs. full refunds.

Refund

src/domain/entities/refund.entity.ts. Extends TenantScoped, Timestamps.

Purpose: a refund issued against a payment.

FieldTypeNotes
idstringLocal identifier.
paymentIdstringPayment being refunded.
providerstringBilling provider.
providerRefundIdstring | nullRefund id on the provider.
statusRefundStatusOne of the values in refund-status.
currencyCurrencyCodeCurrency of amount.
amountnumberRefund amount, minor units.
reasonstring | nullOptional reason.

Relationships: belongs to one Payment (required paymentId).

Lifecycle: status is governed by the Refund state machine.

Canonical product and provider bindings

CanonicalProduct is the provider-neutral sellable product used by payable.products(). Its stable local ID, tenant, name, description, lifecycle state, and metadata do not depend on a remote provider. ProductProviderBinding stores one provider account name and remote product ID for that canonical product. A product can have multiple bindings.

CanonicalPrice belongs to a canonical product and stores amount, currency, one-time or recurring type, interval, interval count, description, lookup key, and lifecycle state. Billing terms are immutable after creation. PriceProviderBinding stores provider account and remote price identity.

Canonical subscription and provider bindings

A canonical Subscription belongs to a logical customer and snapshots the accepted canonical price, currency, unit amount, recurring interval, interval count, and quantity. Its local ID and accepted terms remain stable when a price is archived or a provider is attached later. Initial state and period boundaries are explicit, and collection responsibility never proves that payment occurred.

SubscriptionProviderBinding stores the tenant, local subscription ID, provider account name, remote subscription ID, and last provider synchronization time. A provider binding can be added without replacing the canonical ID or rewriting accepted terms. Local reads do not need a binding; provider operations do.

Legacy provider-first product

src/domain/entities/product.entity.ts. Extends TenantScoped, Timestamps.

Purpose: a sellable product that prices attach to.

FieldTypeNotes
idstringLocal identifier.
providerstringBilling provider.
providerProductIdstring | nullProduct id on the provider.
namestringProduct name.
descriptionstring | nullOptional description.
activebooleanWhether the product is sellable.
metadataMetadata | nullOptional string key/value bag.

Relationships: offers many Price records (each references productId).

Legacy provider-first price

src/domain/entities/price.entity.ts. Extends TenantScoped, Timestamps.

Purpose: a specific price (one-off or recurring) for a product.

FieldTypeNotes
idstringLocal identifier.
providerstringBilling provider.
providerPriceIdstring | nullPrice id on the provider.
productIdstringOwning product.
currencyCurrencyCodeCurrency of unitAmount.
unitAmountnumberUnit price, minor units.
intervalRecurringInterval | nullBilling interval; null for one-off prices.
intervalCountnumber | nullNumber of intervals per billing cycle.
activebooleanWhether the price is usable.

Relationships: belongs to one Product; referenced by Subscription.priceId and SubscriptionItem.priceId.

Notes: a recurring price sets both interval and intervalCount; a one-off price leaves both null.

Webhook Event

src/domain/entities/webhook-event.entity.ts. Extends TenantScoped (note: not Timestamps - it carries its own receivedAt/processedAt fields).

Purpose: a received provider webhook, persisted for idempotent processing and reconciliation.

WebhookEventStatus = 'pending' | 'processed' | 'failed'.

FieldTypeNotes
idstringLocal identifier.
providerstringSource provider.
providerEventIdstringEvent id on the provider (used for idempotency).
typestringRaw provider event type.
normalizedTypestring | nullCanonical event type, once mapped.
payloadstringRaw payload string.
dataRecord<string, unknown>Parsed payload.
headersRecord<string, string>Request headers.
statusWebhookEventStatusProcessing status.
correlationIdstringCorrelation id for tracing.
receivedAtDateReceipt instant.
processedAtDate | nullProcessing instant; null until processed.

Invariants (enforced outside the entity): (provider, providerEventId) uniquely identifies an event, supporting idempotent webhook handling. See Value Objects for IdempotencyKey.forWebhook.

Webhook Endpoint

src/domain/entities/webhook-endpoint.entity.ts. Extends TenantScoped, and carries its own createdAt/updatedAt (not the Timestamps mixin).

Purpose: an outbound webhook destination that the engine delivers normalized events to, with its own signing secret and event subscription set.

WebhookEndpointStatus = 'enabled' | 'disabled'.

FieldTypeNotes
idstringLocal identifier.
urlstringDelivery target URL. See WebhookEndpointUrl for the HTTPS-only, non-routable-host validation applied on input.
eventsreadonly string[]Normalized event types this endpoint subscribes to.
secretstringSigning secret used to sign deliveries. See WebhookSigningSecret.
statusWebhookEndpointStatusWhether the endpoint receives deliveries.
createdAtDateCreation instant.
updatedAtDateLast-update instant.

Relationships: receives many WebhookDelivery records (each references endpointId).

Webhook Delivery

src/domain/entities/webhook-delivery.entity.ts. Extends TenantScoped, and carries its own createdAt/updatedAt (not the Timestamps mixin).

Purpose: a single delivery attempt of an event to an endpoint, persisted for observability and reconciliation.

WebhookDeliveryStatus = 'delivered' | 'failed'.

FieldTypeNotes
idstringLocal identifier.
endpointIdstringTarget endpoint.
eventIdstringSource event id being delivered.
eventTypestringNormalized event type delivered.
payloadRecord<string, unknown>Delivered payload.
statusWebhookDeliveryStatusOutcome of the attempt.
attemptsnumberNumber of delivery attempts so far.
responseCodenumber | nullHTTP status returned by the endpoint; null if no response.
responseBodystring | nullCaptured response body; null if none.
createdAtDateCreation instant.
updatedAtDateLast-update instant.

Relationships: belongs to one WebhookEndpoint (required endpointId); references the source event via eventId.

Audit Log

src/domain/entities/audit-log.entity.ts. Extends TenantScoped (carries its own createdAt, not Timestamps).

Purpose: an immutable record of a mutation to a domain resource, for audit and traceability.

FieldTypeNotes
idstringLocal identifier.
correlationIdstringCorrelation id linking related actions.
actorTypestring | nullWho acted (type).
actorIdstring | nullWho acted (id).
actionstringAction performed.
resourceTypestringAffected resource type.
resourceIdstringAffected resource id.
beforeRecord<string, unknown> | nullState before the change.
afterRecord<string, unknown> | nullState after the change.
metadataRecord<string, unknown> | nullExtra context.
ipAddressstring | nullOrigin IP.
userAgentstring | nullOrigin user agent.
createdAtDateWhen the entry was written.

Notes: before/after capture the diff of the audited mutation; correlationId ties the entry to the request and to related webhook events.

Errors

Domain errors live in src/domain/errors/. They all extend PayableError, a structured base built on the native Error.

PayableError

src/domain/errors/payable-error.ts. The base class. Beyond the message, it carries:

MemberTypeNotes
codestringMachine-readable code; defaults to PAYABLE_ERROR.
contextRecord<string, unknown> | undefinedStructured context for the failure.
correlationIdstring | undefinedRequest/trace correlation id.

The constructor takes PayableErrorOptions ({ code?, context?, correlationId?, cause? }); cause is forwarded to the native Error cause. toJSON() serializes { name, code, message, correlationId, context } and redacts any context key matching authorization, password, secret, token, signature, api[-_]?key, cookie, card, cvv, cvc, or pin (replacing the value with [redacted]). The static PayableError.notImplemented(symbol) returns a NOT_IMPLEMENTED-coded error.

Error catalog

Each subclass sets a fixed code and a populated context, and accepts the same PayableErrorOptions.

Error classcodeWhen thrown
PayableErrorPAYABLE_ERRORBase class; used directly for ad-hoc failures and notImplemented (NOT_IMPLEMENTED).
CustomerNotFoundErrorCUSTOMER_NOT_FOUNDA customer lookup by identifier returns nothing. Context: { identifier }.
SubscriptionNotFoundErrorSUBSCRIPTION_NOT_FOUNDA subscription lookup by identifier returns nothing. Context: { identifier }.
IdempotencyConflictErrorIDEMPOTENCY_CONFLICTAn idempotency key is reused with a different request payload. Context: { key }.
IdempotencyInProgressErrorIDEMPOTENCY_IN_PROGRESSAn operation is already running for the same idempotency key. Context: { key }.
InvalidStateTransitionErrorINVALID_STATE_TRANSITIONA state machine rejects a transition from the current state. Context: { machine, from, transition }.
InvalidWebhookSignatureErrorINVALID_WEBHOOK_SIGNATUREA provider webhook fails signature verification. Context: { provider }.
ProviderNotFoundErrorPROVIDER_NOT_FOUNDNo payment provider is registered under the requested name. Context: { provider }.
ProviderCapabilityNotSupportedErrorPROVIDER_CAPABILITY_NOT_SUPPORTEDA provider is asked for a capability it does not implement. Context: { provider, capability }.

The HTTP status each code maps to (for example IDEMPOTENCY_CONFLICT -> 409, PROVIDER_CAPABILITY_NOT_SUPPORTED -> 422) is documented in Troubleshooting.

DTOs

The input/output shapes in src/domain/dtos/ are the boundary types: the plain interfaces that builders and actions accept and return, and that the PaymentProvider contract is defined in terms of. Monetary fields on these DTOs use the Money value object (not raw integers), unlike the persisted entity shapes above. They are re-exported from src/domain/dtos/index.ts.

GroupFileTypesPurpose
Customercustomer.dto.tsCreateCustomerInput, UpdateCustomerInput, CustomerDTOProvision and update a provider customer; return the provider id, email, and name.
Productproduct.dto.tsCreateProductInput, UpdateProductInput, ProductDTOCreate/update a catalog product; return the provider product id, name, and active flag.
Priceprice.dto.tsCreatePriceInput, PriceDTOCreate a one-off or recurring price (unitAmount: Money); return the provider price id and interval.
Checkoutcheckout.dto.tsCheckoutMode, CheckoutLineItem, CreateCheckoutSessionInput, CheckoutSessionDTOOpen a hosted checkout session (payment or subscription mode); return the session id and redirect URL (and optional html).
Chargecharge.dto.tsChargeInput, ChargeResultDTOOne-off charge of a Money amount; return the provider payment id and status.
Refundrefund.dto.tsRefundInput, RefundResultDTORefund a payment (optional partial Money amount); return the provider refund id and status.
Subscriptionsubscription.dto.tsCreateSubscriptionInput, UpdateSubscriptionInput, CancelSubscriptionInput, SubscriptionDTOCreate, update, and cancel a subscription; return status and period/trial end dates.
Invoiceinvoice.dto.tsListInvoicesInput, InvoiceDTO, InvoicePdfDTOList a customer’s invoices and download the PDF bytes.
Billing portalbilling-portal.dto.tsBillingPortalInput, BillingPortalDTOOpen a provider billing portal session; return the portal URL.
Webhookwebhook.dto.tsWebhookVerificationInput, VerifiedWebhookVerify a raw signed webhook and expose the normalized event.
Capabilitiescapabilities.dto.tsProviderCapability, ProviderCapabilityValue, ProviderCapabilitiesThe set of capabilities a provider advertises (a ReadonlySet).
Commoncommon.dto.tsOperationContextThe { correlationId, idempotencyKey?, tenantId? } carried through every provider operation.

Representative shapes:

export interface CreateCustomerInput {
  email: string;
  name?: string;
  billableType: string;
  billableId: string;
  metadata?: Metadata;
}

export interface ChargeInput {
  providerCustomerId?: string;
  amount: Money;
  reference?: string;
  description?: string;
}

export interface OperationContext {
  correlationId: string;
  idempotencyKey?: string;
  tenantId?: string | null;
}

See the source files for the full field lists.