payable

Contracts

Contracts live in src/domain/contracts/ (re-exported from index.ts, plus the separately-exported tenant-resolver.contract.ts). They are the dependency-inversion seams of the engine: the domain layer defines these interfaces, and the infrastructure layer implements them. The dependency rule always points inward - infrastructure depends on the domain contracts, never the reverse - so storage, queue, cache, provider, and cross-cutting concerns are pluggable without the domain knowing the concrete type.

Most repository methods are tenant-scoped: they take a trailing tenantId argument or a tenantId write field. null selects the tenantless partition. Catalog repository reads require an explicit tenant argument; omitting it is not accepted. See Multi-tenancy.

Repositories

Repositories persist and read the entities. Each defines a New<Entity> write shape (an Omit of generated fields like id, createdAt, updatedAt) and tenant-scoped reads. They are grouped behind the Repositories interface and exposed transactionally by StorageDriver (see Drivers). In this repo every repository is implemented by the Knex driver classes in src/infrastructure/storage/knex/repositories/ (KnexCustomerRepository, KnexSubscriptionRepository, and so on).

ContractKey methodsNotes
CustomerRepositorycreate, update, findById, findByBillable, listPersists provider-neutral customers and supports tenant-scoped logical collection queries.
CustomerProviderBindingRepositorycreate, findByCustomerAndProvider, findByProviderId, listByCustomerIdsMaps a logical customer to each registered provider account; every lookup is tenant-scoped through the owning customer.
CustomerProviderSyncStateRepositoryupsert, findByCustomerAndProviderStores the tenant-scoped provider synchronization lifecycle without creating a binding for failed or pending attempts.
SubscriptionRepositorycreate, update, findById, findByName, findByProviderId, listByCustomer, list, pageArray lists accept ListOptions; page backs the canonical collection contract.
SubscriptionItemRepositorycreate, createMany, updatePrimary, listBySubscriptionupdatePrimary patches the primary line via SubscriptionItemPatch.
PaymentRepositorycreate, update, findById, findByIdForUpdate, findByProviderId, listByCustomer, list, pagefindByIdForUpdate takes a row lock; page backs canonical stored-payment reads.
RefundRepositorycreate, update, findById, findByProviderId, listByPaymentScoped to a payment via listByPayment.
InvoiceRepositorycreate, update, findById, findByProviderId, listByCustomer
CanonicalProductRepositorycreate, update, findById, listProvider-neutral products with tenant-scoped deterministic pagination.
CanonicalPriceRepositorycreate, update, findById, findByLookupKey, listProvider-neutral prices with immutable billing terms and tenant-scoped lookup keys.
ProductProviderBindingRepositorycreate, binding lookups, listByProductId, listByProductIdsMaps one canonical product to independent registered provider accounts.
PriceProviderBindingRepositorycreate, binding lookups, listByPriceId, listByPriceIdsMaps one canonical price to independent registered provider accounts.
ProductRepositorycreate, update, findById, findByProviderIdLegacy provider-first compatibility storage.
PriceRepositorycreate, update, findById, findByProviderId, listByProductLegacy provider-first compatibility storage.
WebhookEventRepositorycreate, list, findById, findByProviderEvent, claim, markStatusclaim returns a claim token for exactly-once processing; findByProviderEvent backs idempotent receipt.
WebhookEndpointRepositorycreate, findById, list, listEnabledForEvent, setStatuslistEnabledForEvent resolves delivery targets for a normalized event type.
WebhookDeliveryRepositoryrecord, listForEventAppend-only delivery log.
AuditLogRepositorycreate, list, verifyChain, backfillChainHash-chained; verifyChain/backfillChain operate per tenant. See Reliability.
OutboxEventRepositorycreate, claimPending, markPublished, markFailedBacks the transactional outbox; claimPending(limit) leases rows for the relay.

Audit resource and repository

AuditLogRepository is the storage seam; AuditResource is the application-facing API. The resource binds a trusted tenant, validates custom domain entries, returns cursor pages, and delegates hash-chain verification to the repository.

