payable

Payment Providers

Every payment integration in @akira-io/payable is reduced to a single interface: PaymentProvider. The engine never talks to Stripe or Paddle directly. It talks to the contract, and concrete adapters translate domain DTOs into provider SDK or API calls and provider webhooks back into domain events. This keeps the application and domain layers provider-agnostic and makes a new integration a matter of implementing one interface.

The contract lives in src/domain/contracts/payment-provider.contract.ts.

The PaymentProvider contract

PaymentProvider is deliberately small. It declares only the surface that every provider can honour, regardless of its business model. A subscription/SaaS gateway (Stripe, Paddle) and a one-time hosted-redirect gateway (SISP) both satisfy it. All methods that reach a provider API are asynchronous and receive an OperationContext (ctx) carrying the idempotency key.

MemberInputOutput
name (readonly property)-string - the registry key, e.g. 'stripe'
capabilities()-ProviderCapabilities
createCheckoutSession(input, ctx)CreateCheckoutSessionInputPromise<CheckoutSessionDTO>
refund(input, ctx)RefundInputPromise<RefundResultDTO>

Everything else - customers, catalog, subscription management, webhooks, the billing portal - is an optional capability interface. A provider implements only the ones it genuinely supports, and the engine narrows to them at runtime with a type guard before calling. This is why SISP (which has no customers API, no catalog, no subscriptions, and no asynchronous signed webhook) can be a first-class provider: it implements the slim core plus the one optional interface that fits its redirect flow.

Optional capability interfaces

Catalog capability interfaces are declared in src/domain/contracts/catalog-provider.contract.ts. src/domain/contracts/payment-provider.contract.ts retains their type and guard re-exports for compatibility. The remaining optional interfaces are declared in src/domain/contracts/payment-provider.contract.ts. Each interface ships with a structural isXCapable guard (duck-typing on method presence). Calling code narrows first, then either calls the method or throws ProviderCapabilityNotSupportedError.

InterfaceMethod(s)Guard
CustomerCapablecreateCustomer(input, ctx), updateCustomer(input, ctx)isCustomerCapable(provider)
CatalogCapablecreateProduct, updateProduct, createPriceisCatalogCapable(provider)
CatalogProductCreateCapablecreateProductisCatalogProductCreateCapable(provider)
CatalogProductUpdateCapableupdateProductisCatalogProductUpdateCapable(provider)
CatalogPriceCreateCapablecreatePriceisCatalogPriceCreateCapable(provider)
CatalogPriceUpdateCapableupdatePriceisCatalogPriceUpdateCapable(provider)
CatalogReadCapableretrieveProduct, listProducts, retrievePrice, listPricesisCatalogReadCapable(provider)
CatalogLifecycleCapablesetProductActive, setPriceActiveisCatalogLifecycleCapable(provider)
PriceLookupKeyCapablekeyed createPrice, keyed listPrices, transferPriceLookupKeyisPriceLookupKeyCapable(provider)
SubscriptionManagementCapableupdateSubscription, cancelSubscription, resumeSubscriptionisSubscriptionManagementCapable(provider)
WebhookCapableverifyWebhook(input), reconcileSubscription(verified)isWebhookCapable(provider)
PaymentWebhookCapablereconcilePayment(verified)isPaymentWebhookCapable(provider)
BillingPortalCapablebillingPortal(input, ctx)isBillingPortalCapable(provider)
RedirectCallbackCapableverifyCallback(payload), handleRedirectCallback(payload)isRedirectCallbackCapable(provider)
ChargeCapablecharge(input, ctx)isChargeCapable(provider)
DirectSubscriptionCapablecreateSubscription(input, ctx)isDirectSubscriptionCapable(provider)
InvoiceCapablelistInvoices(input), downloadInvoicePdf(id)isInvoiceCapable(provider)
PaymentMethodCapablelistPaymentMethods(input), deletePaymentMethod(input, ctx)isPaymentMethodCapable(provider)
PaymentMethodSetupCapablecreatePaymentMethodSetup(input, ctx), retrievePaymentMethodSetup(id), cancelPaymentMethodSetup(id, ctx)isPaymentMethodSetupCapable(provider)
DisputeCapablelistDisputes(input), retrieveDispute(id), acceptDispute(id, ctx)isDisputeCapable(provider)
PayoutCapablelistPayouts(input), retrievePayout(id)isPayoutCapable(provider)
ProviderWebhookEndpointManagementCapableprovider webhook endpoint CRUD with bounded listingisProviderWebhookEndpointManagementCapable(provider)

