Create your own
Lesson illustration

Billing Provider Contract Design: Quotes, Subscriptions, Invoices, and Authoritative Snapshots

Good to see the local subscription model now has durable lifecycle and billing-period records. In the previous lesson, you deliberately kept provider references separate from local state, with immutable catalog versions and an audit trail ready for external events.

This module moves across that boundary. A billing provider is not simply an SDK hidden behind a service: it is an external system with different identifiers, tax rules, asynchronous behavior, retries, and uneven feature support. Your goal is to define one contract that the rest of the NestJS subscription module can depend on whether the active adapter is Stripe, Paddle, or a deterministic fake.

By the end of this lesson, you will have a TypeScript contract for provider customers, checkout, quotes, subscription changes, cancellation, invoices, snapshots, idempotency, capabilities, and errors. The next lesson will implement a fake adapter against this contract.


1. Keep the provider boundary narrow and explicit

Your local subscription aggregate remains the source for product decisions:

  • Which local plan and immutable price versions an owner selected.
  • Whether an actor may purchase or change a subscription for an individual or organization.
  • Whether a requested seat quantity is valid for the host application.
  • Whether a local state-machine transition is permitted.
  • Whether access is currently granted.

The billing provider is authoritative for payment execution and its own recurring agreement:

  • Its customer and subscription identifiers.
  • Tax, discounts, proration, and the payment total it will actually collect.
  • Hosted checkout or payment-action URLs.
  • Invoice documents and payment status.
  • Its current view of an external subscription.

That division avoids two common mistakes:

  1. Leaking provider SDK types into controllers, entities, and Angular DTOs. A Stripe.Subscription or a Paddle transaction should never become the shared model of your application.
  2. Treating a provider response as a local entitlement decision. A checkout session can be created successfully while payment is incomplete. Paid access still waits for confirmed provider state.

Think of the adapter as an anti-corruption layer. It translates provider-specific vocabulary into deliberately small, stable types.

Local identifiers versus provider identifiers

Do not use provider IDs as local domain IDs. Keep both:

ConceptExampleOwner
Local subscription ID7bb2... UUIDYour PostgreSQL database
Local plan version ID5d9e... UUIDYour catalog
Provider namestripe, paddle, fakeAdapter registry
Provider customer referencecus_...Provider
Provider subscription referencesub_...Provider
Provider invoice referenceProvider-specific opaque stringProvider

Provider references are opaque strings. The generic module may persist and pass them back to the adapter, but it must not parse prefixes such as cus_ or assume length, format, or ordering.


2. Model capabilities before defining operations

Providers differ materially. One may support a hosted checkout page and reversal of a scheduled cancellation; another may support neither. One may produce trustworthy, monotonic subscription versions; another may force you to retrieve a current snapshot when events conflict.

Do not discover those differences from if (adapter.reverseCancellation) checks scattered through command services. Make them a declared part of the contract.

export interface BillingProviderCapabilities {
  readonly idempotencyKeys: boolean;

  readonly hostedCheckout: boolean;
  readonly embeddedCheckout: boolean;
  readonly customerPortal: boolean;

  readonly subscriptionQuotes: boolean;
  readonly immediateSubscriptionChanges: boolean;
  readonly scheduledSubscriptionChanges: boolean;

  readonly cancelAtPeriodEnd: boolean;
  readonly reversePendingCancellation: boolean;

  readonly invoicePdf: boolean;
  readonly authoritativeSubscriptionSnapshots: boolean;

  /**
   * When true, a non-null providerVersion is comparable for one
   * provider subscription: a larger value represents newer state.
   */
  readonly monotonicSubscriptionVersions: boolean;
}

Capabilities are not authorization. For example:

  • scheduledSubscriptionChanges: true means the provider can schedule a change.
  • Your own service must still decide whether a downgrade should be scheduled.
  • The adapter must still reject a scheduled-change request if a specific offering or provider account does not allow it.