export interface AuditLogQuery {
  tenantId?: string | null;
  actions?: readonly string[];
  resourceTypes?: readonly string[];
  resourceIds?: readonly string[];
  correlationIds?: readonly string[];
  actorTypes?: readonly string[];
  actorIds?: readonly string[];
  createdAfter?: Date;
  createdBefore?: Date;
  beforeSequence?: number;
  limit?: number;
}

export interface AuditLogRepository {
  create(data: NewAuditLog): Promise<AuditLog>;
  list(query: AuditLogQuery): Promise<AuditLog[]>;
  verifyChain(tenantId: string | null): Promise<boolean>;
  backfillChain(tenantId: string | null): Promise<number>;
}

Fields within one plural filter use OR semantics; separate filter dimensions use AND semantics. Repositories order by the immutable tenant sequence descending. AuditResource converts that sequence into an opaque cursor and returns { data, nextCursor }.

Applications may construct new AuditResource(repositories.auditLogs, tenantId) inside a storage transaction. For transactions that also modify host-owned tables, use the exported KnexAuditLogRepository or PrismaAuditLogRepository over the host transaction. See Custom domain audit.

export interface CustomerRepository {
  create(data: NewCustomer): Promise<Customer>;
  update(id: string, patch: Partial<NewCustomer>, tenantId?: string | null): Promise<Customer>;
  findById(id: string, tenantId?: string | null): Promise<Customer | null>;
  findByBillable(
    billableType: string,
    billableId: string,
    tenantId?: string | null,
  ): Promise<Customer | null>;
  list?(
    query: CustomerListQuery,
    tenantId: string | null,
  ): Promise<CustomerListResult>;
}

export interface CustomerProviderBindingRepository {
  create(data: NewCustomerProviderBinding): Promise<CustomerProviderBinding>;
  findByCustomerAndProvider(
    customerId: string,
    provider: string,
    tenantId: string | null,
  ): Promise<CustomerProviderBinding | null>;
  findByProviderId(
    provider: string,
    providerCustomerId: string,
    tenantId: string | null,
  ): Promise<CustomerProviderBinding | null>;
  listByCustomerIds?(
    customerIds: readonly string[],
    tenantId: string | null,
  ): Promise<CustomerProviderBinding[]>;
}

export interface CustomerProviderSyncStateRepository {
  upsert(data: NewCustomerProviderSyncState): Promise<CustomerProviderSyncState>;
  findByCustomerAndProvider(
    customerId: string,
    provider: string,
    tenantId: string | null,
  ): Promise<CustomerProviderSyncState | null>;
}

CustomerListQuery combines exact id, billableType, and billableId filters with case-insensitive substring email and name filters. Its cursor is the exclusive (createdAt, id) boundary decoded by CustomerResource. Repositories order both fields descending, fetch one row beyond the bounded page, and return { items, hasMore }. CustomerResource converts that result to the provider-neutral { items, nextCursor, hasMore } contract.

The list methods are optional so existing custom storage drivers remain source-compatible. The bundled Knex and Prisma drivers implement both methods. Calling CustomerResource.list() against a custom driver without collection support returns CUSTOMER_LIST_UNSUPPORTED; requesting bindings without batch binding support returns CUSTOMER_BINDING_LIST_UNSUPPORTED.

Provider-neutral collection pages

Canonical customers, products, prices, subscriptions, and stored payments expose the same public page contract:

export interface CollectionPage<T> {
  items: T[];
  nextCursor: string | null;
  hasMore: boolean;
}

The default limit is 25 and the hard maximum is 100. Explicit zero, negative, fractional, non-finite, or larger limits fail with COLLECTION_LIMIT_INVALID before repository execution. Repositories fetch at most limit + 1 rows, order by createdAt DESC, id DESC, and keep the cursor boundary and all filters inside the selected tenant partition.