Notes on the non-obvious members:

  • verifyWebhook (on WebhookCapable) takes no ctx. Its input is { payload, signature, headers? } and it returns a VerifiedWebhook with providerEventId, raw type, the engine’s normalizedType, and the event data. A failed signature throws InvalidWebhookSignatureError.
  • reconcileSubscription is synchronous and pure. Given an already-verified webhook it returns a SubscriptionDTO when the normalized type starts with subscription., otherwise null.
  • PaymentWebhookCapable is separate from WebhookCapable. It lets hosted-checkout providers map an already-verified payment webhook to { providerPaymentId, status } without forcing every webhook-capable provider to implement payment reconciliation.
  • PaymentMethodSetupCapable manages the setup lifecycle independently from charging. Its normalized result can expose a client secret, a hosted checkout URL, or the resulting provider payment method ID without exposing vendor SDK types.
  • RedirectCallbackCapable models a synchronous browser-POST callback (SISP), not an asynchronous signed webhook. handleRedirectCallback returns a normalized { providerPaymentId, status } the engine uses to reconcile a local payment. See SISP.

Narrowing helper

For capabilities that are also declared in the ProviderCapabilities set and backed by optional interfaces, the engine uses assertCapableProvider (src/application/services/provider-capabilities/assert-provider-capability.ts), which checks the set and narrows the type in one step:

assertCapableProvider(provider, 'customers', isCustomerCapable);
// provider is now PaymentProvider & CustomerCapable

Some provider features are represented both ways: a known capability string for honest feature advertising and an optional interface for the callable methods. Examples include customers (CustomerCapable), invoicePdf (InvoiceCapable), charges (ChargeCapable), and webhooks (WebhookCapable). Catalog creation, reads, and lifecycle changes use catalog, catalogRead, and catalogLifecycle with their matching structural guards. Redirect callbacks remain guard-only because they model a provider-specific browser callback flow, not an asynchronous provider webhook.

PriceLookupKeyCapable is narrower than the ordinary catalog capabilities. It supports a create with lookupKey, create-time transferLookupKey: true, list filtering with lookupKeys, and explicit prices().transferLookupKey({ providerPriceId, lookupKey }, options). The engine gates those calls with priceLookupKeys and isPriceLookupKeyCapable; normal catalog operations do not require it.

Capability matrix

The matrix describes support implemented by each built-in Payable adapter, not every feature offered by the external provider. A no cell only means that this adapter does not expose the operation.

CapabilityStripePaddleSISPRevolut
checkoutyesyesyes (redirect form)yes (amount order, subscription setup order)
refundsyesyesnoyes (amount required)
customersyesyesno (local-only customers)yes
catalogyesyesnono
catalogReadyesyesnono
catalogLifecycleyesyesnono
catalogIdempotencyyesnonono
catalogProductCreateyesyesnono
catalogProductUpdateyesyesnono
catalogProductArchiveyesyesnono
catalogProductReactivateyesyesnono
catalogPriceCreateyesyesnono
catalogPriceUpdateyesyesnono
catalogPriceArchiveyesyesnono
catalogPriceReactivateyesyesnono
priceLookupKeysyesnonono
subscriptionsyesyesnoyes (limited)
trialsyesnonono
couponsyesnonono
billingPortalyesyesnono
webhooks (WebhookCapable)yesyesno (uses redirect callback)yes
PaymentWebhookCapableyesnonoyes
RedirectCallbackCapablenonoyesno
charges (ChargeCapable)yesnonono
invoicePdf (InvoiceCapable)yesnonono
paymentMethods (PaymentMethodCapable)yesnonoyes
paymentMethodSetup (PaymentMethodSetupCapable)yesnonoyes
disputes (DisputeCapable)yesnonoyes (production only)
payouts (PayoutCapable)yesnonoyes
webhookEndpointManagementyesnonoyes

Canonical catalogue synchronization uses the granular rows rather than inferring one operation from another. catalogRead supports reconciliation and catalogIdempotency enables safe automatic retry.

See Catalog Lifecycle for explicit synchronization, retry, and reconciliation examples. Provider API background: Stripe Products, Stripe Prices, Stripe idempotency, Stripe webhooks, Paddle Products, Paddle Prices, and Paddle notifications.

The capabilities system

A provider declares the feature set it supports through capabilities(), which returns a ProviderCapabilities (src/domain/dtos/capabilities.dto.ts):