A simple provider registry can expose the active adapter and its capabilities at application startup. The UI can use the same capabilities to hide impossible actions, but the server must enforce them as well.

export interface BillingProviderRegistry {
  get(providerName: string): BillingProvider;
  getDefault(): BillingProvider;
}

Use stable adapter keys such as stripe, paddle, and fake; do not use mutable display labels.


3. Treat idempotency as an operation identity, not a retry detail

Checkout, quantity increases, plan changes, and cancellation requests can all be submitted twice because a user double-clicks, an Angular request times out, a NestJS process restarts after calling the provider, or a worker retries after an uncertain response.

Watch the following segment before designing the mutation context. It focuses on the important property: a retry must carry the same identifier as the original attempt.

Idempotency - What it is and How to Implement it

Watch “Idempotency - What it is and How to Implement it” by Alex Hyett to connect retries with a durable operation identifier rather than request timing.

Watch stable operation keys. Focus on why hashing a payment-like payload is insufficient to distinguish a genuine second purchase from a duplicate submission, and why idempotency data must survive process restarts and multiple application instances.

The key rule

Generate an idempotency key once per intended local operation, persist it, and reuse it for every attempt to carry out that exact operation at the provider.

For example, when an organization admin requests a quantity increase:

ValueExample
Local operation IDop_0aa5...
Operation kindsubscription_change
Provider idempotency keybill:op_0aa5...:change
Initial requested quantity25
Retry requested quantitystill 25
A later, new request for 30 seatsa new operation and a new key

Do not generate a new idempotency key inside every adapter invocation. That would make retries indistinguishable from new actions.

Also do not derive a key solely from owner, plan, or payload. An organization may legitimately buy the same plan twice at different times, or make two separate seat increases to the same quantity. The persisted local operation ID represents intent; it is the correct root identity.

Define a shared mutation context:

export interface ProviderMutationContext {
  /**
   * Your durable operation ID. It is safe to store in provider metadata.
   */
  readonly operationId: string;

  /**
   * Stable across retries of this exact operation.
   * Never place email addresses or other personal data in this key.
   */
  readonly idempotencyKey: string;
}

The adapter passes idempotencyKey to a provider that natively supports it. For a provider without native support, the adapter must not pretend retries are safe. Its capabilities should say idempotencyKeys: false, allowing the orchestrator to choose a more cautious flow rather than silently duplicating a commercial action.

Stripe’s API documentation illustrates the relevant provider behavior: the first result associated with a key is retained, and later calls with the same key return that saved result. Its details also reinforce two design constraints for your generic layer: keys must have sufficient entropy, and they must not contain sensitive information.

Idempotent requests | Stripe API Reference

Read Stripe’s “Idempotent requests” reference as a concrete example of how a provider accepts and remembers idempotency keys. The interface you define should not copy Stripe’s API, but it must preserve this retry-safe property.

In the “Idempotent requests” section, read the opening explanation of safe retries and how the provider stores the first response for a supplied key. Then find the paragraph beginning key generation guidance. Continue through the discussion of retention and parameter comparison. Focus on the distinction between repeating one request and accidentally reusing one key for a different request.

Your own local operation record must outlive the provider’s idempotency-retention window. A provider may prune keys after hours or days; your subscription system still needs a durable answer to “did we already attempt this local checkout?” The upcoming checkout orchestrator lesson will persist that record before it calls the adapter.


4. Normalize money, quotes, invoices, and provider state

Money is always currency plus minor units

You already modeled catalog price amounts in minor units. Use the same convention at the provider boundary:

export interface Money {
  readonly currency: string; // ISO 4217 uppercase, for example "USD"
  readonly amountMinor: bigint;
}

export interface MonetaryBreakdown {
  readonly subtotal: Money;
  readonly discount: Money;
  readonly tax: Money;
  readonly total: Money;
}