The versioned cursor is opaque. It is bound to the resource type, tenant, normalized filters, and ordering version. Reusing it for another resource, tenant, or filter set fails with COLLECTION_CURSOR_INVALID. Records inserted after the first page do not move the exclusive continuation boundary, and the local ID tie-breaker prevents equal timestamps from being skipped or duplicated.

Exact and search filters have different semantics:

  • id, tenant IDs, billable identity fields, product IDs, price IDs, statuses, types, currencies, and lookup keys are exact filters.
  • Customer email and name, product name and description, and payment reference and description are case-insensitive substring searches.
  • Subscription name is exact.
  • includeBindings defaults to false. When true, responses expose only binding IDs, registered provider names, remote resource IDs, and synchronization timestamps where applicable. Provider configuration and credentials are never returned.

CustomerRepository.list, CanonicalProductRepository.list, CanonicalPriceRepository.list, SubscriptionRepository.page, and PaymentRepository.page return internal { items, hasMore } results. Their resources encode the final row into nextCursor. Bundled binding repositories also provide batch collection methods so an included binding page does not require one query per item. The new page methods are optional for custom storage-driver source compatibility. A driver without them returns SUBSCRIPTION_PAGE_UNSUPPORTED or PAYMENT_PAGE_UNSUPPORTED from the corresponding canonical resource; existing array methods remain available.

Catalog repositories

Canonical products and prices are partitioned by tenant. Pass a tenant id for a tenant-owned catalog, or null for the tenantless partition. Their stable local IDs do not contain a provider identity. Provider product and price IDs live in separate binding repositories keyed by tenant, canonical resource, and registered provider account name.

CanonicalPricePatch exposes only description, lookup key, and active state. Amount, currency, billing type, interval, and interval count are not update fields. Lookup-key transfer runs in one storage transaction. Both bundled drivers enforce tenant-scoped uniqueness and same-tenant foreign keys for prices and provider bindings.

ProductRepository and PriceRepository remain as the provider-first compatibility persistence path used by providerCatalog(). Their creates require tenantId in the input and their patches cannot move records between tenant partitions.

export interface ProductRepository {
  create(data: NewProduct): Promise<Product>;
  update(id: string, patch: ProductPatch, tenantId: string | null): Promise<Product>;
  findById(id: string, tenantId: string | null): Promise<Product | null>;
  findByProviderId(provider: string, providerProductId: string, tenantId: string | null): Promise<Product | null>;
}

export interface PriceRepository {
  create(data: NewPrice): Promise<Price>;
  update(id: string, patch: PricePatch, tenantId: string | null): Promise<Price>;
  findById(id: string, tenantId: string | null): Promise<Price | null>;
  findByProviderId(provider: string, providerPriceId: string, tenantId: string | null): Promise<Price | null>;
  listByProduct(productId: string, tenantId: string | null): Promise<Price[]>;
}
const product = await storage.products.findById('product-id', 'tenant-a');
const tenantlessProduct = await storage.products.findById('product-id', null);
const prices = await storage.prices.listByProduct('product-id', 'tenant-a');
await storage.products.update('product-id', { name: 'Pro' }, 'tenant-a');

ListOptions

src/domain/contracts/list-options.contract.ts. Cursor pagination shared by the list* repository methods.

export interface ListCursor {
  createdAt: Date;
  id: string;
}

export interface ListOptions {
  limit?: number;
  before?: ListCursor;
}

Drivers

Drivers define infrastructure contracts. The engine consumes the storage, queue, and encryption contracts. Cache and lock contracts remain available for direct composition outside the engine.

ContractKey methodsImplementations in this repo
StorageDriver (extends Repositories)transaction<T>(work: (repos) => Promise<T>) plus all repository accessorsKnexStorageDriver, PrismaStorageDriver
QueueDriverdispatch<T>(job), process<T>(name, handler)SyncQueueDriver, BullMQQueueDriver
CacheDriverget, set, delete, hasMemoryCacheDriver (public, working); RedisCacheDriver (internal, unusable)
LockDriveracquire(key, ttlMs), withLock(key, ttlMs, work)MemoryLockDriver (public, working); RedisLockDriver (internal, unusable)
Encryptionencrypt(plaintext), decrypt(ciphertext)NodeEncryptionDriver

