Skip to main content
Stripe is the production payment processor: it wraps the Stripe Node SDK behind the PaymentProcessorType contract and handles subscriptions, invoices, checkout sessions, payment methods, and usage-based billing.

What the processor covers

The PaymentProcessorType 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, thin metadata.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:
  1. Creates Stripe Product + Price(s).
  2. Persists products.external_id = Stripe Product id (prod_*) with packaging-only product metadata ({ model: seat_based | usage_based } or {} for one-off).
  3. Inserts the default row in prices (external_id = Stripe Price id, plus commercial columns / price metadata).
Subscriptions and checkout sessions store 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. The quantityDowngradePolicy setting lets you control whether subscription seat reductions are prorated immediately or deferred to the end of the billing cycle.

Setup

  1. In apps/backend/src/configuration/development.ts, set tools.paymentProcessor using requireStripeApiVersionFromEnv() and requireStripeDefaultCurrencyFromEnv() from apps/backend/src/configuration/requiredStripeEnv.ts (no hard-coded API version or currency — both come from env).
  2. In production.ts, use QuantityDowngradePolicy.END_OF_BILLING_CYCLE unless you have a specific reason for immediate proration.
  3. In your environment, set (all required for real Stripe; see apps/backend/.env.example):
    STRIPE_DEFAULT_CURRENCY is the single settlement / catalog default for that environment (one of USD, 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.
  4. For local webhook testing, use the Stripe CLI to forward events:
  5. Run make test module=backend.

Quantity downgrade policy

The quantityDowngradePolicy 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:
Every operation on the mock returns { success: false } by default. Two exceptions — retrieveInvoice and cancelSubscription — reject with an error instead. When a test needs a specific response, use jest.spyOn:
The mock satisfies the full 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

  1. 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 }.
  2. Add the function to the StripePaymentProcessorType interface in apps/backend/src/tools/paymentProcessor/stripe/types.ts.
  3. Register it in buildStripePaymentProcessor inside apps/backend/src/tools/paymentProcessor/stripe/index.ts.
  4. Add a no-op stub to createTestStripePaymentProcessor in the same file.
  5. Write a test in apps/backend/src/tools/paymentProcessor/__tests__/stripe/<operationName>.spec.ts.
  6. Run make test module=backend.

Webhooks

When Stripe sends a webhook event, it hits the POST /stripe/webhook endpoint in apps/backend/src/routers/api/stripe.ts. Here’s the full flow: Step by step:
  1. Stripe POSTs to /stripe/webhook with a Stripe-Signature header.
  2. The router calls Stripe.webhooks.constructEvent to verify the signature using webhookSigningSecret. If verification fails, it returns 400 immediately.
  3. The router checks whether the event type is in HandledStripeWebhookEvent (defined in apps/backend/src/utilities/stripe.ts). Unhandled event types get a 200 response and are silently ignored — this is intentional, since Stripe sends many event types you may not care about.
  4. The router checks hasWebhookEventBeenProcessed to deduplicate — Stripe can deliver the same event more than once.
  5. 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.
  6. The event is serialised and enqueued to QueueName.STRIPE_WEBHOOK.
  7. The stripeWebhookConsumer picks it up from the queue and dispatches to the right handler based on event.type.
Currently handled events: 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

  1. Add the new event type to HandledStripeWebhookEvent in apps/backend/src/utilities/stripe.ts:
  2. Add a Zod validator for the payload shape in apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/webhookValidators.ts.
  3. Create the handler file apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/invoicePaymentSucceeded.ts:
  4. Register the handler in apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/index.ts.
  5. Write a test in apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/__tests__/.
  6. For local testing, forward events using the Stripe CLI:
  7. Verify:

Gotchas

  • The Stripe client is configured with maxNetworkRetries: 3 and timeout: 80000ms. Operations that exceed this timeout will throw — wrap long-running calls with expRetries using isConnectionTimeoutError as the shouldRetry guard.
  • Webhook signatures expire quickly. Never replay a webhook event after the tolerance window — Stripe will reject the signature.
  • Never log secretKey or webhookSigningSecret — they are credentials.
  • apiVersion must exactly match a supported Stripe API version string. A mismatch causes a startup error.
  • Single currency only: STRIPE_DEFAULT_CURRENCY is per-environment, not per-customer. Do not treat catalog support for USD / EUR / GBP price 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?