Do not use JavaScript number for financial totals. It can lose integer precision at sufficiently large values. bigint is suitable in domain code; serialize it to a string at the HTTP boundary.

A provider often needs billing location data to calculate tax and may return tax, discounts, and totals that differ from the static catalog amount. That is why a plan-change screen must request a quote rather than computing “new price minus old price” in Angular.

Preview prices | Paddle Developer Docs

Read Paddle’s “Preview prices” API reference to see why a provider quote requires customer and location context, and why the result must preserve a full monetary breakdown rather than a single displayed price.

In the “Request body” section, begin with customer and location inputs. Continue through the items fields, noting that each item has both a provider price reference and a quantity. Then move to the “Response (200)” section. Follow data, details, and line_items, concentrating on the breakdown beginning minor-unit totals. Notice that taxes and discounts are part of the provider’s calculated result, not assumptions the client should recreate.

A quote is provisional; an invoice is a financial record

A quote answers: “What would this requested subscription configuration cost under current provider rules?”

An invoice answers: “What did the provider bill or expect to collect for a specific charge?”

A quote should include:

  • an opaque quote reference, if the provider gives one;
  • expiry time;
  • the requested recurring items and quantities;
  • immediate charge or credit, if relevant;
  • next recurring total;
  • a line-level and aggregate breakdown;
  • the actual effective time selected by the provider.

An invoice should include:

  • its provider reference and status;
  • currency and line items;
  • amount due and amount paid;
  • due date and billing-period dates when available;
  • hosted invoice and PDF URLs when supported.
A sample provider invoice showing the invoice number, customer, due date, line item, subtotal, total, amount due, and payment options. These are the kinds of provider-authoritative fields the normalized invoice model preserves without copying a provider-specific document format.

The invoice sample makes an important distinction visible: Subtotal, Total, and Amount due may coincide in a simple charge, but your contract must not assume they always do. Tax, discount, credit, partial payment, and collection state can make them different.


5. Define the provider-neutral TypeScript contract

Place the contract in a provider-independent area, for example:

src/billing/provider/billing-provider.contract.ts

It must not import Stripe, Paddle, NestJS controller types, TypeORM entities, or HTTP DTO classes.

Shared references and recurring items

The subscription service validates the local IDs and selects the correct immutable catalog versions. The adapter receives the corresponding provider price reference only after that validation.

export type ProviderCustomerRef = string;
export type ProviderSubscriptionRef = string;
export type ProviderInvoiceRef = string;
export type ProviderCheckoutRef = string;
export type ProviderQuoteRef = string;

export type RecurringItemKind = "fixed" | "per_seat";

export interface DesiredRecurringItem {
  readonly kind: RecurringItemKind;

  readonly localPriceVersionId: string;
  readonly providerPriceRef: string;

  /**
   * Fixed recurring items use quantity 1.
   * Per-seat items use the requested purchased-seat quantity.
   */
  readonly quantity: number;
}

export interface DesiredSubscription {
  readonly localPlanVersionId: string;
  readonly items: readonly DesiredRecurringItem[];
  readonly currency: string;
}

For a combined fixed-plus-seat plan, items contains two entries:

const desiredSubscription: DesiredSubscription = {
  localPlanVersionId: "plan-version-growth-v3",
  currency: "USD",
  items: [
    {
      kind: "fixed",
      localPriceVersionId: "price-platform-monthly-v3",
      providerPriceRef: "provider_price_platform_monthly",
      quantity: 1,
    },
    {
      kind: "per_seat",
      localPriceVersionId: "price-seat-monthly-v3",
      providerPriceRef: "provider_price_seat_monthly",
      quantity: 25,
    },
  ],
};

The provider reference belongs in a provider-specific catalog mapping, not in an Angular request. The browser should submit a local plan or price-version selection; server-side code resolves it to the adapter’s opaque price reference.

Customers, checkout, and quotes