MemoryCacheDriver and MemoryLockDriver can be instantiated and used directly outside createPayable. The Redis classes are internal scaffolds, not external backends available to the engine. Each Redis constructor throws NOT_IMPLEMENTED before cache operations, acquire, or withLock can run.

StorageDriver exposes the full Repositories bag both directly and inside transaction, so a unit of work commits or rolls back atomically:

export interface StorageDriver extends Repositories {
  transaction<T>(work: (repos: Repositories) => Promise<T>): Promise<T>;
}
export interface QueueDriver {
  dispatch<T>(job: QueueJob<T>): Promise<void>;
  process<T>(name: string, handler: JobHandler<T>): void;
}

Provider and cross-cutting seams

PaymentProvider

src/domain/contracts/payment-provider.contract.ts. The provider abstraction. The base interface is intentionally small - every provider must expose name, capabilities(), createCheckoutSession, and refund. Everything else is an optional capability interface that a provider opts into. A is*Capable(provider) type guard accompanies each one, so callers narrow at runtime before invoking an optional method (and raise ProviderCapabilityNotSupportedError otherwise).

export interface PaymentProvider {
  readonly name: string;
  capabilities(): ProviderCapabilities;
  createCheckoutSession(
    input: CreateCheckoutSessionInput,
    ctx: OperationContext,
  ): Promise<CheckoutSessionDTO>;
  refund(input: RefundInput, ctx: OperationContext): Promise<RefundResultDTO>;
}
Capability interfaceMethodsGuard
CustomerCapablecreateCustomer, updateCustomerisCustomerCapable
CatalogCapablecreateProduct, updateProduct, createPriceisCatalogCapable
CatalogReadCapableretrieveProduct, listProducts, retrievePrice, listPricesisCatalogReadCapable
CatalogLifecycleCapablesetProductActive, setPriceActiveisCatalogLifecycleCapable
PriceLookupKeyCapablekeyed createPrice, keyed listPrices, transferPriceLookupKeyisPriceLookupKeyCapable
SubscriptionManagementCapableupdateSubscription, cancelSubscription, resumeSubscriptionisSubscriptionManagementCapable
SubscriptionPauseCapablepauseSubscriptionisSubscriptionPauseCapable
PausedSubscriptionResumeCapableresumePausedSubscriptionisPausedSubscriptionResumeCapable
SubscriptionPaymentCollectionCapablepausePaymentCollection, resumePaymentCollectionisSubscriptionPaymentCollectionCapable
ScheduledSubscriptionChangeCapablecancelScheduledSubscriptionChangeisScheduledSubscriptionChangeCapable
DirectSubscriptionCapablecreateSubscriptionisDirectSubscriptionCapable
ChargeCapablechargeisChargeCapable
InvoiceCapablelistInvoices, downloadInvoicePdfisInvoiceCapable
BillingPortalCapablebillingPortalisBillingPortalCapable
PaymentMethodSetupCapablecreatePaymentMethodSetup, retrievePaymentMethodSetup, cancelPaymentMethodSetupisPaymentMethodSetupCapable
WebhookCapableverifyWebhook, reconcileSubscriptionisWebhookCapable
PaymentWebhookCapablereconcilePaymentisPaymentWebhookCapable
RedirectCallbackCapableverifyCallback, handleRedirectCallbackisRedirectCallbackCapable

Implementations: StripeProvider (charge, direct subscription, invoice, and more), PaddleProvider, and SispProvider (redirect-callback based). See Providers for the capability matrix.

The lifecycle interfaces are intentionally narrow. A provider may implement lifecycle pause without payment-collection pause, or the inverse. The serializable subscription-operation descriptor then declares the precise timings, collection behaviors, scheduled-resume support, and resume billing policies accepted by that implementation. Callers must check both the interface and the descriptor; the application actions enforce both before invoking the provider.

Catalog provider contracts