export type ProviderCapability =
  | 'checkout'
  | 'charges'
  | 'subscriptions'
  | 'trials'
  | 'refunds'
  | 'coupons'
  | 'billingPortal'
  | 'meteredBilling'
  | 'invoicePdf'
  | 'webhooks'
  | 'customers'
  | 'paymentMethods'
  | 'paymentMethodSetup'
  | 'disputes'
  | 'payouts'
  | 'webhookEndpointManagement'
  | 'catalog'
  | 'catalogRead'
  | 'catalogLifecycle'
  | 'priceLookupKeys';

export type ProviderCapabilityValue = ProviderCapability | (string & {});

export type ProviderCapabilities = ReadonlySet<ProviderCapabilityValue>;

It is a set, not a fixed matrix. A provider lists only what it supports; an absent capability means unsupported (opt-in by presence). ProviderCapabilityValue is the union of known capabilities plus an open string arm, so a provider may declare custom capabilities the core does not know about (for example 'x-acme-dunning') while the known names keep autocomplete. Adding a new core capability is a new union member, not a new required field, so it does not break existing custom providers.

Subscription operation capabilities

The coarse subscriptions capability says that a provider participates in subscription workflows. It does not imply that every lifecycle operation or policy is available. Providers can implement the optional SubscriptionOperationCapabilitiesProvider contract to publish a serializable descriptor:

const operations = payable
  .providers()
  .subscriptionOperationCapabilities('stripe');

operations.create.direct;
operations.itemIdentity;
operations.changePrice.effectiveTimings;
operations.cancel.atPeriodEnd;

The descriptor separates creation, price changes, quantity changes, cancellation, pause, and resume. Change capabilities list supported effective timings, proration policies, and payment-failure policies. Pause and resume capabilities list scheduling and billing-cycle behavior. The returned snapshot and its policy arrays are immutable.

itemIdentity is stable for Stripe, price for Paddle, and none for providers that do not expose addressable subscription items. This lets consumers require an explicit local item and avoid assuming that the first provider item is primary.

Subscription operationStripePaddleSISPRevolut
Hosted checkout creationyesyesnoyes
Direct creationyesnonoyes
Price change timingimmediateimmediatenonext renewal
Price change prorationimmediate, next invoice, noneimmediate, next invoice, full charge immediately, full charge at next renewal, nonenonone
Price change payment failureprevent change, apply changeprevent change, apply changenoapply change
Quantity changeimmediateimmediatenono
Preview changeyesyesnoyes
Cancel immediatelyyesyesnoyes
Cancel at period endyesyesnono
Pause subscription timingnoimmediate, next renewalnono
Pause scheduled resumenoyesnono
Pause resume billing policynonew billing period, existing billing periodnono
Pause payment collectionkeep as draft, mark uncollectible, voidnonono
Payment collection scheduled resumeyesnonono
Resume pending cancellationyesnonono
Resume paused subscription timingnoimmediate, schedulednono
Resume paused subscription billingnonew billing period, existing billing periodnono
Resume payment collectionyesnonono
Cancel scheduled changenoyesnono

This matrix describes the current Payable adapters, not every feature offered by the external providers. Lifecycle pause and payment-collection pause are deliberately separate operations. Stripe payment-collection pause leaves the subscription lifecycle status unchanged; Paddle lifecycle pause changes the subscription state. SISP and Revolut advertise neither operation.

Stripe and Paddle return provider-calculated monetary previews. Revolut has no monetary change-plan preview endpoint, so Payable returns a structural next-renewal preview with unknown amounts as null. Apply always reuses the exact stored preview input and provider calculation timestamp.

Built-in providers publish explicit descriptors. A custom provider can add the optional method without changing its existing capabilities() implementation. For legacy providers that do not implement the method, registry discovery returns a conservative creation-only descriptor inferred from the coarse capability and direct-creation interface. Existing legacy operations are not blocked by granular assertions, which preserves compatibility while integrations migrate to explicit descriptors.

The coarse subscriptions value remains supported for family-level discovery and existing guards, but it is deprecated as a source for operation-level decisions. Migration is additive: implement subscriptionOperationCapabilities(), move user-interface and workflow checks to the descriptor, then retain subscriptions while supporting current Payable releases. No removal release is scheduled.