export interface ProviderCustomerInput {
  readonly localOwnerReference: string;
  readonly email?: string;
  readonly displayName?: string;
  readonly taxCountryCode?: string;
  readonly postalCode?: string;
}

export interface ProviderCustomer {
  readonly providerCustomerRef: ProviderCustomerRef;
  readonly email?: string;
}

export interface EnsureCustomerRequest {
  readonly customer: ProviderCustomerInput;
  readonly mutation: ProviderMutationContext;
}

export type CheckoutPresentation = "hosted" | "embedded";

export interface CreateCheckoutRequest {
  readonly providerCustomerRef: ProviderCustomerRef;
  readonly subscription: DesiredSubscription;
  readonly presentation: CheckoutPresentation;
  readonly successUrl: string;
  readonly cancelUrl: string;
  readonly mutation: ProviderMutationContext;
}

export interface ProviderCheckout {
  readonly providerCheckoutRef: ProviderCheckoutRef;
  readonly status: "requires_customer_action" | "processing" | "completed";
  readonly checkoutUrl?: string;
  readonly providerSubscriptionRef?: ProviderSubscriptionRef;
  readonly expiresAt?: Date;
}

export type ChangeTiming = "immediate" | "next_period";

export interface QuoteSubscriptionChangeRequest {
  readonly providerCustomerRef: ProviderCustomerRef;
  readonly providerSubscriptionRef?: ProviderSubscriptionRef;
  readonly desiredSubscription: DesiredSubscription;
  readonly requestedTiming: ChangeTiming;
}

export interface QuoteLineItem {
  readonly kind: RecurringItemKind;
  readonly localPriceVersionId: string;
  readonly quantity: number;
  readonly totals: MonetaryBreakdown;
}

export interface SubscriptionQuote {
  readonly providerQuoteRef?: ProviderQuoteRef;
  readonly expiresAt?: Date;

  readonly effectiveAt: Date;
  readonly appliedTiming: ChangeTiming;

  readonly immediateTotals?: MonetaryBreakdown;
  readonly nextRecurringTotals: MonetaryBreakdown;
  readonly lineItems: readonly QuoteLineItem[];
}

Notice several deliberate choices:

  • providerSubscriptionRef is optional for a quote because initial checkout and a change to an existing subscription are different contexts.
  • The provider may apply timing differently from what was requested. Return both requested and applied semantics.
  • A quote may expire, so do not silently treat an old quote as a binding agreement.
  • checkoutUrl is optional because an embedded flow may return a client token rather than a URL. Add such provider-specific presentation data only when your product needs it; do not prematurely create a union for every provider UI variation.

Subscription changes, cancellation, invoices, and snapshots

export interface ApplySubscriptionChangeRequest {
  readonly providerSubscriptionRef: ProviderSubscriptionRef;
  readonly desiredSubscription: DesiredSubscription;
  readonly quoteRef?: ProviderQuoteRef;
  readonly timing: ChangeTiming;
  readonly mutation: ProviderMutationContext;
}

export interface CancelSubscriptionRequest {
  readonly providerSubscriptionRef: ProviderSubscriptionRef;
  readonly mode: "immediate" | "end_of_period";
  readonly mutation: ProviderMutationContext;
}

export interface ReverseCancellationRequest {
  readonly providerSubscriptionRef: ProviderSubscriptionRef;
  readonly mutation: ProviderMutationContext;
}

export interface ProviderBillingPeriod {
  readonly startsAt: Date;
  readonly endsAt: Date;
}

export interface ProviderSubscriptionSnapshot {
  readonly providerSubscriptionRef: ProviderSubscriptionRef;
  readonly providerCustomerRef: ProviderCustomerRef;

  /**
   * A normalized payment-state view, not your local lifecycle state.
   */
  readonly status:
    | "pending"
    | "trialing"
    | "active"
    | "past_due"
    | "paused"
    | "cancelled"
    | "unknown";