Catalog capabilities are split so a provider can expose only the operations it supports. Canonical synchronization checks granular product and price create, update, archive, and reactivate capability names together with the corresponding method guard. Retrieval and cursor pagination require catalogRead; the provider-first compatibility API uses catalog and catalogLifecycle.

export interface CatalogCapable {
  createProduct(input: CreateProductInput, ctx: OperationContext): Promise<ProductDTO>;
  updateProduct(input: UpdateProductInput, ctx: OperationContext): Promise<ProductDTO>;
  createPrice(input: CreatePriceInput, ctx: OperationContext): Promise<PriceDTO>;
}

export interface CatalogReadCapable {
  retrieveProduct(id: string): Promise<ProductDTO>;
  listProducts(input?: ListProductsInput): Promise<CatalogPage<ProductDTO>>;
  retrievePrice(id: string): Promise<PriceDTO>;
  listPrices(input?: ListPricesInput): Promise<CatalogPage<PriceDTO>>;
}

export interface CatalogLifecycleCapable {
  setProductActive(id: string, active: boolean, ctx: OperationContext): Promise<ProductDTO>;
  setPriceActive(id: string, active: boolean, ctx: OperationContext): Promise<PriceDTO>;
}

export interface PriceLookupKeyCapable {
  createPrice(input: CreatePriceInput, ctx: OperationContext): Promise<PriceDTO>;
  listPrices(input?: ListPricesInput): Promise<CatalogPage<PriceDTO>>;
  transferPriceLookupKey(
    input: TransferPriceLookupKeyInput,
    ctx: OperationContext,
  ): Promise<PriceDTO>;
}

ListProductsInput accepts limit, cursor, and active. ListPricesInput adds an optional providerProductId filter and, for providers with priceLookupKeys, a lookupKeys filter. Each lookup key has a maximum of 200 Unicode code points, and a lookupKeys request accepts at most 10 keys. The resource layer defaults limit to 50 and active to true, rejects limits outside 1 through 100, and treats CatalogPage.nextCursor as an opaque provider cursor.

Payable rejects non-string, malformed Unicode, empty, whitespace-only, and over-limit lookup keys with PRICE_LOOKUP_KEY_INVALID. It also rejects a lookupKeys value that is not an array or has more than 10 items with the same error. After the capability gate, list({ lookupKeys: [] }) returns an empty page locally without calling the provider.

PriceLookupKeyCapable is optional. The resource checks the priceLookupKeys capability and isPriceLookupKeyCapable before a create with lookupKey or transferLookupKey: true, a list with lookupKeys, or transferLookupKey(...). Ordinary catalog creates and lists remain available to providers that do not support price lookup keys when these fields are absent.

ProductDTO contains provider identity, name, description, active state, and string metadata. PriceDTO contains provider price and product identities, Money, optional recurring terms, description, active state, and a provider-returned lookupKey when available. A lookup key is provider-native routing metadata, not Payable price identity. Payable does not persist it as local price identity. Price monetary terms have no update contract. Create a replacement price and archive the old price when an amount, currency, interval, or interval count changes.

There is no portable delete contract for products or prices. Archival uses setProductActive or setPriceActive with false; activation uses the same methods with true. Missing provider records normalize to PRODUCT_NOT_FOUND or PRICE_NOT_FOUND.

PaymentMethodSetupCapable models saving a payment method without charging it. Its normalized DTO supports provider flows that return a client secret, a hosted checkout URL, or a saved payment method ID. It remains optional, and a provider advertises paymentMethodSetup only after implementing all three lifecycle methods.

TaxProvider

src/domain/contracts/tax-provider.contract.ts. Tax providers use a registry independent from payment and Treasury providers. The base contract exposes only name and capabilities().

Capability interfaceMethodsGuard
TaxCalculationCapablecalculateTax, retrieveTaxCalculationisTaxCalculationCapable
TaxTransactionCapablecommitTaxTransaction, reverseTaxTransactionisTaxTransactionCapable

