Invoices and Billing Portal
This page covers reading a customer’s invoices, downloading an invoice PDF, and opening the provider-hosted billing portal. All three depend on optional provider capabilities and degrade or fail explicitly when the provider does not support them.
Canonical local invoices
payable.canonicalInvoices(tenantId) manages Payable-owned invoice records without resolving or
calling a provider. A canonical invoice has a stable local ID, canonical customer and optional
subscription relationships, amounts in minor units, lifecycle status, and optional document
metadata. Provider identifiers are stored in separate bindings and never replace the local ID.
const invoices = payable.canonicalInvoices('tenant-1');
const invoice = await invoices.create({
customerId: customer.id,
subscriptionId: subscription.id,
status: 'open',
currency: 'EUR',
total: 4900,
amountPaid: 0,
amountDue: 4900,
number: 'INV-100',
});
await invoices.attachPayment(invoice.id, payment.id);
await invoices.attachProvider(invoice.id, {
provider: 'stripe-primary',
providerResourceType: 'invoice',
providerResourceId: 'in_100',
});
list() returns the standard bounded canonical page with an opaque cursor. retrieve() and page
items expose stable bindings and paymentIds arrays, including empty arrays for invoices without
relationships. Attach operations validate the same tenant and never infer relationships from
provider IDs, invoice numbers, email addresses, labels, currency, or amount matching.
Canonical reads are available at /canonical/invoices and /canonical/invoices/:id in Express,
Fastify, and Nest, and through canonical_invoices_list and canonical_invoice_get in MCP.
The existing payable.invoices(providerName, tenantId) API remains the provider-native
compatibility path for provider listing and PDF download. PDF retrieval continues to use the
provider invoice ID and its existing ownership and capability checks. Stripe invoice IDs and Paddle
transaction IDs are binding metadata, not canonical route identities.
The forward migration copies legacy invoice rows without changing their local IDs or values and
creates a provider binding only when both provider and provider invoice ID are present. Incomplete
provider identities remain as canonical invoices and are reported as unresolved. A customer must
be proven in the same tenant before its invoice is canonicalized; an unproven optional subscription
is left unmapped. Conflicting provider resources are reported and skipped instead of aborting the
migration. Stripe resources retain the invoice type while Paddle transaction IDs use
transaction. Payment relationships are never guessed during backfill.
The forward-only ledger runs invoice migration as step 020-canonical-invoices, after local
payment evidence step 019. It stores the structured JSON result in
payable_migration_reports under the step name. Operators should inspect that row after an
upgrade and resolve non-zero orphan, mismatch, conflict, unresolved, or unmapped counters before
any later schema contraction. Replaying the migration is additive and cannot restore a dropped
legacy field, so database snapshots remain the rollback boundary.
Listing invoices
ListInvoicesAction returns the customer’s invoices from the provider.
const invoices = await new ListInvoicesAction(deps).handle(billable, 50);
handle(billable, limit?):
- Validates
limit: when supplied it must be a positive integer, otherwise throwsPayableError(INVOICE_LIMIT_INVALID). This runs before the capability check. - Requires the provider to be invoice capable (
isInvoiceCapable, i.e. it implements bothlistInvoicesanddownloadInvoicePdf); otherwise throwsProviderCapabilityNotSupportedError(reported as theinvoicePdfcapability). - If there is no storage driver, returns
[]. - Loads the logical customer and its binding for the selected registered provider; if either is
missing, returns
[]. - Calls
provider.listInvoices({ providerCustomerId: binding.providerCustomerId, limit }).
Output: InvoiceDTO[]:
export interface InvoiceDTO {
providerInvoiceId: string;
status: InvoiceStatus;
total: Money;
hostedInvoiceUrl: string | null;
invoicePdf: string | null;
}
total is a Money value object; hostedInvoiceUrl and invoicePdf are provider-hosted links when
available.
Downloading an invoice PDF
DownloadInvoicePdfAction fetches the raw PDF bytes for one invoice.
const pdf = await new DownloadInvoicePdfAction(deps).handle('in_1', billable);
// pdf.filename -> 'in_1.pdf', pdf.content -> Uint8Array
handle(providerInvoiceId, billable?):
- Requires the provider to be invoice capable; otherwise throws
ProviderCapabilityNotSupportedError(reported asinvoicePdf). - Requires a storage driver; otherwise throws
PayableError(INVOICE_STORAGE_REQUIRED). - Loads the invoice by provider id (
storage.invoices.findByProviderId). It then requires the caller to own the invoice viabelongsToBillable: when the invoice is missing, whenbillableis omitted, or when the suppliedbillabledoes not own it, throwsPayableError(INVOICE_NOT_FOUND). - Calls
provider.downloadInvoicePdf(providerInvoiceId).
Output: InvoicePdfDTO:
export interface InvoicePdfDTO {
filename: string;
content: Uint8Array;
}
Ownership check. billable is effectively required. When provided, the action resolves the
caller’s local customer (storage.customers.findByBillable) and confirms it owns the invoice
(customer.id === invoice.customerId) before returning the bytes - a mismatch is reported as
INVOICE_NOT_FOUND. When billable is omitted, belongsToBillable returns false, so the action
always throws INVOICE_NOT_FOUND; there is no trust-the-caller path that returns bytes.
Note: the omitted-
billablepath currently throws rather than returning bytes; this is under review as a possible regression of the trust-caller path.
Billing portal
payable.customer(billable).billingPortal(returnUrl) returns a provider-hosted portal URL where the
customer can manage payment methods, invoices, and subscriptions.
const { url } = await payable
.customer(billable)
.billingPortal('https://app.test/account');
return redirect(url);
billingPortal(returnUrl):
- Asserts the provider’s
billingPortalcapability viaassertProviderCapability. - Syncs the customer to the provider (
SyncCustomerWithProviderAction) to obtain theproviderCustomerId. - Builds an idempotency key
portal:${providerName}:${billableType}:${billableId}. - Calls
provider.billingPortal({ providerCustomerId, returnUrl }, ctx).
Output: BillingPortalDTO:
export interface BillingPortalDTO {
url: string;
}
sequenceDiagram
participant App
participant Ctx as CustomerContext
participant Sync as SyncCustomerWithProviderAction
participant Provider
App->>Ctx: billingPortal(returnUrl)
Ctx->>Ctx: assert billingPortal capability
Ctx->>Sync: handle(billable)
Sync-->>Ctx: providerCustomerId
Ctx->>Provider: billingPortal({ providerCustomerId, returnUrl }, ctx)
Provider-->>App: BillingPortalDTO { url }
Provider dependency and capabilities
These features ride on optional provider methods declared as capability interfaces on the
PaymentProvider contract:
- Invoices.
InvoiceCapable(listInvoices,downloadInvoicePdf). Detected withisInvoiceCapable. The capability surfaced in errors isinvoicePdf. - Billing portal.
billingPortalis a required method on thePaymentProvidercontract, but its availability is gated by thebillingPortalcapability flag, asserted before use.
The ProviderCapabilities flags (checkout, subscriptions, trials, refunds, coupons,
billingPortal, meteredBilling, invoicePdf) let the application probe support before calling -
see 17-providers.
Inputs and outputs
| Operation | Input | Output |
|---|---|---|
| List invoices | Billable, optional limit | InvoiceDTO[] |
| Download PDF | providerInvoiceId, optional billable | InvoicePdfDTO ({ filename, content }) |
| Billing portal | returnUrl | BillingPortalDTO ({ url }) |
Edge cases
- Provider lacks invoice capability. Both invoice actions throw
ProviderCapabilityNotSupportedError(invoicePdf). - Provider lacks the billing-portal capability.
billingPortal()throws viaassertProviderCapabilitybefore any sync or provider call. - No storage driver (invoices).
ListInvoicesActionreturns[]instead of throwing. - No logical customer / no binding for the selected provider (invoices). Returns
[]. - Billing portal without storage. The portal still syncs the customer via the provider, which
requires no storage to obtain a
providerCustomerId, but nothing is persisted - see 08-customers-billable. - PDF ownership. Pass
billabletoDownloadInvoicePdfAction.handleto verify the invoice belongs to that caller (mismatch ->INVOICE_NOT_FOUND). Omitting it is not a trust-the-caller path: with nobillable, ownership cannot be confirmed and the action always throwsINVOICE_NOT_FOUND. - Invalid list limit.
ListInvoicesActionthrowsINVOICE_LIMIT_INVALIDfor a non-positive-integerlimit, before the capability check. - PDF without storage.
DownloadInvoicePdfActionthrowsINVOICE_STORAGE_REQUIREDwhen no storage driver is configured.