  readonly currentPeriod?: ProviderBillingPeriod;
  readonly cancelAtPeriodEnd: boolean;
  readonly scheduledCancellationAt?: Date;
  readonly endedAt?: Date;

  readonly items: readonly DesiredRecurringItem[];

  /**
   * Present only when the capability promises comparable versions.
   * A larger value means newer provider state for this subscription.
   */
  readonly providerVersion?: bigint;

  readonly retrievedAt: Date;
}

export interface ProviderInvoice {
  readonly providerInvoiceRef: ProviderInvoiceRef;
  readonly providerSubscriptionRef?: ProviderSubscriptionRef;

  readonly status:
    | "draft"
    | "open"
    | "paid"
    | "void"
    | "uncollectible"
    | "unknown";

  readonly dueAt?: Date;
  readonly paidAt?: Date;
  readonly period?: ProviderBillingPeriod;

  readonly lineItems: readonly {
    readonly description: string;
    readonly quantity?: number;
    readonly total: Money;
  }[];

  readonly totals: MonetaryBreakdown;
  readonly amountDue: Money;
  readonly amountPaid: Money;

  readonly hostedInvoiceUrl?: string;
  readonly pdfUrl?: string;
}

export interface ListInvoicesRequest {
  readonly providerCustomerRef?: ProviderCustomerRef;
  readonly providerSubscriptionRef?: ProviderSubscriptionRef;
  readonly cursor?: string;
  readonly limit: number;
}

export interface InvoicePage {
  readonly invoices: readonly ProviderInvoice[];
  readonly nextCursor?: string;
}

The snapshot is intentionally richer than a local billing_subscriptions row. It represents the provider’s present view, including cancellation context, items, period, and a provider version when safely available.

Your webhook worker will later use this method when events arrive out of order or lack enough information to make a safe local transition.


6. Make errors part of the contract

A generic command service must distinguish “the customer must take payment action” from “retry later” and from “this provider cannot perform the requested capability.” It cannot do that reliably from error.message.includes("card").

Use a normalized error union.

export type ProviderErrorCode =
  | "unsupported_capability"
  | "invalid_request"
  | "not_found"
  | "conflict"
  | "authentication_required"
  | "payment_required"
  | "payment_failed"
  | "rate_limited"
  | "temporarily_unavailable"
  | "timeout"
  | "provider_unavailable"
  | "provider_rejected"
  | "unknown";

export interface ProviderError {
  readonly code: ProviderErrorCode;

  /**
   * Safe for customer-facing or internal application handling.
   * Do not expose a raw provider error body.
   */
  readonly message: string;

  readonly retryable: boolean;

  /**
   * True when the remote effect may have happened but no reliable
   * response was received. Reuse the same key and reconcile first.
   */
  readonly outcomeUnknown: boolean;

  readonly providerRequestId?: string;
  readonly providerErrorCode?: string;
  readonly retryAfterSeconds?: number;
}

export type ProviderResult<T> =
  | {
      readonly ok: true;
      readonly value: T;
      readonly providerRequestId?: string;
    }
  | {
      readonly ok: false;
      readonly error: ProviderError;
    };

The outcomeUnknown field deserves special attention. A network timeout after the adapter sends a cancellation request does not prove that cancellation failed. It means the local service does not know the outcome.

In that situation:

  1. Keep the local operation pending.
  2. Retry only with the same idempotency key when safe.
  3. Retrieve an authoritative subscription snapshot if ambiguity remains.
  4. Do not create a second operation merely because the first HTTP response was lost.

This is why provider errors are domain data, not only exceptions logged by an HTTP interceptor.


7. Assemble the interface

The complete adapter interface can now remain small even though it covers the subscription lifecycle.

export interface BillingProvider {
  readonly name: string;
  readonly capabilities: BillingProviderCapabilities;

  ensureCustomer(
    request: EnsureCustomerRequest,
  ): Promise<ProviderResult<ProviderCustomer>>;

