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,PlatformBillingErrorclass,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 thestripenpm package and translates Stripe error classes toPlatformBillingError.src/endpoints/stripe-webhook.endpoint.ts— thePOST /api/stripe/webhookhandler. 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:
| Code | Stripe class | Verdict |
|---|---|---|
card_declined | StripeCardError | dead — surface to user |
rate_limited | StripeRateLimitError | retry via outbox |
invalid_request | StripeInvalidRequestError | dead-letter |
network | StripeConnectionError | retry via outbox |
authentication | StripeAuthenticationError | dead — ops issue |
permission | StripePermissionError | dead — restricted key scope |
idempotency | StripeIdempotencyError | dead — surface to ops |
unknown | other / StripeAPIError | retry 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:
- Rate limit (
RATE_LIMIT_STRIPE_PER_MIN, default 60/min/IP). - Body size limit (256 KB).
- 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_SECRETin production → 500 (Stripe retries while ops fix config). - Invalid signature → 401.
- Verification path threw something else → 500.
Handled types and their consequences:
| Event | Service call |
|---|---|
invoice.paid | platformInvoiceService.markPaid(invoiceId, paidAt, paymentIntentId?) |
invoice.payment_failed | platformInvoiceService.markFailed(invoiceId, failureMessage) + platformAccountService.markPastDue(customerId, reason) + email ops (best-effort) |
customer.subscription.deleted | platformAccountService.markCancelled(customerId) |
customer.subscription.updated | platformAccountService.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.