Skip to main content

Stripe adapter reference

The Stripe adapter implements PlatformBillingPort for Ledgerline's platform-billing surface (Michael charging tenants for using Ledgerline, distinct from end-customer payments handled elsewhere).

Files

  • src/types/platform-billing.types.ts — port + service seam types, PlatformBillingError class, PlatformBillingErrorCode.
  • src/ports/platform-billing.port.ts — re-exports the types and ships webhook helpers (verifyStripeWebhookSignature, HANDLED_STRIPE_EVENT_TYPES, isHandledStripeEvent). The endpoint imports from here so the lint rule "endpoints must not import adapters" stays satisfied.
  • src/adapters/stripe.adapter.ts — the adapter itself. Wraps the stripe npm package and translates Stripe error classes to PlatformBillingError.
  • src/endpoints/stripe-webhook.endpoint.ts — the POST /api/stripe/webhook handler. Verifies signature, dispatches the four handled event types to the platform-account / platform-invoice service seams.

API surface

type PlatformBillingPort = {
createCustomer(input: { tenantId; name; email }): Promise<{ stripeCustomerId }>
attachPaymentMethod(customerId, paymentMethodId): Promise<void>
listPaymentMethods(customerId): Promise<PaymentMethodSummary[]>
createInvoice(input): Promise<{ stripeInvoiceId; hostedInvoiceUrl; pdfUrl }>
getInvoice(invoiceId): Promise<{ status; pdfUrl? }>
createSubscription(input): Promise<{ subscriptionId }>
cancelSubscription(subscriptionId, opts?): Promise<void>
createPortalSession(customerId, returnUrl): Promise<{ portalUrl }>
}

createCustomer stamps tenantId and source: 'ledgerline' into the Stripe customer's metadata so the Stripe dashboard is searchable by tenant.

attachPaymentMethod sets the new method as default ONLY when the customer has no default yet. Subsequent attaches don't displace the existing default — that's a deliberate UX rule (the hosted Billing Portal is the right place to swap defaults).

createInvoice creates a draft, attaches each line item using Stripe's amount shorthand (pre-multiplied amountCents × quantity), then finalizes. The finalized invoice URL + PDF are returned.

createSubscription uses payment_behavior: 'default_incomplete' so the subscription enters incomplete state until payment is confirmed via the hosted portal — this avoids charging the wrong card during onboarding.

createPortalSession returns a Stripe-hosted URL where the tenant can update payment methods, view invoices, and cancel. Saves us from building card-update UI.

Error mapping

The adapter throws PlatformBillingError (in @app-types) with one of:

CodeStripe classVerdict
card_declinedStripeCardErrordead — surface to user
rate_limitedStripeRateLimitErrorretry via outbox
invalid_requestStripeInvalidRequestErrordead-letter
networkStripeConnectionErrorretry via outbox
authenticationStripeAuthenticationErrordead — ops issue
permissionStripePermissionErrordead — restricted key scope
idempotencyStripeIdempotencyErrordead — surface to ops
unknownother / StripeAPIErrorretry via outbox

classifyStripeError(err) returns the code, shouldRetryStripeError(code) returns whether the outbox dispatcher should retry. Both are exported from the adapter.

Webhook event handling

Mounted at POST /api/stripe/webhook. Middleware order:

  1. Rate limit (RATE_LIMIT_STRIPE_PER_MIN, default 60/min/IP).
  2. Body size limit (256 KB).
  3. Handler.

The handler reads req.rawBody (set by the global express.json verify hook) and verifies the Stripe-Signature header via stripe.webhooks.constructEvent. Failure modes:

  • Missing signature header → 401.
  • Missing STRIPE_WEBHOOK_SECRET in production → 500 (Stripe retries while ops fix config).
  • Invalid signature → 401.
  • Verification path threw something else → 500.

Handled types and their consequences:

EventService call
invoice.paidplatformInvoiceService.markPaid(invoiceId, paidAt, paymentIntentId?)
invoice.payment_failedplatformInvoiceService.markFailed(invoiceId, failureMessage) + platformAccountService.markPastDue(customerId, reason) + email ops (best-effort)
customer.subscription.deletedplatformAccountService.markCancelled(customerId)
customer.subscription.updatedplatformAccountService.syncFromSubscription({ ... })

All other event types ack with 200 and are ignored.

When a service call throws, the handler returns 500 so Stripe retries. The platform-billing domain is responsible for making its own writes idempotent (the same stripeInvoiceId should be markPaid-able twice).

Wiring

Composition root (src/init/api.init.ts):

let platformBilling: PlatformBillingPort | undefined
if (
environmentConfig.STRIPE_SECRET_KEY != null &&
environmentConfig.STRIPE_SECRET_KEY.trim() !== ''
) {
platformBilling = createStripePlatformBillingAdapter({
apiKey: environmentConfig.STRIPE_SECRET_KEY,
...(environmentConfig.STRIPE_WEBHOOK_SECRET != null
? { webhookSecret: environmentConfig.STRIPE_WEBHOOK_SECRET }
: {}),
logger,
})
}

The adapter is exposed on deps.repositories.platformBilling. The webhook endpoint pulls platformInvoiceService and platformAccountService from ctx.repositories — the platform-billing domain agent owns those services and binds them on the same registry.

Sample webhook signature snippet

The endpoint never constructs a signature itself, but the test harness illustrates the full pattern:

import Stripe from 'stripe'
import { createHmac } from 'node:crypto'

const rawBody = JSON.stringify({ id: 'evt_test', type: 'invoice.paid', data: { ... } })
const timestamp = Math.floor(Date.now() / 1000)
const signedPayload = `${timestamp}.${rawBody}`
const signature = createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload, 'utf8')
.digest('hex')
const header = `t=${timestamp},v1=${signature}`

// On the server side, in the endpoint:
const event = stripe.webhooks.constructEvent(rawBody, header, WEBHOOK_SECRET)

In tests, a stub Stripe['webhooks'] replaces constructEvent directly so we don't bake key material into the test fixtures.

Integration with parallel platform-billing domain agent

The webhook endpoint expects two seams on ctx.repositories:

type PlatformAccountServiceSeam = {
markPastDue(stripeCustomerId, reason): Promise<void>
markCancelled(stripeCustomerId): Promise<void>
syncFromSubscription({ stripeCustomerId, stripeSubscriptionId, priceId, status, currentPeriodEnd? }): Promise<void>
}

type PlatformInvoiceServiceSeam = {
markPaid(stripeInvoiceId, paidAt, paymentIntentId?): Promise<void>
markFailed(stripeInvoiceId, failureMessage?): Promise<void>
}

The platform-billing domain module implements both. When it lands, its init hook adds them to deps.repositories and the webhook endpoint auto-registers; until then, the endpoint short-circuits during registration and Stripe events 404 (with a Render log line).

Manual integration test

A skipped integration test in src/adapters/stripe.adapter.spec.ts unblocks when STRIPE_SECRET_KEY is exported as a sk_test_* (test mode) key. The test creates a real customer in Stripe test mode and asserts the returned id starts with cus_. Run with:

STRIPE_SECRET_KEY=sk_test_... pnpm vitest run src/adapters/stripe.adapter.spec.ts

Live keys (sk_live_*) are rejected at the test guard — the integration test refuses to talk to production.