  createCheckout(
    request: CreateCheckoutRequest,
  ): Promise<ProviderResult<ProviderCheckout>>;

  quoteSubscriptionChange(
    request: QuoteSubscriptionChangeRequest,
  ): Promise<ProviderResult<SubscriptionQuote>>;

  applySubscriptionChange(
    request: ApplySubscriptionChangeRequest,
  ): Promise<ProviderResult<ProviderSubscriptionSnapshot>>;

  cancelSubscription(
    request: CancelSubscriptionRequest,
  ): Promise<ProviderResult<ProviderSubscriptionSnapshot>>;

  reverseCancellation(
    request: ReverseCancellationRequest,
  ): Promise<ProviderResult<ProviderSubscriptionSnapshot>>;

  listInvoices(
    request: ListInvoicesRequest,
  ): Promise<ProviderResult<InvoicePage>>;

  getInvoice(
    providerInvoiceRef: ProviderInvoiceRef,
  ): Promise<ProviderResult<ProviderInvoice>>;

  getAuthoritativeSubscriptionSnapshot(
    providerSubscriptionRef: ProviderSubscriptionRef,
  ): Promise<ProviderResult<ProviderSubscriptionSnapshot>>;
}

A few boundaries are worth preserving:

  • No webhook verification method yet. Webhook raw-body verification and event normalization belong to Module 4, where they can be designed around authenticated inbox processing.
  • No local database writes here. The adapter calls the provider and normalizes its reply. Your checkout orchestrator owns local transactions, pending operations, state changes, and transition audits.
  • No entitlement decision here. A provider snapshot is input to the local state machine and later the entitlement evaluator. It does not directly grant access.
  • No provider plan creation API by default. Your catalog versioning remains local. If a provider requires catalog synchronization, implement it as a separate provisioning concern rather than mixing it into customer checkout.

Capability enforcement at the call site

A command service checks capability before creating an external operation:

if (!provider.capabilities.subscriptionQuotes) {
  return {
    ok: false,
    error: {
      code: "unsupported_capability",
      message: "The active billing provider cannot quote subscription changes.",
      retryable: false,
      outcomeUnknown: false,
    },
  };
}

return provider.quoteSubscriptionChange(request);

The adapter should repeat the check defensively. The service check gives a fast, clear application response; the adapter check prevents misuse from another future caller.


Implementation checkpoint

Create the contract file and verify that it has these properties:

  • Every provider identifier is an opaque string distinct from local UUIDs.
  • Every externally effectful operation accepts ProviderMutationContext.
  • Combined fixed and per-seat billing is represented as two recurring items with independent quantities.
  • Quotes include provider-calculated tax, discounts, immediate totals where applicable, and next recurring totals.
  • Invoice amounts use minor units and preserve due and paid amounts separately.
  • Provider state is normalized without being confused with your local subscription state machine.
  • Capability flags are explicit and granular enough for your planned customer and admin flows.
  • Provider failures are returned as normalized, machine-readable data, including retryability and uncertain outcomes.
  • Authoritative snapshots provide the material needed for reconciliation and later webhook ordering logic.

Key takeaways

A provider-neutral billing contract is a stability boundary, not a lowest-common-denominator wrapper. Keep local ownership, catalog selection, lifecycle authority, and entitlement rules inside your module. Let adapters own provider calls and translate their identifiers, totals, status vocabulary, and failures into small neutral types.

For all effectful operations, durable operation identity is essential: persist one local operation, generate one idempotency key, and reuse it for every retry. Quotes remain provisional provider calculations; invoices and authoritative snapshots represent provider-backed financial and subscription facts.

Next, you will implement a deterministic fake billing-provider adapter. It will exercise this exact contract while simulating asynchronous confirmations, declines, timeouts, duplicate event delivery, and unsupported capabilities.

Can't find a good explanation? Sign up and we'll make it for you

Sign up