PaymentProcessorType contract and handles subscriptions, invoices, checkout sessions, payment methods, and usage-based billing.
What the processor covers
ThePaymentProcessorType contract is satisfied by both the Stripe adapter and the mock. Every operation is available through tools.paymentProcessor:
Catalog: Product vs Price
Product rows hold plan packaging (scopes, limits, thinmetadata.model). Price rows hold commercial terms (amount, currency, billing interval, quantity rules, usage meter fields, Stripe price_* id). They are separate tables linked by prices.product_id.
createProduct is the normal write path for new catalog SKUs:
- Creates Stripe Product + Price(s).
- Persists
products.external_id= Stripe Product id (prod_*) with packaging-only product metadata ({ model: seat_based | usage_based }or{}for one-off). - Inserts the default row in
prices(external_id= Stripe Price id, plus commercial columns / price metadata).
product_id (which plan) and optional price_id (which price row bills them — pins grandfathered rates). All reads of billable Stripe price ids go through apps/backend/src/utilities/price.ts.
See Billing architecture — Product vs Price for the full model and grandfathering rules.
Why
The Stripe adapter gives you access to the full Stripe billing API through a single, testable interface. Every operation is a small pure function that receives the Stripe instance, parameters, and the tools object — making individual operations easy to unit-test in isolation. ThequantityDowngradePolicy setting lets you control whether subscription seat reductions are prorated immediately or deferred to the end of the billing cycle.
Setup
-
In
apps/backend/src/configuration/development.ts, settools.paymentProcessorusingrequireStripeApiVersionFromEnv()andrequireStripeDefaultCurrencyFromEnv()fromapps/backend/src/configuration/requiredStripeEnv.ts(no hard-coded API version or currency — both come from env). -
In
production.ts, useQuantityDowngradePolicy.END_OF_BILLING_CYCLEunless you have a specific reason for immediate proration. -
In your environment, set (all required for real Stripe; see
apps/backend/.env.example):STRIPE_DEFAULT_CURRENCYis the single settlement / catalog default for that environment (one ofUSD,EUR,GBP). Refract does not run multi-currency billing: public catalog and marketing resolve one currency at a time, and there is no supported upgrade/downgrade path that migrates an active subscription between currencies (for example EUR → USD). If a customer must change currency, cancel their current subscription and have them re-subscribe on the target-currency plan. See Billing — Single currency. -
For local webhook testing, use the Stripe CLI to forward events:
-
Run
make test module=backend.
Quantity downgrade policy
ThequantityDowngradePolicy setting controls how Stripe handles subscription seat reductions:
Set this in
apps/backend/src/configuration/production.ts:
Testing with stripeMock
In apps/backend/src/configuration/test.ts, the payment processor is configured as stripeMock — a no-op stub that requires no Stripe account, API key, or network traffic:
{ success: false } by default. Two exceptions — retrieveInvoice and cancelSubscription — reject with an error instead. When a test needs a specific response, use jest.spyOn:
PaymentProcessorType contract. When you add a new operation, TypeScript will fail to compile until you add a matching stub to createTestStripePaymentProcessor in apps/backend/src/tools/paymentProcessor/stripe/index.ts — this is intentional.
Adding a new Stripe operation
- Create
apps/backend/src/tools/paymentProcessor/stripe/<operationName>.ts. Export a function that takes(stripe: Stripe, params, tools: ToolsType)and returns{ success: true, result }or{ success: false, reason }. - Add the function to the
StripePaymentProcessorTypeinterface inapps/backend/src/tools/paymentProcessor/stripe/types.ts. - Register it in
buildStripePaymentProcessorinsideapps/backend/src/tools/paymentProcessor/stripe/index.ts. - Add a no-op stub to
createTestStripePaymentProcessorin the same file. - Write a test in
apps/backend/src/tools/paymentProcessor/__tests__/stripe/<operationName>.spec.ts. - Run
make test module=backend.
Webhooks
When Stripe sends a webhook event, it hits thePOST /stripe/webhook endpoint in apps/backend/src/routers/api/stripe.ts. Here’s the full flow:
Step by step:
- Stripe POSTs to
/stripe/webhookwith aStripe-Signatureheader. - The router calls
Stripe.webhooks.constructEventto verify the signature usingwebhookSigningSecret. If verification fails, it returns400immediately. - The router checks whether the event type is in
HandledStripeWebhookEvent(defined inapps/backend/src/utilities/stripe.ts). Unhandled event types get a200response and are silently ignored — this is intentional, since Stripe sends many event types you may not care about. - The router checks
hasWebhookEventBeenProcessedto deduplicate — Stripe can deliver the same event more than once. - The router validates the payload shape using Zod schemas in
webhookValidators.ts. This is a safety net: if Stripe changes their payload format, this fails loudly rather than letting malformed data reach your business logic. - The event is serialised and enqueued to
QueueName.STRIPE_WEBHOOK. - The
stripeWebhookConsumerpicks it up from the queue and dispatches to the right handler based onevent.type.
Payment method ownership:
payment_method.attached is the sole owner for inserting local payment_methods rows (syncPaymentMethodFromStripe). PaymentIntent handlers (payment_intent.succeeded, payment_intent.amount_capturable_updated) only look up existing rows and set the Stripe default via updateOrganization. customer.updated reconciles primary and may call syncPaymentMethodFromStripe when the default PM id is missing locally.
Invoice ownership model: invoice document lifecycle rows are primarily synchronized from invoice.created and invoice.updated. invoice.paid variants remain active for checkout finalization and compatibility.
Adding a new event handler
-
Add the new event type to
HandledStripeWebhookEventinapps/backend/src/utilities/stripe.ts: -
Add a Zod validator for the payload shape in
apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/webhookValidators.ts. -
Create the handler file
apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/invoicePaymentSucceeded.ts: -
Register the handler in
apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/index.ts. -
Write a test in
apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/__tests__/. -
For local testing, forward events using the Stripe CLI:
-
Verify:
Gotchas
- The Stripe client is configured with
maxNetworkRetries: 3andtimeout: 80000ms. Operations that exceed this timeout will throw — wrap long-running calls withexpRetriesusingisConnectionTimeoutErroras theshouldRetryguard. - Webhook signatures expire quickly. Never replay a webhook event after the tolerance window — Stripe will reject the signature.
- Never log
secretKeyorwebhookSigningSecret— they are credentials. apiVersionmust exactly match a supported Stripe API version string. A mismatch causes a startup error.- Single currency only:
STRIPE_DEFAULT_CURRENCYis per-environment, not per-customer. Do not treat catalog support forUSD/EUR/GBPprice rows as multi-currency subscription migration. Changing an org from one currency to another requires cancel + re-subscribe (Billing — Single currency). - Hold PaymentIntents for paid validation use
settings.defaultCurrency(createIntent); keep catalog prices and that default aligned for the environment you are running.
What’s next?
- Stripe checkout and webhooks — event matrix, one-off finalizer, queue ack/retry semantics.
- Billing architecture — how Stripe fits into the broader billing system.
- Configuration — environment-level config.