Before a built-in provider call, Payable asserts the requested operation and throws ProviderCapabilityNotSupportedError with a stable capability name such as subscriptions.change-quantity or subscriptions.cancel.at-period-end. The assertion runs before customer synchronization or other provider side effects.

This is distinct from the optional interfaces above. The interfaces answer “does this method exist?”; ProviderCapabilities answers “does the provider claim to support this feature?”. The engine guards a declared capability with assertProviderCapability (src/application/services/provider-capabilities/assert-provider-capability.ts):

export function assertProviderCapability(
  provider: PaymentProvider,
  capability: ProviderCapabilityValue,
): void {
  if (!provider.capabilities().has(capability)) {
    throw new ProviderCapabilityNotSupportedError(provider.name, capability);
  }
}

When the capability is absent from the set, it throws ProviderCapabilityNotSupportedError (src/domain/errors/provider-capability-not-supported.error.ts) with code PROVIDER_CAPABILITY_NOT_SUPPORTED and a message of the form Provider '<name>' does not support capability: <capability>. The error context carries { provider, capability }.

Tax providers

Tax calculation and transaction recording use the independent TaxProvider family. Configure adapters through taxProviders and retrieve them with payable.taxProviders(). The registry is empty when omitted and throws TaxProviderNotFoundError for unknown names.

TaxCalculationCapable calculates and retrieves normalized tax calculations. TaxTransactionCapable commits and reverses tax transactions. Tax providers are not payment providers, and no tax adapter is registered automatically.

Issuing providers

Card issuing uses the independent IssuingProvider family and issuingProviders configuration. Capabilities cover cardholders, cards, authorizations, and issuing transactions independently. Every operation requires its matching structural guard, and no built-in adapter is registered by default.

Normalized card DTOs expose only non-sensitive display metadata. Full card numbers, CVV, PIN, track data, and provider secrets are excluded from the contracts.

Marketplace providers

Seller account coordination uses MarketplaceProvider and the optional marketplaceProviders configuration. Accounts, onboarding, transfers, and payouts are separate capabilities with complete runtime guards. Payment acceptance remains the responsibility of payment providers.

  • Purpose: fail fast and explicitly before reaching the provider API for an unsupported operation.
  • Edge case: a provider may also throw ProviderCapabilityNotSupportedError from inside a method for a partial limitation. Paddle does this for partial refunds (see the Paddle integration page).

Known capabilities gate their matching resource operations before Payable calls a provider:

  • customers guards payable.customers().create(...) and .update(...). A read with .get(...) comes from local storage and is not gated. Provider-backed customer sync also requires the customers capability before calling createCustomer or updateCustomer. If a stored customer has a provider customer id and the provider declares customers, update requires the full CustomerCapable interface instead of silently falling back to local-only changes.
  • catalog guards payable.providerCatalog().products.create(...), payable.providerCatalog().products.update(...), and payable.providerCatalog().prices.create(...).
  • catalogRead guards product and price retrieve(...) and list(...) operations.
  • catalogLifecycle guards product and price activate(...) and archive(...) operations.
  • priceLookupKeys guards price creates carrying lookupKey or transferLookupKey: true, price lists carrying lookupKeys, and prices().transferLookupKey(...). Providers without an official equivalent do not advertise it.
  • subscriptions guards subscription management. Direct subscription creation also requires this declared capability before storage or provider calls, but a provider may still omit DirectSubscriptionCapable and support subscription creation only through checkout.
  • charges guards direct charge creation before the provider is called.
  • invoicePdf guards invoice listing and PDF download before invoice provider methods are used.
  • webhooks guards webhook receipt before signature verification is delegated to the provider. Replay and subscription reconciliation also treat providers without this declared capability as stored-event-only, even if a provider object happens to expose webhook-shaped methods.

A provider whose set omits a required capability rejects the corresponding operation with PROVIDER_CAPABILITY_NOT_SUPPORTED (HTTP 422) before any network call.

The provider registry

ProviderRegistry (src/payable.ts) is a thin Map<string, PaymentProvider> wrapper:

MethodBehavior
register(name, provider)Stores a provider under name.
get(name)Returns the provider, or throws ProviderNotFoundError (PROVIDER_NOT_FOUND) when absent.
has(name)true when a provider is registered under name.
names()Registered provider names, in insertion order.
subscriptionOperationCapabilities(name)Returns an immutable granular subscription descriptor or a conservative legacy fallback.

The registry is built from the resolved config and exposed via payable.providers().

Provider selection and ambiguity

