Skip to content
You are reading the unreleased documentation. No version is released yet, and these pages describe code that is not in a release.

Add a payment provider

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.

  • 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.ts and libs/storefront-services/src/lib/checkout/checkout.service.ts: how checkout learns which tenders exist.
  • apps/api/src/modules/orders/orders.controller.ts and apps/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.ts and apps/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.ts and tools/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.

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.

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.

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.

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.

  • Widen STOREFRONT_PAYMENT_PROVIDERS in apps/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_KEY in tools/setup/src/output/compose-env.ts from the answer tools/setup/src/steps/payments.ts collects. Read it through ConfigService in a new service under apps/api/src/modules/payments/, or store it in the method’s config blob, whose apiKey and webhookSecret keys are masked as *** on every admin read and preserved when a form resubmits the mask.
  • Create the intent. Inject the new service into OrdersService beside PaymentMethodsService (its constructor in apps/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 in apps/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, then ProcessedWebhookService.recordInboundEvent('stripe', event.id, payload) before any side effect.
  • Move the order. PaymentStatus in prisma/schema.prisma has PENDING, AUTHORIZED, CAPTURED, FAILED, REFUNDED, PARTIALLY_REFUNDED and CANCELLED; nothing in the orders module writes it today. Set it from the webhook, then emit payment.received or payment.refunded. Both are in the outbound catalogue and apps/api/src/modules/webhooks/webhooks.listener.ts already 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.ts renders 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.ts already offers every provider in the allow-list; add the provider’s config fields there.
Terminal window
npx nx test api
npm run test:e2e
npx nx e2e storefront-e2e

npx 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.

  • The installer’s comment in tools/setup/src/steps/payments.ts calls 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_PROVIDERS says so, and it is why the two lists differ.
  • config on a payment method is masked on read. A PATCH that sends *** back keeps the stored secret, null deletes the key, anything else overwrites. Never log the blob.
  • An empty enabled set refuses every order. Keep cod or manual active 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 recordInboundEvent before the side effect, a retried payment.received captures twice.
  • ProcessedWebhook rows are never deleted by the service; the comment names a retention job the caller owns, and none exists in apps/api/src.