Add a payment provider
When you need this
Section titled “When you need this”You want customers to pay by card. No card provider is implemented. The allow-list names stripe and paypal, the installer asks for a Stripe secret key and writes it to the environment, and nothing in apps/api reads it: a search for STRIPE in apps/api/src finds no match, and outside spec files the lowercase word appears only in the allow-list and in comments. What works today is cash on delivery, manual bank transfer and gift-card redemption. This page states what exists, then lists the steps to add the first card provider, each pointing at the file where the cash-on-delivery equivalent lives.
Files you touch
Section titled “Files you touch”apps/api/src/modules/payments/dto/create-payment-method.dto.ts: the admin provider allow-list,PAYMENT_PROVIDERS.apps/api/src/modules/payments/payment-methods.service.ts: the storefront allow-list,STOREFRONT_PAYMENT_PROVIDERS, the masked config keys and the public projection.apps/api/src/modules/checkout/checkout-storefront.controller.tsandlibs/storefront-services/src/lib/checkout/checkout.service.ts: how checkout learns which tenders exist.apps/api/src/modules/orders/orders.controller.tsandapps/api/src/modules/orders/orders.service.ts: the idempotent order create, tender validation and gift-card redemption.apps/api/src/modules/webhooks/processed-webhook.service.tsandapps/api/src/modules/webhooks/resend-webhook.controller.ts: inbound dedup and a working signed-webhook route to copy.apps/storefront/src/app/sections/checkout/payment-method-step.component.ts: the checkout step that renders the tenders.apps/admin/src/app/features/payments/payment-method-new.page.ts: the admin form that creates a method.tools/setup/src/steps/payments.tsandtools/setup/src/output/compose-env.ts: the installer prompt and the environment variable it writes.prisma/seeds/store-config/payment-methods.ts: the seed that plants cash on delivery.
The pattern
Section titled “The pattern”1. What the allow-list says and what checkout offers
Section titled “1. What the allow-list says and what checkout offers”apps/api/src/modules/payments/dto/create-payment-method.dto.ts:
export const PAYMENT_PROVIDERS = ['stripe', 'paypal', 'cod', 'manual', 'gift_card'] as const;An operator can create a stripe row in the admin. It never reaches a customer. apps/api/src/modules/payments/payment-methods.service.ts:
export const STOREFRONT_PAYMENT_PROVIDERS = ['cod', 'manual'] as const;
async listStorefront(): Promise<StorefrontPaymentMethod[]> { const rows = await this.prisma.paymentMethod.findMany({ where: { isActive: true, deletedAt: null, provider: { in: [...STOREFRONT_PAYMENT_PROVIDERS] }, }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], select: { code: true, name: true, description: true, provider: true, sortOrder: true }, });The select leaves the config column, which holds provider secrets, off the wire. GET /v1/checkout/settings in apps/api/src/modules/checkout/checkout-storefront.controller.ts is @Public() and returns this list beside the checkout settings; the storefront reads it in libs/storefront-services/src/lib/checkout/checkout.service.ts and renders it in the payment step component.
2. How a tender is validated at order create
Section titled “2. How a tender is validated at order create”apps/api/src/modules/orders/orders.service.ts reads the enabled set at create time rather than trusting the client:
const enabledMethods = await this.paymentMethods.listStorefront(); if (enabledMethods.length === 0) throw new OrderPaymentMethodUnavailableException(); let paymentMethod = enabledMethods[0]; if (dto.paymentMethodCode) { const match = enabledMethods.find((m) => m.code === dto.paymentMethodCode); if (!match) throw new OrderPaymentMethodInvalidException(); paymentMethod = match; } else if (enabledMethods.length > 1) { throw new OrderPaymentMethodInvalidException('A payment method must be selected'); }The two errors are ORDER_PAYMENT_METHOD_UNAVAILABLE (no tender enabled, the store cannot take an order) and ORDER_PAYMENT_METHOD_INVALID. The order stores paymentMethodCode and a name resolved for the request locale. prisma/seeds/store-config/payment-methods.ts creates the cod row once, create-only, so an operator who disables it is not overridden by the next deploy.
3. Gift cards
Section titled “3. Gift cards”gift_card is in the admin allow-list but not in the storefront one; a gift card is not a method the customer picks, it is a code applied to the cart. At create the service reads the balance, caps the discount at the payable total, and redeems after the order row exists:
if (giftCardCode && giftCardDiscount > 0) { await this.giftCards.redeem(giftCardCode, giftCardDiscount, order.id, order.currency); }GiftCardsService.redeem in apps/api/src/modules/gift-cards/gift-cards.service.ts is the atomic, WHERE-guarded decrement. apps/api/test/gift-card-tender.e2e-spec.ts covers the whole path.
4. The idempotent create
Section titled “4. The idempotent create”apps/api/src/modules/orders/orders.controller.ts:
@Post() @OptionalAuth() @RateLimit.StorefrontWrite() @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Create order (authenticated customer or guest)' }) createOrder( @Body() dto: CreateOrderDto, @Req() req: OptionalAuthRequest, @IdempotencyKey(IdempotencyKeyPipe) idempotencyKey: string, @Headers('accept-language') acceptLanguage?: string, ) {IdempotencyKeyPipe in apps/api/src/common/decorators/idempotency-key.decorator.ts requires the X-Idempotency-Key header and a UUID v4 shape. IdempotencyService in apps/api/src/common/idempotency.service.ts stores the key in Redis with a caller hash and a body hash for IDEMPOTENCY_TTL_SECONDS, the 24 hours declared in apps/api/src/common/idempotency.constants.ts: a replay returns the cached customer-facing order, a different body is a 409 IDEMPOTENCY_KEY_CONFLICT, a different caller a 403 IDEMPOTENCY_KEY_FORBIDDEN. Both exceptions, and the 400s for a missing or malformed header, are classes in apps/api/src/common/exceptions/idempotency.exception.ts; the service only imports them. On any failure the service calls abortRequest so the client can retry with the same key.
5. Inbound webhook dedup
Section titled “5. Inbound webhook dedup”prisma/schema.prisma:
model ProcessedWebhook { id String @id @default(cuid()) provider String externalEventId String processedAt DateTime @default(now())
@@unique([provider, externalEventId])}apps/api/src/modules/webhooks/processed-webhook.service.ts wraps it as recordInboundEvent(provider, externalEventId, payload), which returns { alreadyProcessed: true } on a replay. The only inbound route today is the Resend mail webhook. apps/api/src/modules/webhooks/resend-webhook.controller.ts:
@Post('resend') @Public() @RateLimit.Skip() @HttpCode(HttpStatus.OK) @ApiExcludeEndpoint() async handle( @Req() req: RawBodyRequest, @Headers('svix-id') svixId: string | undefined, @Headers('svix-timestamp') svixTimestamp: string | undefined, @Headers('svix-signature') svixSignature: string | undefined, ): Promise<{ ok: true; replay: boolean }> { const raw = req.rawBody ? req.rawBody.toString('utf8') : ''; return this.service.process({ raw, svixId, svixTimestamp, svixSignature }); }The service verifies an HMAC over the raw bytes, rejects a timestamp more than five minutes off, records the event id, and answers 200 on a replay so the provider stops retrying. req.rawBody is captured in apps/api/src/main.ts before the JSON parser runs.
6. Steps for the first card provider
Section titled “6. Steps for the first card provider”- Widen
STOREFRONT_PAYMENT_PROVIDERSinapps/api/src/modules/payments/payment-methods.service.ts. That constant is the switch that lets the provider reach a customer; do it last. - Read the credential. The installer writes
STRIPE_API_KEYintools/setup/src/output/compose-env.tsfrom the answertools/setup/src/steps/payments.tscollects. Read it throughConfigServicein a new service underapps/api/src/modules/payments/, or store it in the method’sconfigblob, whoseapiKeyandwebhookSecretkeys are masked as***on every admin read and preserved when a form resubmits the mask. - Create the intent. Inject the new service into
OrdersServicebesidePaymentMethodsService(its constructor inapps/api/src/modules/orders/orders.service.ts) and call it where the tender is resolved in step 2. Put what the client needs on the customer projection inapps/api/src/modules/orders/orders.serializer.ts; that is the payload the idempotency cache replays. - Add the inbound route. Copy the Resend controller:
@Public(),@RateLimit.Skip(), raw body, signature check over the exact bytes, thenProcessedWebhookService.recordInboundEvent('stripe', event.id, payload)before any side effect. - Move the order.
PaymentStatusinprisma/schema.prismahasPENDING,AUTHORIZED,CAPTURED,FAILED,REFUNDED,PARTIALLY_REFUNDEDandCANCELLED; nothing in the orders module writes it today. Set it from the webhook, then emitpayment.receivedorpayment.refunded. Both are in the outbound catalogue andapps/api/src/modules/webhooks/webhooks.listener.tsalready forwards them, but no module emits them yet. See Add a webhook event. - Extend checkout.
apps/storefront/src/app/sections/checkout/payment-method-step.component.tsrenders the tender list and captures nothing. The card form and the confirmation step go after the order create, keyed on the intent the create returned. - Extend the admin.
apps/admin/src/app/features/payments/payment-method-new.page.tsalready offers every provider in the allow-list; add the provider’s config fields there.
Tests to run
Section titled “Tests to run”npx nx test apinpm run test:e2enpx nx e2e storefront-e2enpx nx test api runs payment-methods.service.spec.ts, checkout.service.spec.ts, orders.service.spec.ts and processed-webhook.service.spec.ts. npm run test:e2e runs test/e2e/checkout-settings.e2e-spec.ts (a gateway row never reaches the public list and a planted sk_live value never appears on the wire), apps/api/test/gift-card-tender.e2e-spec.ts and the idempotency cases in apps/api/test/orders.e2e-spec.ts. npx nx e2e storefront-e2e runs the checkout journeys.
Gotchas
Section titled “Gotchas”- The installer’s comment in
tools/setup/src/steps/payments.tscalls Stripe “already coded”. It is not. The key it collects lands in the environment file and nothing reads it. - Widening the storefront allow-list before the intent and webhook exist produces orders nobody can charge; the comment on
STOREFRONT_PAYMENT_PROVIDERSsays so, and it is why the two lists differ. configon a payment method is masked on read. A PATCH that sends***back keeps the stored secret,nulldeletes the key, anything else overwrites. Never log the blob.- An empty enabled set refuses every order. Keep
codormanualactive until the card path is proven, or the store stops selling during the rollout. - The idempotency cache replays the serialized customer order for 24 hours. A field you add to the create response is in that replay too.
- A card provider retries webhooks. Without
recordInboundEventbefore the side effect, a retriedpayment.receivedcaptures twice. ProcessedWebhookrows are never deleted by the service; the comment names a retention job the caller owns, and none exists inapps/api/src.