Tax DTOs use Money for every amount and do not expose vendor SDK types. Applications select an adapter through payable.taxProviders() and narrow it with the matching guard.

IssuingProvider

Issuing providers have an independent registry and optional contracts for cardholders, cards, authorizations, and issuing transactions. Card DTOs expose only last four, expiry, brand, status, and provider identifiers. PAN, CVV, PIN, and track data are outside the domain contract.

Cardholder creation can carry a generic billing address and phone number. Card creation can carry a currency, spending limit, and generic shipping contact. These inputs are optional at the domain boundary because provider requirements differ; an adapter rejects a request before its remote call when a provider-required field is absent.

MarketplaceProvider

Marketplace providers coordinate seller accounts, onboarding, transfers, and payouts through an independent registry. They identify recipients and move funds but do not process customer payments automatically or add connected-account fields to payment DTOs.

AccountingProvider

Accounting providers expose granular contracts for categories, bookkeeping tax-rate metadata, labels, expenses, and ledger entries. These tax rates describe accounting records and do not satisfy TaxProvider calculation or transaction capabilities.

Capability interfaceMethodsGuard
AccountingCategoryCapablecategory create, list, retrieve, update, and deleteisAccountingCategoryCapable
AccountingTaxRateCapablebookkeeping tax-rate create, list, retrieve, update, and deleteisAccountingTaxRateCapable
AccountingLabelCapablelabel create, list, retrieve, update, and deleteisAccountingLabelCapable
AccountingExpenseReadCapablelistAccountingExpenses, retrieveAccountingExpenseisAccountingExpenseReadCapable
AccountingExpenseCapableexpense reads plus updateAccountingExpenseisAccountingExpenseCapable
AccountingLedgerCapableledger-entry list and retrieveisAccountingLedgerCapable

Expense access is deliberately split by behavior. A read-only provider advertises expenseReads and implements AccountingExpenseReadCapable. A provider may advertise expenses only when it also implements updates through AccountingExpenseCapable. This keeps capability sets honest without weakening the existing full expense contract.

IdentityProvider

Identity providers expose verification-session lifecycle operations through an independent registry. Normalized results contain opaque references and status data only; raw documents, images, biometric data, national identifiers, and provider verification reports are excluded from the contracts.

TerminalProvider

Terminal providers expose in-person device discovery and server-driven payment actions through an independent registry. Their DTOs contain device and action identifiers but no card-present secrets, and the contracts do not require browser, mobile, Bluetooth, or hardware SDK dependencies.

EventBus

src/domain/contracts/event-bus.contract.ts. The publish/subscribe seam for domain events.

export interface EventBus {
  listen(name: string, listener: EventListener): Unsubscribe;
  emit(event: DomainEvent): Promise<void>;
}

Implementation: InMemoryEventBus (the default).

Clock

src/domain/contracts/clock.contract.ts. A single now(): Date, so time is injectable and testable.

Implementations: SystemClock (wall clock), FakeClock (test).

Logger

src/domain/contracts/logger.contract.ts. Levelled structured logging: debug, info, warn, error, each (message, context?).

Implementations: ConsoleLogger, NullLogger.

TenantResolver

src/domain/contracts/tenant-resolver.contract.ts. Resolves the tenant for an inbound request (used by webhook receipt). Host-provided - there is no built-in implementation; it is supplied through PayableConfig.

export interface TenantResolver {
  resolve(context: TenantResolutionContext): string | null | Promise<string | null>;
}

IdempotencyStore

src/domain/contracts/idempotency-store.contract.ts. Persists idempotency records and their lifecycle (processing / completed / failed / expired): find, acquire, takeOver, put, markCompleted, markFailed, each tenant-scoped. markCompleted and markFailed also accept optional lockToken? and expiresAt? params. See Idempotency.

Implementation: KnexIdempotencyRepository.

IdempotencyKeyResolver

src/domain/contracts/idempotency-key-resolver.contract.ts. Derives an idempotency key from an operation context: resolve(context): string | null.

Implementation: DefaultIdempotencyKeyResolver.