Selection rules:

  • Passing a name targets it explicitly: payable.customer(billable, 'secondary') routes to the secondary provider.
  • Omitting the name defaults to the first registered provider: names()[0].
  • An unknown name throws ProviderNotFoundError.

Webhook routing has a stricter ambiguity rule (Payable.defaultWebhookProvider in src/payable.ts): when more than one provider is registered and the incoming webhook does not name a provider, the engine throws PayableError with code WEBHOOK_PROVIDER_AMBIGUOUS and the message Multiple providers are registered; route the webhook to /webhooks/:provider. With a single provider it is inferred.

flowchart TD
  Engine[Payable engine] --> Registry[ProviderRegistry]
  Registry -->|get name| Contract[PaymentProvider contract]
  Contract -. implemented by .-> Stripe[StripeProvider]
  Contract -. implemented by .-> Paddle[PaddleProvider]
  Contract -. implemented by .-> Sisp[SispProvider]
  Contract -. implemented by .-> Custom[Your provider]
  Stripe --> StripeAPI[Stripe SDK]
  Paddle --> PaddleAPI[Paddle SDK]
  Sisp --> SispPkg["@akira-io/sisp (node-sisp)"]
  Revolut --> RevolutAPI[Revolut Merchant API]

Implementing a custom provider

A minimal provider implements the slim PaymentProvider core, declares its name and capabilities(), and adds only the optional interfaces it genuinely supports. The implements list and the capabilities() set must agree.

import type {
  PaymentProvider,
  CustomerCapable,
  ChargeCapable,
} from '@akira-io/payable';

export class AcmeProvider implements PaymentProvider, CustomerCapable, ChargeCapable {
  readonly name = 'acme';

  capabilities() {
    return new Set(['checkout', 'charges', 'refunds', 'customers', 'x-acme-dunning']);
  }

  async createCheckoutSession(input, ctx) { /* ...map to Acme hosted checkout... */ }
  async refund(input, ctx) { /* ...map to Acme refund... */ }

  // CustomerCapable
  async createCustomer(input, ctx) {
    const customer = await this.api.createCustomer(input.email, ctx.idempotencyKey);
    return { providerCustomerId: customer.id, email: customer.email, name: customer.name ?? null };
  }
  async updateCustomer(input, ctx) { /* ... */ }

  // ChargeCapable
  async charge(input, ctx) {
    const charge = await this.api.charge(input.amount.amount(), ctx.idempotencyKey);
    return { providerPaymentId: charge.id, status: 'succeeded', amount: input.amount };
  }
}

Constraints to honour:

  • name must be a valid ProviderName (src/domain/value-objects/provider-name.ts): lower-case, matching ^[a-z][a-z0-9_-]*$. It is also the registry key callers pass to customer(billable, name).
  • Implement createCheckoutSession and refund (the required core) plus exactly the optional interfaces your capabilities() set claims. A guard narrows on method presence, so a declared capability whose method is missing fails at call time.
  • verifyWebhook (if WebhookCapable) must throw InvalidWebhookSignatureError (not a generic error) on a bad signature so the webhook pipeline can reject it cleanly.
  • reconcileSubscription should return null for non-subscription events.
  • PaymentWebhookCapable.reconcilePayment should return null for non-payment events and should only return statuses representable by the domain PaymentStatus value object.
  • Keep capabilities() honest. The engine trusts it to gate features; lying produces failures at the provider API instead of a clean ProviderCapabilityNotSupportedError.

Register it like any built-in provider through the engine config ({ providers: { acme: new AcmeProvider(...) } }).

Accounting integrations use the independent accountingProviders config and payable.accountingProviders() registry. Their tax-rate metadata is for bookkeeping only and remains separate from tax calculation providers. Categories, tax rates, labels, expense reads, full expense management, and ledger access are advertised independently.

AccountingExpenseReadCapable is the normalized list and retrieve surface and uses the expenseReads capability. AccountingExpenseCapable extends it with updates and requires expenses. A provider that can only read expenses must not advertise the full capability. Applications should narrow with isAccountingExpenseReadCapable when they do not need mutation.

Identity integrations use the independent identityProviders config and payable.identityProviders() registry. Applications remain responsible for consent, retention, access control, and legal compliance; provider adapters must not return raw identity evidence through the normalized contracts.

Terminal integrations use the independent terminalProviders config and payable.terminalProviders() registry. They discover devices and coordinate server-driven terminal actions without extending PaymentProvider or adding device SDK dependencies to the core package.