diff --git a/packages/stripe/.gitignore b/packages/stripe/.gitignore new file mode 100644 index 000000000..8f240a5eb --- /dev/null +++ b/packages/stripe/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +/coverage diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md new file mode 100644 index 000000000..f60a57647 --- /dev/null +++ b/packages/stripe/ANALYSIS.md @@ -0,0 +1,97 @@ + + +## Base Library Passthrough Analysis + +### `stripe` (Stripe Node SDK) — PARTIAL PASSTHROUGH / MODIFIED + +- Options type: Custom `StripeConfig` for this plugin plus `clientConfig?: Stripe.StripeConfig` passed verbatim to `new Stripe(apiKey, clientConfig)`. +- Options passed: `apiKey` and `clientConfig` unmodified to constructor. Webhook verification uses `Stripe.webhooks.constructEvent(rawBody, signature, secret)` (SDK static API) with no extra transformation. +- Features restricted: Consumers do not receive a generic “pass all Checkout params” helper; `createCheckoutSession` only accepts `CreateSessionInput` and builds a fixed `SessionCreateParams`. `getActivePromotionCode` wraps `promotionCodes.list` with `active: true` and returns only the first result. +- Features added: Fastify plugin wiring, webhook route, scoped raw-body parser, signature preHandler, optional handler warning + safe default handler, `StripeClient` helper class, TypeScript augmentations, route constant export. + +### `@prefabs.tech/fastify-config` — MODIFIED (integration only) + +- This package augments `ApiConfig` with optional `stripe?: StripeConfig`; it does not redefine the config system. +- `StripeClient` reads `config.stripe` from a full `ApiConfig` instance. + +### `fastify` / `fastify-plugin` — THEIRS (framework) + +- Plugin pattern: default export wrapped with `fastify-plugin` so behavior applies to the enclosing Fastify scope. Webhook controller is a **nested** plugin without `fastify-plugin`, keeping the JSON parser override local. + +--- + +## Summary + +### Package metadata + +- **Runtime dependency:** `stripe@20.3.1` (bundled; consumers do not declare it unless they use the SDK alongside this package). +- **Peers:** `@prefabs.tech/fastify-config@0.94.0`, `fastify@>=5.2.2`, `fastify-plugin@>=5.0.1`. +- **Entry:** `src/index.ts` → `export *` from `./constants`, `./utils`; default plugin from `./plugin`; types `StripeConfig`, `StripeEvent`. + +### Public exports (from `src/index.ts` and `src/utils/index.ts`) + +| Export | Description | +| ------ | ----------- | +| `default` | `FastifyPluginAsync`-wrapped Stripe plugin; requires non-empty `StripeConfig`. | +| `ROUTE_STRIPE_WEBHOOK` | Constant `"/payment/webhook"`. | +| `StripeClient` | Holds `Stripe` SDK + `createCheckoutSession` + `getActivePromotionCode`. | +| `registerRawBodyParser` | Alias export of the default from `stripeRawBodyParser` — registers JSON parser with `rawBody`. | +| `StripeConfig` | Type of plugin options / `config.stripe`. | +| `StripeEvent` | Type alias for `Stripe.Event`. | + +`CreateSessionInput` exists in `src/types/index.ts` but is **not** re-exported from the package entry. + +### Internal (not in public API) + +| Symbol | Role | +| ------ | ---- | +| `createVerifyStripeSignature` | Factory returning webhook `preHandler`; validates secret, header, `rawBody`; calls `Stripe.webhooks.constructEvent`; sets `request.stripeEvent`. | +| `webhookHandler` | Default fallback when `handlers.webhook` omitted — logs error, responds so Stripe does not retry indefinitely. | +| Webhook `controller` plugin | Registers route, installs raw-body parser in this scope, attaches preHandler. | +| Default export of `stripeRawBodyParser.ts` | Same implementation as `registerRawBodyParser`; registers `application/json` buffer parser setting `request.rawBody` and JSON body. | + +### Source modules (one line each) + +| File | Responsibility | +| ---- | ---------------- | +| `src/plugin.ts` | Top-level plugin: validate options, optionally register webhook controller. | +| `src/webhook/controller.ts` | Encapsulated route + parser + signature preHandler + dispatch to user or default handler. | +| `src/webhook/handler.ts` | Default no-op handler: log misconfiguration, avoid Stripe retry storms. | +| `src/middlewares/verifyStripeSignature.ts` | `createVerifyStripeSignature` — signature verification preHandler. | +| `src/utils/stripeClient.ts` | `StripeClient` — SDK constructor passthrough + checkout/promo helpers. | +| `src/utils/stripeRawBodyParser.ts` | JSON raw-body parser; augments `FastifyRequest.rawBody`. | +| `src/types/index.ts` | `StripeConfig`, `CreateSessionInput`, webhook types; augments `FastifyRequest.stripeEvent`. | +| `src/constants.ts` | `ROUTE_STRIPE_WEBHOOK`. | + +### `StripeClient` methods (ours vs theirs) + +| Member | Classification | +| ------ | -------------- | +| `constructor` | **Ours** — requires `config.stripe`; passes `apiKey` + `clientConfig` to `new Stripe(...)`. | +| `stripe` | **Theirs** — live SDK instance. | +| `createCheckoutSession` | **Ours** — builds `SessionCreateParams` (defaults, line item, metadata by mode); **theirs** — `checkout.sessions.create`. | +| `getActivePromotionCode` | **Ours** — restricts to `active: true`, first result; **theirs** — `promotionCodes.list`. | + +### Framework / lifecycle + +- Registration log: `"Registering Stripe plugin"` (`plugin.ts`). +- Webhook registration log: `"Registering Stripe webhook route"` (`controller.ts`). +- Conditional: `enablePaymentWebhook` registers webhook sub-plugin only when truthy. + +### Conditional branches & defaults + +- Empty/missing plugin options → throw `"Missing stripe configuration..."`. +- Missing `{ stripeConfig }` on webhook controller options → throw (defensive; normally only called internally). +- No `handlers.webhook` but webhooks enabled → warn at register; route uses default ack handler. +- Webhook path: `stripeConfig.webhookPath || ROUTE_STRIPE_WEBHOOK`. +- Checkout helper defaults: `quantity` → 1, `mode` → `"payment"`, `currency` → `defaultCurrency`, success/cancel URLs from `urls`, `allow_promotion_codes` from `allowPromotionCodes`. + +### Default values (ours) + +- Fallback webhook handler behavior: log at error level with event id/type; response path returns 200-equivalent completion without throwing. +- JSON parse error in raw-body parser: assign `statusCode: 400` on error for Fastify. + +### “Ours” vs “Theirs” highlights + +- **Ours:** Validation, logging, warning, error response bodies, `SessionCreateParams` assembly, metadata routing by checkout mode, promotion code list filtering/return shape, plugin encapsulation for parser, module augmentations. +- **Theirs:** `new Stripe(...)`, `stripe.checkout.sessions.create`, `stripe.promotionCodes.list`, `Stripe.webhooks.constructEvent` inputs/outputs as provided by the SDK. diff --git a/packages/stripe/FEATURES.md b/packages/stripe/FEATURES.md new file mode 100644 index 000000000..0a91fb0ba --- /dev/null +++ b/packages/stripe/FEATURES.md @@ -0,0 +1,52 @@ + + +## Plugin registration + +1. The default export is wrapped with `fastify-plugin` so the plugin applies to the parent Fastify scope. +2. If plugin options are missing or an empty object, registration throws with a clear message (`Missing stripe configuration. Did you forget to pass it to the stripe plugin?`). +3. An info-level log is emitted when the Stripe plugin registers (`Registering Stripe plugin`). +4. When `enablePaymentWebhook` is truthy, the webhook controller sub-plugin is registered; when falsy, no webhook route or webhook-scoped parser is installed. + +## Webhook route + +5. The webhook controller throws if `stripeConfig` is missing from its register options (internal guard). +6. An info-level log is emitted when the webhook route plugin registers (`Registering Stripe webhook route`). +7. If `enablePaymentWebhook` is true and `handlers.webhook` is not set, a warning is logged at registration; the HTTP route still accepts webhooks. +8. The webhook `POST` path is `config.stripe.webhookPath` when set, otherwise the default `ROUTE_STRIPE_WEBHOOK` (`/payment/webhook`). +9. When no custom `handlers.webhook` is provided, the default handler logs an error with event id and type and completes without throwing so Stripe does not retry indefinitely. +10. When `handlers.webhook` is provided, it is invoked with `(request, event)` after successful verification. +11. If `request.stripeEvent` is missing after the preHandler chain (defensive), the route responds with HTTP 500 and a structured error body. + +## Webhook signature verification + +12. If `webhookSecret` is missing on the resolved config, the preHandler responds with HTTP 400 and `{ error: "Webhook secret not configured" }` and logs an error. +13. If the `stripe-signature` header is missing, responds with HTTP 400 and `{ error: "Missing stripe-signature header" }`. +14. If `request.rawBody` is missing, responds with HTTP 400 and `{ error: "Raw body is not available for signature verification" }`. +15. If `Stripe.webhooks.constructEvent` throws, responds with HTTP 400 and `{ error: "Webhook signature verification failed" }` and logs the error. +16. On success, the verified `Stripe.Event` is assigned to `request.stripeEvent`. + +## Raw body parser + +17. `registerRawBodyParser` (same implementation as used inside the webhook controller) adds an `application/json` parser using `parseAs: "buffer"`, stores the buffer on `request.rawBody`, and parses JSON for the handler. +18. Invalid JSON in the body results in an error passed to `done` with `statusCode: 400` so clients get a 400 response instead of a generic 500. +19. The parser is registered only on the Fastify instance passed in; when used from the webhook controller, that instance is the non-`fastify-plugin`-wrapped sub-scope so other parent routes keep their default JSON parsing. + +## Types and module augmentations + +20. Importing the package augments `@prefabs.tech/fastify-config`’s `ApiConfig` with optional `stripe?: StripeConfig`. +21. `FastifyRequest` is augmented with optional `stripeEvent?: Stripe.Event` (from `types/index.ts`). +22. `FastifyRequest` is augmented with optional `rawBody?: Buffer` (from the raw-body parser module). +23. The package entry exports the `StripeConfig` type and `StripeEvent` as a public alias for `Stripe.Event`. + +## StripeClient + +24. `StripeClient` constructor throws if `config.stripe` is undefined on the provided `ApiConfig`. +25. `StripeClient` constructs `new Stripe(apiKey, clientConfig)` using `config.stripe.apiKey` and optional `config.stripe.clientConfig` unchanged. +26. `createCheckoutSession` builds a single line item from `CreateSessionInput` with defaults: `quantity` defaults to `1`, `mode` defaults to `"payment"`, `currency` defaults to `config.stripe.defaultCurrency`, success and cancel URLs default to `config.stripe.urls`. +27. `createCheckoutSession` sets `allow_promotion_codes` from `config.stripe.allowPromotionCodes` (may be `undefined`). +28. When `metadata` is passed to `createCheckoutSession`, it is set on `metadata` and on the mode-appropriate field only (`payment_intent_data`, `setup_intent_data`, or `subscription_data`); when omitted, metadata and those blocks are left off the payload. +29. `getActivePromotionCode` calls `promotionCodes.list` with `{ active: true, code }` and returns the first element of `data` or `undefined`. + +## Constants + +30. `ROUTE_STRIPE_WEBHOOK` is exported as the string `/payment/webhook`. diff --git a/packages/stripe/GUIDE.md b/packages/stripe/GUIDE.md new file mode 100644 index 000000000..753496da8 --- /dev/null +++ b/packages/stripe/GUIDE.md @@ -0,0 +1,476 @@ +# `@prefabs.tech/fastify-stripe` — Developer Guide + +A Fastify plugin that wires Stripe payment processing into your app: a configurable webhook endpoint with signature verification, a raw-body content-type parser, and a `StripeClient` helper for one-shot Checkout session creation. + +This guide assumes familiarity with Fastify and the [Stripe Node SDK](https://github.com/stripe/stripe-node). For the wrapped library's full API, refer to its own docs (linked below) — this guide only documents what *we* add on top. + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config fastify fastify-plugin +``` + +```bash +pnpm add @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config fastify fastify-plugin +``` + +The `stripe` SDK is bundled as a direct dependency of this package (see `package.json`), so you do not install it separately unless you choose to add it for your own scripts. + +Peer dependencies enforced by `package.json`: `fastify >= 5.2.2`, `fastify-plugin >= 5.0.1`, `@prefabs.tech/fastify-config 0.94.0`. + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/fastify-stripe test +pnpm --filter @prefabs.tech/fastify-stripe build +pnpm --filter @prefabs.tech/fastify-stripe typecheck +pnpm --filter @prefabs.tech/fastify-stripe lint +``` + +## Setup + +Register `@prefabs.tech/fastify-config` first, then pass `config.stripe` into the Stripe plugin at register time — the same integration pattern as `@prefabs.tech/fastify-graphql`, `@prefabs.tech/fastify-slonik`, and `@prefabs.tech/fastify-mailer` (`register(plugin, config.)`). + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import stripePlugin from "@prefabs.tech/fastify-stripe"; +import Fastify from "fastify"; + +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + // ...your other config + stripe: { + apiKey: process.env.STRIPE_API_KEY!, + defaultCurrency: "usd", + enablePaymentWebhook: true, + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, + urls: { + cancel: "https://example.com/cancel", + success: "https://example.com/success", + }, + handlers: { + webhook: async (request, event) => { + request.server.log.info({ type: event.type }, "received Stripe event"); + }, + }, + }, +}; + +const fastify = Fastify({ logger: true }); + +await fastify.register(configPlugin, { config }); +await fastify.register(stripePlugin, config.stripe); + +await fastify.listen({ port: 3000, host: "0.0.0.0" }); +``` + +Pass `config.stripe` as the second argument to `register`, like `@prefabs.tech/fastify-mailer` / `@prefabs.tech/fastify-graphql`. Omitting options or passing `{}` makes registration throw with `"Missing stripe configuration. Did you forget to pass it to the stripe plugin?"`. + +All examples below assume this setup is in place. Examples will only show the relevant subset of `config.stripe` rather than repeating the whole object. + +> **Heads up:** registering this plugin with `enablePaymentWebhook: true` installs an `application/json` content-type parser scoped to the webhook controller's plugin encapsulation. The webhook route gets `request.rawBody` populated; other JSON routes on the parent instance are unaffected. If you want `request.rawBody` on a route you control, call `registerRawBodyParser(fastify)` yourself — see the [Raw Body Parser](#raw-body-parser-registerrawbodyparser) section. + +--- + +## Base Libraries + +### `stripe` (Stripe Node SDK) — Partial Passthrough / Modified + +The official [Stripe Node SDK](https://github.com/stripe/stripe-node) for talking to the Stripe API. + +→ **Their docs:** [Stripe API reference](https://docs.stripe.com/api) · [stripe-node README](https://github.com/stripe/stripe-node#readme) + +This package exposes the SDK partially: + +- **`clientConfig` is passed through unchanged.** Anything you put on `config.stripe.clientConfig` (`apiVersion`, `httpClient`, `maxNetworkRetries`, `timeout`, `telemetry`, etc.) is forwarded directly to `new Stripe(apiKey, clientConfig)`. +- **`webhooks.constructEvent` is passed through unchanged.** The webhook route runs an internal `preHandler` (implemented as `createVerifyStripeSignature` in source, **not exported**) that calls `Stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)` and attaches the result to `request.stripeEvent`. +- **`checkout.sessions.create` is wrapped with a different surface.** `StripeClient.createCheckoutSession` accepts a flat `CreateSessionInput` (one product, one quantity, one amount) and synthesizes a fixed `SessionCreateParams` payload. You **cannot** pass arbitrary Checkout options through this helper — for advanced cases use `client.stripe.checkout.sessions.create(...)` directly. +- **`promotionCodes.list` is wrapped with a different surface.** `StripeClient.getActivePromotionCode` hardcodes `active: true` and returns only the first match. For full search behavior, call `client.stripe.promotionCodes.list(...)` directly. + +**What we add on top:** + +- A Fastify plugin that wires the webhook endpoint when `enablePaymentWebhook` is set. +- A signature-verification preHandler with structured 400 responses and log lines. +- A raw-body content-type parser that retains `request.rawBody` (required for `webhooks.constructEvent`). +- A config-aware `StripeClient` helper with default URLs, currency, and promotion-code wiring. +- Module augmentations so `ApiConfig.stripe`, `request.rawBody`, and `request.stripeEvent` are typed without manual declaration. + +--- + +## Features + +### Plugin registration and options + +`stripePlugin` is `fastify-plugin`-wrapped, so its decorations attach to the top-level Fastify instance. + +Register with `register(stripePlugin, config.stripe)`. Calling `register(stripePlugin)` or `register(stripePlugin, {})` throws the `"Missing stripe…"` error. + +Services that do not use Stripe should **not** register this plugin (you may omit `stripe` from `ApiConfig` entirely on those services). + +### Webhook endpoint toggle (`enablePaymentWebhook`) + +The webhook route is only registered when `config.stripe.enablePaymentWebhook` is truthy. Use this to keep the rest of `config.stripe` available (for `StripeClient`) without exposing a webhook endpoint on services that don't need one. + +```typescript +{ + stripe: { + apiKey: process.env.STRIPE_API_KEY!, + defaultCurrency: "usd", + enablePaymentWebhook: false, // no /payment/webhook route registered + urls: { cancel: "...", success: "..." }, + }, +} +``` + +### Configurable webhook route path + +The default route is `/payment/webhook`, exported as `ROUTE_STRIPE_WEBHOOK`. Override it via `config.stripe.webhookPath`. + +```typescript +import { ROUTE_STRIPE_WEBHOOK } from "@prefabs.tech/fastify-stripe"; + +console.log(ROUTE_STRIPE_WEBHOOK); // "/payment/webhook" + +{ + stripe: { + // ... + webhookPath: "/stripe/events", + }, +} +``` + +### Custom webhook handler (`handlers.webhook`) + +Provide a function on `config.stripe.handlers.webhook` to receive the verified event. It is called with the Fastify `request` and a `Stripe.Event`. + +If you omit it but still set `enablePaymentWebhook: true`, the plugin logs a warning at registration time and the route falls back to a default handler that **acknowledges the event with HTTP 200 and logs an error** containing the event id and type. This is intentional: returning a non-2xx status would cause Stripe to retry the delivery with exponential backoff, so the package optimizes for "your logs scream that you forgot to wire a handler" rather than "Stripe retries indefinitely". + +```typescript +import type { StripeEvent } from "@prefabs.tech/fastify-stripe"; +import type { FastifyRequest } from "fastify"; + +const handleWebhook = async ( + request: FastifyRequest, + event: StripeEvent, +): Promise => { + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object; + request.server.log.info({ sessionId: session.id }, "checkout complete"); + break; + } + case "payment_intent.payment_failed": { + const intent = event.data.object; + request.server.log.warn({ intentId: intent.id }, "payment failed"); + break; + } + } +}; + +{ + stripe: { + // ... + handlers: { webhook: handleWebhook }, + }, +} +``` + +### Signature verification (webhook `preHandler`) + +Before your webhook handler runs, the route executes a bundled `preHandler` that verifies the Stripe signature. You cannot import this preHandler factory from the package; it validates the `stripe-signature` header against `webhookSecret` using `Stripe.webhooks.constructEvent` (same semantics as in the Stripe Node SDK). All failures return HTTP 400 with a `{ error }` body and log an error line: + +| Condition | Status | Response body | +| ------------------------------------------------------ | ------ | ------------------------------------------------------------------- | +| `webhookSecret` unset on resolved Stripe config | 400 | `{ error: "Webhook secret not configured" }` | +| `stripe-signature` header missing | 400 | `{ error: "Missing stripe-signature header" }` | +| `request.rawBody` missing | 400 | `{ error: "Raw body is not available for signature verification" }` | +| `Stripe.webhooks.constructEvent` throws | 400 | `{ error: "Webhook signature verification failed" }` | + +On success, the verified `Stripe.Event` is attached to `request.stripeEvent`. The field is typed via module augmentation, so it is automatically available on `FastifyRequest`: + +```typescript +import type { FastifyRequest } from "fastify"; + +function readEvent(request: FastifyRequest) { + const event = request.stripeEvent; + // ... +} +``` + +If verification succeeds but `request.stripeEvent` is missing afterward, the webhook route responds with HTTP 500 (defensive; should not occur in normal operation). + +### Raw body parser (`registerRawBodyParser`) + +Stripe's signature verification requires the *exact* raw request bytes. The webhook controller installs an `application/json` content-type parser that retains the raw buffer on `request.rawBody` while still parsing JSON for downstream handlers. JSON parse errors are tagged with `statusCode: 400`, so Fastify responds 400 instead of a generic 500. + +The parser is installed inside the webhook controller's plugin scope, so it applies to the webhook route but **does not** override `application/json` parsing on other routes registered on the parent Fastify instance. + +If you want the raw body on routes you control (e.g. a custom raw-body-aware endpoint), import the parser directly and register it on the instance where those routes live: + +```typescript +import { registerRawBodyParser } from "@prefabs.tech/fastify-stripe"; + +registerRawBodyParser(fastify); + +fastify.post("/custom-webhook", async (request) => { + return { rawBytes: request.rawBody?.length ?? 0 }; +}); +``` + +### `StripeConfig` and `StripeEvent` types + +`StripeConfig` is the shape of `config.stripe` — a curated subset of options used by this package, augmented onto `ApiConfig`. `StripeEvent` is a re-export of `Stripe.Event` so consumers don't need to import from `stripe` directly. + +```typescript +import type { + StripeConfig, + StripeEvent, +} from "@prefabs.tech/fastify-stripe"; + +const stripeConfig: StripeConfig = { + apiKey: process.env.STRIPE_API_KEY!, + defaultCurrency: "usd", + enablePaymentWebhook: true, + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, + urls: { cancel: "...", success: "..." }, +}; + +function logEvent(event: StripeEvent) { + console.log(event.type, event.id); +} +``` + +### `StripeClient` — config-aware Stripe wrapper + +`StripeClient` is a small class that holds an `ApiConfig` and exposes: + +- `client.stripe` — the raw `Stripe` SDK instance, for any call this package doesn't wrap. +- `createCheckoutSession(input, metadata?)` — one-product Checkout session helper. +- `getActivePromotionCode(code)` — promo code lookup (active-only, first match). + +The constructor throws if `config.stripe` is unset: + +```typescript +new StripeClient({} as ApiConfig); +// Error: StripeClient requires config.stripe to be set on the provided ApiConfig. +``` + +```typescript +import { StripeClient } from "@prefabs.tech/fastify-stripe"; + +const client = new StripeClient(fastify.config); + +const session = await client.createCheckoutSession( + { + productName: "Premium Plan", + unitAmount: 2999, + }, + { userId: "user_123" }, +); + +console.log(session.url); +``` + +#### `createCheckoutSession` defaults and behavior + +`createCheckoutSession` builds a `SessionCreateParams` payload with these defaults applied when fields are unset on `input`: + +| Field | Default | +| ------------- | ------------------------------------------ | +| `quantity` | `1` | +| `mode` | `"payment"` | +| `currency` | `config.stripe.defaultCurrency` | +| `successUrl` | `config.stripe.urls.success` | +| `cancelUrl` | `config.stripe.urls.cancel` | + +`config.stripe.allowPromotionCodes` is forwarded as `allow_promotion_codes` (passing `undefined` is fine — Stripe ignores it). + +When the optional `metadata` argument is provided, it is written to `session.metadata` *and* to the mode-specific data block so it surfaces on the downstream object too: + +| Mode | Mode-specific placement | +| ---------------- | ------------------------------------ | +| `"payment"` | `payment_intent_data.metadata` | +| `"subscription"` | `subscription_data.metadata` | +| `"setup"` | `setup_intent_data.metadata` | + +Only the placement matching the selected mode is set — Stripe rejects mode-specific `*_data` blocks that don't match the session mode, so the helper picks the right one for you. When `metadata` is omitted, the helper leaves `metadata` and every `*_data` block off the payload entirely. + +```typescript +await client.createCheckoutSession( + { + productName: "Annual subscription", + unitAmount: 9900, + currency: "eur", + mode: "subscription", + quantity: 1, + successUrl: "https://example.com/thanks", + cancelUrl: "https://example.com/cancelled", + }, + { orderId: "order_42", source: "campaign-q4" }, +); +``` + +If you need anything `CreateSessionInput` doesn't cover (multiple line items, tax rates, shipping options, customer fields, etc.), bypass the helper and call the SDK directly: + +```typescript +const session = await client.stripe.checkout.sessions.create({ + mode: "payment", + line_items: [ + { price: "price_1", quantity: 1 }, + { price: "price_2", quantity: 2 }, + ], + success_url: "https://example.com/success", + cancel_url: "https://example.com/cancel", +}); +``` + +#### `getActivePromotionCode` + +Looks up an *active* promotion code by its customer-facing string and returns the first matching `Stripe.PromotionCode`, or `undefined` if there is no match. Note that this never paginates — if you have multiple active codes that share the same string (rare), only the first one Stripe returns is visible. + +```typescript +const promo = await client.getActivePromotionCode("LAUNCH20"); + +if (promo) { + console.log(promo.id, promo.coupon.percent_off); +} +``` + +### Module augmentations + +Importing this package brings these ambient TypeScript augmentations into scope: + +- `ApiConfig` (from `@prefabs.tech/fastify-config`) gains an optional `stripe?: StripeConfig` field. Optional so other consumers of `ApiConfig` aren't forced to declare a Stripe block; the plugin checks for and warns when it is missing. +- `FastifyRequest` gains optional `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` fields. + +You do not need to redeclare these fields — import the package in your entry file so the augmentations apply. + +--- + +## Use Cases + +### Accept a one-time payment for a single product + +When all you need is a "buy this thing" flow, `createCheckoutSession` is the shortest path. Register the plugin, expose a route that creates a session, and rely on the webhook handler for fulfillment. + +```typescript +import { StripeClient } from "@prefabs.tech/fastify-stripe"; + +fastify.post<{ Body: { productName: string; amountCents: number } }>( + "/checkout", + async (request) => { + const client = new StripeClient(fastify.config); + const session = await client.createCheckoutSession( + { + productName: request.body.productName, + unitAmount: request.body.amountCents, + }, + { userId: request.user?.id ?? "anonymous" }, + ); + + return { url: session.url }; + }, +); +``` + +Fulfillment runs in `config.stripe.handlers.webhook` on the `checkout.session.completed` event, using the `metadata.userId` to look up which customer to deliver to. + +### Use the Stripe SDK directly while keeping the helper + +For workflows that need full SDK access (custom line items, subscriptions with price IDs, invoices, refunds…) use `client.stripe` — the helper class is a convenience layer, not a wall. + +```typescript +const client = new StripeClient(fastify.config); + +const customer = await client.stripe.customers.create({ + email: "buyer@example.com", + name: "Buyer", +}); + +const subscription = await client.stripe.subscriptions.create({ + customer: customer.id, + items: [{ price: "price_monthly_premium" }], +}); +``` + +### Service that talks to Stripe but does not host a webhook + +Some services need to call the Stripe API but don't terminate webhooks themselves (e.g. a worker that processes events forwarded from another service, or an API that only creates checkout sessions). Set `enablePaymentWebhook: false` to skip the route and the global raw-body parser. + +```typescript +{ + stripe: { + apiKey: process.env.STRIPE_API_KEY!, + defaultCurrency: "usd", + enablePaymentWebhook: false, + urls: { cancel: "...", success: "..." }, + }, +} +``` + +`StripeClient` still works exactly the same. + +### Verify a webhook signature manually + +If you need to handle Stripe webhooks on a non-standard path or alongside other handlers, install the raw-body parser yourself and run signature verification directly. The package's preHandler is internal, so you'd reproduce its logic with `client.stripe.webhooks.constructEvent`: + +```typescript +import { StripeClient, registerRawBodyParser } from "@prefabs.tech/fastify-stripe"; + +registerRawBodyParser(fastify); + +const client = new StripeClient(fastify.config); + +fastify.post("/my-stripe-webhook", async (request, reply) => { + const signature = request.headers["stripe-signature"]; + + if (!signature || !request.rawBody) { + return reply.status(400).send({ error: "Missing signature or body" }); + } + + try { + const event = client.stripe.webhooks.constructEvent( + request.rawBody, + signature, + fastify.config.stripe.webhookSecret!, + ); + + return { received: true, type: event.type }; + } catch (error) { + request.server.log.error({ err: error }, "signature verification failed"); + return reply.status(400).send({ error: "Invalid signature" }); + } +}); +``` + +### Gate a campaign behind an active promotion code + +`getActivePromotionCode` is convenient for "does this code exist and is it currently active?" checks before redirecting a user into Checkout. Combine it with `createCheckoutSession` and rely on `config.stripe.allowPromotionCodes` to surface the field in the Stripe-hosted UI. + +```typescript +fastify.post<{ Body: { code?: string } }>("/start-checkout", async (request, reply) => { + const client = new StripeClient(fastify.config); + + if (request.body.code) { + const promo = await client.getActivePromotionCode(request.body.code); + + if (!promo) { + return reply.status(400).send({ error: "Invalid or expired promotion code" }); + } + } + + const session = await client.createCheckoutSession({ + productName: "Annual plan", + unitAmount: 9900, + currency: "usd", + }); + + return { url: session.url }; +}); +``` + +The Checkout page will show a "promotion code" field because `config.stripe.allowPromotionCodes` is `true`; users still need to enter the code there — the pre-check just prevents wasted Checkout sessions. diff --git a/packages/stripe/README.md b/packages/stripe/README.md new file mode 100644 index 000000000..5c825b26c --- /dev/null +++ b/packages/stripe/README.md @@ -0,0 +1,96 @@ +# `@prefabs.tech/fastify-stripe` + +Fastify integration for Stripe: optional verified webhooks with a scoped raw-body parser, config typing against [`@prefabs.tech/fastify-config`](../config), and a small `StripeClient` helper around the Stripe Node SDK. + +## Why This Package? + +Handling Stripe correctly in Fastify means signature verification against the exact request bytes, encapsulating parsers so other JSON routes stay normal, and keeping secrets and URLs typed next to the rest of your app config. This package wires those pieces with the same `register(plugin, config.slice)` pattern as other prefabs plugins, so you do not rebuild webhook safety and checkout helpers in every service. + +## What You Get + +### `stripe` (Stripe Node SDK) — Partial passthrough / modified + +Most of the live API is available on the `Stripe` instance from `StripeClient` — see the [Stripe API reference](https://stripe.com/docs/api) and [stripe-node](https://github.com/stripe/stripe-node). This package modifies or narrows the surface in these ways: + +- **`new Stripe(...)`** — Constructed for you via `StripeClient` from `config.stripe.apiKey` and optional `clientConfig` passed through unchanged to the SDK constructor. +- **Checkout** — `createCheckoutSession` does not accept arbitrary `SessionCreateParams`; it takes a fixed `CreateSessionInput` shape and builds the session payload with opinionated defaults (URLs, currency, line item, metadata by mode). +- **Promotion codes** — `getActivePromotionCode` wraps `promotionCodes.list` with `active: true` and returns only the first match. +- **Webhooks** — Verification uses `Stripe.webhooks.constructEvent` on `request.rawBody` and the `stripe-signature` header; no extra transformation beyond that. + +### `@prefabs.tech/fastify-config` — Modified (integration only) + +The package augments `ApiConfig` from [`@prefabs.tech/fastify-config`](../config) with optional `stripe?: StripeConfig`. `StripeClient` reads `config.stripe` from a full `ApiConfig` instance; it does not replace the config system. + +### Added by This Package + +- **Optional webhook route** — `POST` on `webhookPath` or default `ROUTE_STRIPE_WEBHOOK` (`/payment/webhook`), with signature preHandler and user or safe default handler. +- **Scoped raw-body JSON parser** — Webhook controller registers `application/json` parsing with `request.rawBody` only inside its encapsulation; `registerRawBodyParser` is exported for your own routes. +- **`StripeClient`** — SDK instance plus `createCheckoutSession` and `getActivePromotionCode`. +- **Types** — Exported `StripeConfig`, `StripeEvent`; request augmentations for `stripeEvent` and `rawBody` when you import the package. + +→ [Full feature list](FEATURES.md) | [Developer guide](GUIDE.md) + +## Usage Guidelines + +- **Pass real options** — Register with the same `config.stripe` object you put on `ApiConfig`. Empty or missing options throw at register time so misconfiguration fails fast. +- **Webhooks need the secret** — If you enable the webhook route, configure `webhookSecret` for production verification; without it, the preHandler responds with 400 (see [FEATURES.md](FEATURES.md)). +- **Parser scope** — Enabling the webhook installs the raw-body JSON parser only on the webhook sub-scope. Other routes keep the parent’s JSON parser. For a custom route that must verify Stripe (or needs `rawBody`), call `registerRawBodyParser` on that scope — do not assume `rawBody` exists globally. +- **Custom handler vs default** — If `enablePaymentWebhook` is true and you omit `handlers.webhook`, registration logs a warning and the default handler acknowledges events so Stripe does not retry forever; you still need a custom handler to implement your domain logic. + +```typescript +// Avoid: registering with no stripe slice +await fastify.register(stripePlugin, {}); + +// Correct: same object as config.stripe +await fastify.register(stripePlugin, config.stripe); +``` + +## Requirements + +Install and register [`@prefabs.tech/fastify-config`](../config) first. Peers (see `package.json`): `fastify` >= 5.2.2, `fastify-plugin` >= 5.0.1, `@prefabs.tech/fastify-config` 0.94.0. Node >= 20. + +## Quick Start + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import stripePlugin from "@prefabs.tech/fastify-stripe"; +import Fastify from "fastify"; + +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + stripe: { + apiKey: process.env.STRIPE_API_KEY!, + defaultCurrency: "usd", + enablePaymentWebhook: true, + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, + urls: { success: "https://example.com/success", cancel: "https://example.com/cancel" }, + handlers: { + webhook: async (_request, event) => { + /* handle event.type */ + }, + }, + }, +}; + +const app = Fastify({ logger: true }); +await app.register(configPlugin, { config }); +await app.register(stripePlugin, config.stripe); +await app.listen({ port: 3000, host: "0.0.0.0" }); +``` + +## Installation + +Install with npm: + +```bash +npm install @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config fastify fastify-plugin +``` + +Install with pnpm: + +```bash +pnpm add @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config fastify fastify-plugin +``` + +The `stripe` package is bundled as a dependency of `@prefabs.tech/fastify-stripe`; add it separately only if you use the SDK outside this plugin. diff --git a/packages/stripe/eslint.config.js b/packages/stripe/eslint.config.js new file mode 100644 index 000000000..48a1291a4 --- /dev/null +++ b/packages/stripe/eslint.config.js @@ -0,0 +1,3 @@ +import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; + +export default fastifyConfig; diff --git a/packages/stripe/package.json b/packages/stripe/package.json new file mode 100644 index 000000000..3b6b883ed --- /dev/null +++ b/packages/stripe/package.json @@ -0,0 +1,58 @@ +{ + "name": "@prefabs.tech/fastify-stripe", + "version": "0.94.0", + "description": "Fastify stripe plugin", + "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/stripe#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/prefabs-tech/fastify.git", + "directory": "packages/stripe" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "import": "./dist/prefabs-tech-fastify-stripe.js", + "require": "./dist/prefabs-tech-fastify-stripe.cjs" + } + }, + "main": "./dist/prefabs-tech-fastify-stripe.cjs", + "module": "./dist/prefabs-tech-fastify-stripe.js", + "types": "./dist/types/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "sort-package": "npx sort-package-json", + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit -p tsconfig.json --composite false" + }, + "dependencies": { + "stripe": "20.3.1" + }, + "devDependencies": { + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/tsconfig": "0.7.0", + "@types/node": "24.10.15", + "@vitest/coverage-istanbul": "3.2.4", + "eslint": "9.39.4", + "fastify": "5.8.5", + "fastify-plugin": "5.1.0", + "prettier": "3.8.3", + "typescript": "5.9.3", + "vite": "6.4.2", + "vitest": "3.2.4" + }, + "peerDependencies": { + "@prefabs.tech/fastify-config": "0.94.0", + "fastify": ">=5.2.2", + "fastify-plugin": ">=5.0.1" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/stripe/src/__test__/constants.test.ts b/packages/stripe/src/__test__/constants.test.ts new file mode 100644 index 000000000..41922bb3a --- /dev/null +++ b/packages/stripe/src/__test__/constants.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { ROUTE_STRIPE_WEBHOOK } from "../constants"; + +describe("constants", () => { + it("ROUTE_STRIPE_WEBHOOK is /payment/webhook", () => { + expect(ROUTE_STRIPE_WEBHOOK).toBe("/payment/webhook"); + }); +}); diff --git a/packages/stripe/src/__test__/helpers/createStripeConfig.ts b/packages/stripe/src/__test__/helpers/createStripeConfig.ts new file mode 100644 index 000000000..c2723d102 --- /dev/null +++ b/packages/stripe/src/__test__/helpers/createStripeConfig.ts @@ -0,0 +1,19 @@ +import type { StripeConfig } from "../../types"; + +const createStripeConfig = ( + overrides: Partial = {}, +): StripeConfig => { + return { + apiKey: "sk_test_dummy", + defaultCurrency: "usd", + enablePaymentWebhook: false, + urls: { + cancel: "https://example.com/cancel", + success: "https://example.com/success", + }, + webhookSecret: "whsec_test_dummy", + ...overrides, + }; +}; + +export default createStripeConfig; diff --git a/packages/stripe/src/__test__/index.test.ts b/packages/stripe/src/__test__/index.test.ts new file mode 100644 index 000000000..f2576fe52 --- /dev/null +++ b/packages/stripe/src/__test__/index.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import * as packageExports from "../index"; + +describe("package exports", () => { + it("re-exports ROUTE_STRIPE_WEBHOOK", () => { + expect(packageExports.ROUTE_STRIPE_WEBHOOK).toBe("/payment/webhook"); + }); + + it("re-exports StripeClient as a class", () => { + expect(packageExports.StripeClient).toBeDefined(); + expect(typeof packageExports.StripeClient).toBe("function"); + }); + + it("re-exports registerRawBodyParser", () => { + expect(packageExports.registerRawBodyParser).toBeDefined(); + expect(typeof packageExports.registerRawBodyParser).toBe("function"); + }); + + it("exposes the plugin as the default export", () => { + expect(packageExports.default).toBeDefined(); + expect(typeof packageExports.default).toBe("function"); + }); +}); diff --git a/packages/stripe/src/__test__/plugin.test.ts b/packages/stripe/src/__test__/plugin.test.ts new file mode 100644 index 000000000..5149be3fb --- /dev/null +++ b/packages/stripe/src/__test__/plugin.test.ts @@ -0,0 +1,136 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StripeConfig } from "../types"; + +import "../index"; +import createStripeConfig from "./helpers/createStripeConfig"; + +const { stripeMock } = vi.hoisted(() => { + const stripeMock = vi.fn().mockImplementation(() => ({ + checkout: { sessions: { create: vi.fn() } }, + promotionCodes: { list: vi.fn() }, + webhooks: { constructEvent: vi.fn() }, + })); + return { stripeMock }; +}); + +vi.mock("stripe", () => ({ default: stripeMock })); + +describe("stripePlugin — missing configuration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: { level: "silent" } }); + fastify.decorate("config", {} as unknown as FastifyInstance["config"]); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("throws when register is called without options and config.stripe is absent", async () => { + await expect(fastify.register(plugin)).rejects.toThrow( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", + ); + }); + + it("throws when register is called with an empty options object and config.stripe is absent", async () => { + await expect(fastify.register(plugin, {} as StripeConfig)).rejects.toThrow( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", + ); + }); + + it("throws when register is called without options even if fastify.config.stripe is set", async () => { + const app = Fastify({ logger: { level: "silent" } }); + app.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + } as unknown as FastifyInstance["config"]); + + await expect(app.register(plugin)).rejects.toThrow( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", + ); + await app.close(); + }); +}); + +describe("stripePlugin — configuration present", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: { level: "silent" } }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("logs 'Registering Stripe plugin' at info level when config is passed", async () => { + const infoSpy = vi.spyOn(fastify.log, "info"); + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: false }), + ); + await fastify.ready(); + + expect(infoSpy).toHaveBeenCalledWith("Registering Stripe plugin"); + }); + + it("does not register the webhook route when enablePaymentWebhook is false", async () => { + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: false }), + ); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + false, + ); + }); + + it("registers the webhook route when enablePaymentWebhook is true", async () => { + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + true, + ); + }); +}); + +describe("stripePlugin — fastify-plugin wrapping", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("registers without encapsulation so the route is reachable on the top-level instance", async () => { + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + true, + ); + }); +}); diff --git a/packages/stripe/src/__test__/stripeClient.test.ts b/packages/stripe/src/__test__/stripeClient.test.ts new file mode 100644 index 000000000..7a6f12494 --- /dev/null +++ b/packages/stripe/src/__test__/stripeClient.test.ts @@ -0,0 +1,344 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import "../index"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StripeConfig } from "../types"; + +import createStripeConfig from "./helpers/createStripeConfig"; + +const buildApiConfig = ( + stripeOverrides: Partial = {}, +): ApiConfig => + ({ stripe: createStripeConfig(stripeOverrides) }) as unknown as ApiConfig; + +const { promotionCodesListMock, sessionsCreateMock, stripeMock } = vi.hoisted( + () => { + const sessionsCreateMock = vi.fn(); + const promotionCodesListMock = vi.fn(); + const stripeMock = vi.fn().mockImplementation(() => ({ + checkout: { sessions: { create: sessionsCreateMock } }, + promotionCodes: { list: promotionCodesListMock }, + webhooks: { constructEvent: vi.fn() }, + })); + return { promotionCodesListMock, sessionsCreateMock, stripeMock }; + }, +); + +vi.mock("stripe", () => ({ default: stripeMock })); + +describe("StripeClient — constructor", async () => { + const { default: StripeClient } = await import("../utils/stripeClient"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("instantiates the Stripe SDK with apiKey and clientConfig", () => { + const clientConfig = { apiVersion: "2025-12-15.clover" as const }; + new StripeClient(buildApiConfig({ apiKey: "sk_custom", clientConfig })); + + expect(stripeMock).toHaveBeenCalledTimes(1); + expect(stripeMock).toHaveBeenCalledWith("sk_custom", clientConfig); + }); + + it("forwards clientConfig unmodified (undefined when not set)", () => { + new StripeClient(buildApiConfig()); + + expect(stripeMock).toHaveBeenCalledWith( + "sk_test_dummy", + undefined as unknown as undefined, + ); + }); + + it("exposes the raw Stripe SDK instance via client.stripe", () => { + const client = new StripeClient(buildApiConfig()); + + expect(client.stripe).toBeDefined(); + expect(client.stripe.checkout.sessions.create).toBe(sessionsCreateMock); + }); + + it("throws when constructed with an ApiConfig that has no `stripe` block", () => { + expect( + () => + new StripeClient({} as unknown as Parameters[0]), + ).toThrow( + "StripeClient requires config.stripe to be set on the provided ApiConfig.", + ); + }); +}); + +describe("StripeClient — createCheckoutSession synthesis", async () => { + const { default: StripeClient } = await import("../utils/stripeClient"); + + beforeEach(() => { + vi.clearAllMocks(); + sessionsCreateMock.mockResolvedValue({ id: "cs_test" }); + }); + + const buildClient = (stripeOverrides: Partial = {}) => + new StripeClient(buildApiConfig(stripeOverrides)); + + it("builds exactly one line_items entry from productName, unitAmount, quantity, and currency", async () => { + const client = buildClient(); + await client.createCheckoutSession({ + currency: "eur", + productName: "Hat", + quantity: 3, + unitAmount: 1500, + }); + + const arguments_ = sessionsCreateMock.mock.calls[0][0]; + expect(arguments_.line_items).toHaveLength(1); + expect(arguments_.line_items[0]).toEqual({ + price_data: { + currency: "eur", + product_data: { name: "Hat" }, + unit_amount: 1500, + }, + quantity: 3, + }); + }); + + it("defaults quantity to 1 when input.quantity is unset", async () => { + const client = buildClient(); + await client.createCheckoutSession({ + productName: "Hat", + unitAmount: 1500, + }); + + expect(sessionsCreateMock.mock.calls[0][0].line_items[0].quantity).toBe(1); + }); + + it("defaults mode to 'payment' when input.mode is unset", async () => { + const client = buildClient(); + await client.createCheckoutSession({ + productName: "Hat", + unitAmount: 1500, + }); + + expect(sessionsCreateMock.mock.calls[0][0].mode).toBe("payment"); + }); + + it("forwards input.mode when set", async () => { + const client = buildClient(); + await client.createCheckoutSession({ + mode: "subscription", + productName: "Plan", + unitAmount: 999, + }); + + expect(sessionsCreateMock.mock.calls[0][0].mode).toBe("subscription"); + }); + + it("defaults currency to config.stripe.defaultCurrency when input.currency is unset", async () => { + const client = buildClient({ defaultCurrency: "gbp" }); + await client.createCheckoutSession({ + productName: "Hat", + unitAmount: 1500, + }); + + expect( + sessionsCreateMock.mock.calls[0][0].line_items[0].price_data.currency, + ).toBe("gbp"); + }); + + it("defaults success_url to config.stripe.urls.success when input.successUrl is unset", async () => { + const client = buildClient({ + urls: { + cancel: "https://example.com/cancel", + success: "https://example.com/configured-success", + }, + }); + await client.createCheckoutSession({ + productName: "Hat", + unitAmount: 1500, + }); + + expect(sessionsCreateMock.mock.calls[0][0].success_url).toBe( + "https://example.com/configured-success", + ); + }); + + it("uses input.successUrl when provided", async () => { + const client = buildClient(); + await client.createCheckoutSession({ + productName: "Hat", + successUrl: "https://override.example/success", + unitAmount: 1500, + }); + + expect(sessionsCreateMock.mock.calls[0][0].success_url).toBe( + "https://override.example/success", + ); + }); + + it("defaults cancel_url to config.stripe.urls.cancel when input.cancelUrl is unset", async () => { + const client = buildClient({ + urls: { + cancel: "https://example.com/configured-cancel", + success: "https://example.com/success", + }, + }); + await client.createCheckoutSession({ + productName: "Hat", + unitAmount: 1500, + }); + + expect(sessionsCreateMock.mock.calls[0][0].cancel_url).toBe( + "https://example.com/configured-cancel", + ); + }); + + it("uses input.cancelUrl when provided", async () => { + const client = buildClient(); + await client.createCheckoutSession({ + cancelUrl: "https://override.example/cancel", + productName: "Hat", + unitAmount: 1500, + }); + + expect(sessionsCreateMock.mock.calls[0][0].cancel_url).toBe( + "https://override.example/cancel", + ); + }); + + it("forwards config.stripe.allowPromotionCodes as allow_promotion_codes", async () => { + const client = buildClient({ allowPromotionCodes: true }); + await client.createCheckoutSession({ + productName: "Hat", + unitAmount: 1500, + }); + + expect(sessionsCreateMock.mock.calls[0][0].allow_promotion_codes).toBe( + true, + ); + }); + + it("passes allow_promotion_codes as undefined when allowPromotionCodes is unset", async () => { + const client = buildClient(); + await client.createCheckoutSession({ + productName: "Hat", + unitAmount: 1500, + }); + + expect( + sessionsCreateMock.mock.calls[0][0].allow_promotion_codes, + ).toBeUndefined(); + }); + + it("writes metadata onto both session.metadata and payment_intent_data.metadata for payment mode", async () => { + const client = buildClient(); + const metadata = { orderId: "ord_123", userId: "u_42" }; + await client.createCheckoutSession( + { productName: "Hat", unitAmount: 1500 }, + metadata, + ); + + const arguments_ = sessionsCreateMock.mock.calls[0][0]; + expect(arguments_.metadata).toEqual(metadata); + expect(arguments_.payment_intent_data.metadata).toEqual(metadata); + expect(arguments_.subscription_data).toBeUndefined(); + expect(arguments_.setup_intent_data).toBeUndefined(); + }); + + it("omits session.metadata and the mode-specific *_data block entirely when metadata is not provided", async () => { + const client = buildClient(); + await client.createCheckoutSession({ + productName: "Hat", + unitAmount: 1500, + }); + + const arguments_ = sessionsCreateMock.mock.calls[0][0]; + expect(arguments_.metadata).toBeUndefined(); + expect(arguments_.payment_intent_data).toBeUndefined(); + expect(arguments_.subscription_data).toBeUndefined(); + expect(arguments_.setup_intent_data).toBeUndefined(); + }); + + it("routes metadata to subscription_data and omits payment_intent_data for subscription mode", async () => { + const client = buildClient(); + const metadata = { plan: "annual", userId: "u_42" }; + await client.createCheckoutSession( + { mode: "subscription", productName: "Plan", unitAmount: 9900 }, + metadata, + ); + + const arguments_ = sessionsCreateMock.mock.calls[0][0]; + expect(arguments_.metadata).toEqual(metadata); + expect(arguments_.subscription_data.metadata).toEqual(metadata); + expect(arguments_.payment_intent_data).toBeUndefined(); + expect(arguments_.setup_intent_data).toBeUndefined(); + }); + + it("routes metadata to setup_intent_data and omits payment_intent_data for setup mode", async () => { + const client = buildClient(); + const metadata = { customerId: "cus_123" }; + await client.createCheckoutSession( + { mode: "setup", productName: "Card setup", unitAmount: 0 }, + metadata, + ); + + const arguments_ = sessionsCreateMock.mock.calls[0][0]; + expect(arguments_.metadata).toEqual(metadata); + expect(arguments_.setup_intent_data.metadata).toEqual(metadata); + expect(arguments_.payment_intent_data).toBeUndefined(); + expect(arguments_.subscription_data).toBeUndefined(); + }); + + it("returns the session returned by stripe.checkout.sessions.create", async () => { + const sessionResponse = { id: "cs_returned", url: "https://stripe.test" }; + sessionsCreateMock.mockResolvedValueOnce(sessionResponse); + const client = buildClient(); + + const result = await client.createCheckoutSession({ + productName: "Hat", + unitAmount: 1500, + }); + + expect(result).toBe(sessionResponse); + }); +}); + +describe("StripeClient — getActivePromotionCode", async () => { + const { default: StripeClient } = await import("../utils/stripeClient"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const buildClient = () => new StripeClient(buildApiConfig()); + + it("calls stripe.promotionCodes.list with { active: true, code }", async () => { + promotionCodesListMock.mockResolvedValue({ data: [] }); + const client = buildClient(); + + await client.getActivePromotionCode("SUMMER10"); + + expect(promotionCodesListMock).toHaveBeenCalledWith({ + active: true, + code: "SUMMER10", + }); + }); + + it("returns the first matching promotion code when results exist", async () => { + const promo = { active: true, code: "SUMMER10", id: "promo_1" }; + promotionCodesListMock.mockResolvedValue({ + data: [promo, { id: "promo_2" }], + }); + + const client = buildClient(); + const result = await client.getActivePromotionCode("SUMMER10"); + + expect(result).toBe(promo); + }); + + it("returns undefined when no promotion code matches", async () => { + promotionCodesListMock.mockResolvedValue({ data: [] }); + const client = buildClient(); + + const result = await client.getActivePromotionCode("UNKNOWN"); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/stripe/src/__test__/stripeRawBodyParser.test.ts b/packages/stripe/src/__test__/stripeRawBodyParser.test.ts new file mode 100644 index 000000000..f7ac1a634 --- /dev/null +++ b/packages/stripe/src/__test__/stripeRawBodyParser.test.ts @@ -0,0 +1,123 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import registerRawBodyParser from "../utils/stripeRawBodyParser"; +import createStripeConfig from "./helpers/createStripeConfig"; + +const { stripeMock } = vi.hoisted(() => { + const stripeMock = vi.fn().mockImplementation(() => ({ + webhooks: { constructEvent: vi.fn() }, + })); + return { stripeMock }; +}); + +vi.mock("stripe", () => ({ default: stripeMock })); + +describe("stripeRawBodyParser — direct registration", () => { + let fastify: FastifyInstance; + + beforeEach(() => { + fastify = Fastify({ logger: false }); + registerRawBodyParser(fastify); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("captures the raw buffer onto request.rawBody for application/json POSTs", async () => { + let capturedRawBody: unknown; + fastify.post("/test", async (request) => { + capturedRawBody = request.rawBody; + return { ok: true }; + }); + + const payload = JSON.stringify({ hello: "world" }); + const res = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload, + url: "/test", + }); + + expect(res.statusCode).toBe(200); + expect(Buffer.isBuffer(capturedRawBody)).toBe(true); + expect((capturedRawBody as Buffer).toString()).toBe(payload); + }); + + it("parses the JSON body so downstream handlers see request.body normally", async () => { + let receivedBody: unknown; + fastify.post("/test", async (request) => { + receivedBody = request.body; + return { ok: true }; + }); + + await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ foo: "bar", n: 42 }), + url: "/test", + }); + + expect(receivedBody).toEqual({ foo: "bar", n: 42 }); + }); + + it("responds with 400 on invalid JSON and does not invoke the route handler", async () => { + const handlerSpy = vi.fn().mockReturnValue({ ok: true }); + fastify.post("/test", async () => handlerSpy()); + + const res = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: "not-json", + url: "/test", + }); + + expect(res.statusCode).toBe(400); + expect(handlerSpy).not.toHaveBeenCalled(); + }); +}); + +describe("stripeRawBodyParser — scoping when installed by the webhook controller", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("does NOT apply the raw body parser to routes registered outside the webhook controller scope", async () => { + // The webhook controller is registered without `fastify-plugin`, so its + // content-type parser is encapsulated to the controller's plugin scope + // and does NOT bleed into the parent instance. + fastify = Fastify({ logger: false }); + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); + + let unrelatedRawBody: unknown; + fastify.post("/some/other/route", async (request) => { + unrelatedRawBody = request.rawBody; + return { ok: true }; + }); + + await fastify.ready(); + + const res = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ unrelated: true }), + url: "/some/other/route", + }); + + expect(res.statusCode).toBe(200); + expect(unrelatedRawBody).toBeUndefined(); + }); +}); diff --git a/packages/stripe/src/__test__/verifyStripeSignature.test.ts b/packages/stripe/src/__test__/verifyStripeSignature.test.ts new file mode 100644 index 000000000..840289661 --- /dev/null +++ b/packages/stripe/src/__test__/verifyStripeSignature.test.ts @@ -0,0 +1,339 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StripeConfig } from "../types"; + +import "../index"; +import createStripeConfig from "./helpers/createStripeConfig"; + +const { constructEventMock, stripeMock } = vi.hoisted(() => { + const constructEventMock = vi.fn(); + const stripeMock = Object.assign(vi.fn(), { + webhooks: { constructEvent: constructEventMock }, + }); + return { constructEventMock, stripeMock }; +}); + +vi.mock("stripe", () => ({ default: stripeMock })); + +const SAMPLE_EVENT = { + id: "evt_test_1", + object: "event", + type: "checkout.session.completed", +}; + +const registerWithStripe = async ( + fastify: FastifyInstance, + plugin: typeof import("../plugin").default, + overrides: Partial = {}, +) => { + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true, ...overrides }), + ); + await fastify.ready(); +}; + +describe("verifyStripeSignature — webhookSecret missing", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + constructEventMock.mockReturnValue(SAMPLE_EVENT); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("responds with 400 and 'Webhook secret not configured' when webhookSecret is unset", async () => { + fastify = Fastify({ logger: { level: "silent" } }); + + await registerWithStripe(fastify, plugin, { + webhookSecret: undefined, + }); + + const res = await fastify.inject({ + headers: { + "content-type": "application/json", + "stripe-signature": "t=1,v1=sig", + }, + method: "POST", + payload: JSON.stringify({}), + url: "/payment/webhook", + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "Webhook secret not configured" }); + }); + + it("logs an error when webhookSecret is unset", async () => { + fastify = Fastify({ logger: { level: "silent" } }); + const errorSpy = vi.spyOn(fastify.log, "error"); + + await registerWithStripe(fastify, plugin, { + webhookSecret: undefined, + }); + + await fastify.inject({ + headers: { + "content-type": "application/json", + "stripe-signature": "t=1,v1=sig", + }, + method: "POST", + payload: JSON.stringify({}), + url: "/payment/webhook", + }); + + expect(errorSpy).toHaveBeenCalledWith( + "Stripe webhook secret is not configured; rejecting webhook request.", + ); + }); +}); + +describe("verifyStripeSignature — signature header missing", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + constructEventMock.mockReturnValue(SAMPLE_EVENT); + fastify = Fastify({ logger: { level: "silent" } }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("responds with 400 and 'Missing stripe-signature header' when the header is absent", async () => { + await registerWithStripe(fastify, plugin); + + const res = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({}), + url: "/payment/webhook", + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "Missing stripe-signature header" }); + }); + + it("does not invoke stripe.webhooks.constructEvent when the signature header is missing", async () => { + await registerWithStripe(fastify, plugin); + + await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({}), + url: "/payment/webhook", + }); + + expect(constructEventMock).not.toHaveBeenCalled(); + }); +}); + +describe("verifyStripeSignature — signature verification failure", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: { level: "silent" } }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("responds with 400 and 'Webhook signature verification failed' when constructEvent throws", async () => { + constructEventMock.mockImplementation(() => { + throw new Error("invalid signature"); + }); + + await registerWithStripe(fastify, plugin); + + const res = await fastify.inject({ + headers: { + "content-type": "application/json", + "stripe-signature": "t=1,v1=sig", + }, + method: "POST", + payload: JSON.stringify({}), + url: "/payment/webhook", + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ + error: "Webhook signature verification failed", + }); + }); + + it("logs the underlying error when constructEvent throws", async () => { + const verificationError = new Error("invalid signature"); + constructEventMock.mockImplementation(() => { + throw verificationError; + }); + const errorSpy = vi.spyOn(fastify.log, "error"); + + await registerWithStripe(fastify, plugin); + + await fastify.inject({ + headers: { + "content-type": "application/json", + "stripe-signature": "t=1,v1=sig", + }, + method: "POST", + payload: JSON.stringify({}), + url: "/payment/webhook", + }); + + expect(errorSpy).toHaveBeenCalledWith( + { err: verificationError }, + "Stripe webhook signature verification failed", + ); + }); +}); + +describe("verifyStripeSignature — success", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + let capturedEvent: unknown; + const webhookHandlerMock = vi.fn(async (_request, event) => { + capturedEvent = event; + }); + + beforeEach(() => { + vi.clearAllMocks(); + capturedEvent = undefined; + constructEventMock.mockReturnValue(SAMPLE_EVENT); + + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("attaches the verified Stripe.Event to the request before the route handler runs", async () => { + await registerWithStripe(fastify, plugin, { + handlers: { webhook: webhookHandlerMock }, + }); + + const res = await fastify.inject({ + headers: { + "content-type": "application/json", + "stripe-signature": "t=1,v1=sig", + }, + method: "POST", + payload: JSON.stringify({}), + url: "/payment/webhook", + }); + + expect(res.statusCode).toBe(200); + expect(capturedEvent).toEqual(SAMPLE_EVENT); + }); + + it("calls stripe.webhooks.constructEvent with the raw body, signature, and configured secret", async () => { + await registerWithStripe(fastify, plugin, { + handlers: { webhook: webhookHandlerMock }, + }); + + const payload = JSON.stringify({ id: "evt_test" }); + await fastify.inject({ + headers: { + "content-type": "application/json", + "stripe-signature": "t=1,v1=expected", + }, + method: "POST", + payload, + url: "/payment/webhook", + }); + + expect(constructEventMock).toHaveBeenCalledTimes(1); + const [rawBody, signature, secret] = constructEventMock.mock.calls[0]; + expect(Buffer.isBuffer(rawBody)).toBe(true); + expect(rawBody.toString()).toBe(payload); + expect(signature).toBe("t=1,v1=expected"); + expect(secret).toBe("whsec_test_dummy"); + }); +}); + +const buildBareRequest = () => ({ + headers: { "stripe-signature": "t=1,v1=sig" }, + rawBody: undefined, + server: { + config: { stripe: createStripeConfig() }, + log: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + }, +}); + +const buildBareReply = () => ({ + send: vi.fn().mockReturnThis(), + status: vi.fn().mockReturnThis(), +}); + +describe("createVerifyStripeSignature — raw body missing", async () => { + // The raw body parser is installed by the webhook controller, so it is + // always set on requests that hit the route via fastify.inject. To exercise + // the `if (!rawBody)` branch we call the middleware directly with a request + // that has no rawBody. + const { createVerifyStripeSignature } = + await import("../middlewares/verifyStripeSignature"); + + const verifyStripeSignature = + createVerifyStripeSignature(createStripeConfig()); + + type VerifyArguments = Parameters; + + beforeEach(() => { + vi.clearAllMocks(); + constructEventMock.mockReturnValue(SAMPLE_EVENT); + }); + + it("responds with 400 and 'Raw body is not available for signature verification' when rawBody is unset", async () => { + const request = buildBareRequest(); + const reply = buildBareReply(); + + await verifyStripeSignature( + request as unknown as VerifyArguments[0], + reply as unknown as VerifyArguments[1], + ); + + expect(reply.status).toHaveBeenCalledWith(400); + expect(reply.send).toHaveBeenCalledWith({ + error: "Raw body is not available for signature verification", + }); + }); + + it("logs an error when rawBody is unset", async () => { + const request = buildBareRequest(); + const reply = buildBareReply(); + + await verifyStripeSignature( + request as unknown as VerifyArguments[0], + reply as unknown as VerifyArguments[1], + ); + + expect(request.server.log.error).toHaveBeenCalledWith( + "Raw body is not available for signature verification", + ); + }); + + it("does not call constructEvent when rawBody is unset", async () => { + const request = buildBareRequest(); + const reply = buildBareReply(); + + await verifyStripeSignature( + request as unknown as VerifyArguments[0], + reply as unknown as VerifyArguments[1], + ); + + expect(constructEventMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/stripe/src/__test__/webhookController.test.ts b/packages/stripe/src/__test__/webhookController.test.ts new file mode 100644 index 000000000..59ea30d3a --- /dev/null +++ b/packages/stripe/src/__test__/webhookController.test.ts @@ -0,0 +1,245 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import createStripeConfig from "./helpers/createStripeConfig"; + +const { constructEventMock, stripeMock } = vi.hoisted(() => { + const constructEventMock = vi.fn(); + const stripeMock = Object.assign(vi.fn(), { + webhooks: { constructEvent: constructEventMock }, + }); + return { constructEventMock, stripeMock }; +}); + +vi.mock("stripe", () => ({ default: stripeMock })); + +const SAMPLE_EVENT = { + data: { object: { id: "cs_test_1" } }, + id: "evt_test_1", + object: "event", + type: "checkout.session.completed", +}; + +const injectWebhook = ( + fastify: FastifyInstance, + url: string, + payload?: Record, +) => + fastify.inject({ + headers: { + "content-type": "application/json", + "stripe-signature": "t=1,v1=sig", + }, + method: "POST", + payload: JSON.stringify(payload ?? { id: "evt_test_1" }), + url, + }); + +describe("webhookController — route registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + constructEventMock.mockReturnValue(SAMPLE_EVENT); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("registers POST at /payment/webhook by default when webhookPath is unset", async () => { + fastify = Fastify({ logger: false }); + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + true, + ); + }); + + it("registers POST at the configured webhookPath when set", async () => { + fastify = Fastify({ logger: false }); + + await fastify.register( + plugin, + createStripeConfig({ + enablePaymentWebhook: true, + webhookPath: "/custom/webhook", + }), + ); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "POST", url: "/custom/webhook" })).toBe( + true, + ); + }); + + it("logs 'Registering Stripe webhook route' at info level", async () => { + fastify = Fastify({ logger: { level: "silent" } }); + const infoSpy = vi.spyOn(fastify.log, "info"); + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); + await fastify.ready(); + + expect(infoSpy).toHaveBeenCalledWith("Registering Stripe webhook route"); + }); +}); + +describe("webhookController — dispatch", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + constructEventMock.mockReturnValue(SAMPLE_EVENT); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("invokes config.stripe.handlers.webhook with request and verified event", async () => { + const webhookHandlerMock = vi.fn().mockResolvedValue(); + fastify = Fastify({ logger: false }); + + await fastify.register( + plugin, + createStripeConfig({ + enablePaymentWebhook: true, + handlers: { webhook: webhookHandlerMock }, + }), + ); + await fastify.ready(); + + const res = await injectWebhook(fastify, "/payment/webhook"); + + expect(res.statusCode).toBe(200); + expect(webhookHandlerMock).toHaveBeenCalledTimes(1); + expect(webhookHandlerMock.mock.calls[0][1]).toEqual(SAMPLE_EVENT); + }); + + it("responds 200 with the default fallback handler when no custom handler is configured (to suppress Stripe retries)", async () => { + fastify = Fastify({ logger: false }); + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); + await fastify.ready(); + + const res = await injectWebhook(fastify, "/payment/webhook"); + + expect(res.statusCode).toBe(200); + }); + + it("warns at registration time when enablePaymentWebhook is true but handlers.webhook is unset", async () => { + fastify = Fastify({ logger: { level: "silent" } }); + const warnSpy = vi.spyOn(fastify.log, "warn"); + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); + await fastify.ready(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("config.stripe.handlers.webhook is not set"), + ); + }); + + it("does NOT warn at registration time when handlers.webhook is configured", async () => { + const webhookHandlerMock = vi.fn().mockResolvedValue(); + fastify = Fastify({ logger: { level: "silent" } }); + const warnSpy = vi.spyOn(fastify.log, "warn"); + + await fastify.register( + plugin, + createStripeConfig({ + enablePaymentWebhook: true, + handlers: { webhook: webhookHandlerMock }, + }), + ); + await fastify.ready(); + + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("config.stripe.handlers.webhook is not set"), + ); + }); + + it("does not call the default handler when handlers.webhook is configured", async () => { + const webhookHandlerMock = vi.fn().mockResolvedValue(); + fastify = Fastify({ logger: false }); + + await fastify.register( + plugin, + createStripeConfig({ + enablePaymentWebhook: true, + handlers: { webhook: webhookHandlerMock }, + }), + ); + await fastify.ready(); + + const res = await injectWebhook(fastify, "/payment/webhook"); + + expect(res.statusCode).toBe(200); + expect(webhookHandlerMock).toHaveBeenCalled(); + }); +}); + +describe("webhookController — defensive guards", async () => { + const { default: plugin } = await import("../plugin"); + const { default: webhookController } = await import("../webhook/controller"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("throws when the webhook controller is registered without stripeConfig", async () => { + fastify = Fastify({ logger: { level: "silent" } }); + + await expect(fastify.register(webhookController)).rejects.toThrow( + "Missing stripe configuration. Did you forget to pass { stripeConfig } to the Stripe webhook controller?", + ); + }); + + it("returns 500 with { error: 'Stripe event not found on request' } when preHandler did not attach the event", async () => { + // Force constructEvent to return a falsy value so verifyStripeSignature + // assigns `request.stripeEvent = undefined` and the controller's + // defensive guard fires. + constructEventMock.mockReturnValue( + undefined as unknown as ReturnType, + ); + + fastify = Fastify({ logger: false }); + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); + await fastify.ready(); + + const res = await injectWebhook(fastify, "/payment/webhook"); + + expect(res.statusCode).toBe(500); + expect(res.json()).toEqual({ + error: "Stripe event not found on request", + }); + }); +}); diff --git a/packages/stripe/src/__test__/webhookHandler.test.ts b/packages/stripe/src/__test__/webhookHandler.test.ts new file mode 100644 index 000000000..cc3348fdf --- /dev/null +++ b/packages/stripe/src/__test__/webhookHandler.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +import handleWebhook from "../webhook/handler"; + +const buildRequest = () => ({ + log: { error: vi.fn() }, +}); + +const buildEvent = () => ({ + id: "evt_test_1", + type: "checkout.session.completed", +}); + +describe("default webhookHandler (fallback)", () => { + it("resolves (does not throw) so the route responds 200 and Stripe stops retrying", async () => { + await expect( + handleWebhook( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + buildRequest() as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + buildEvent() as any, + ), + ).resolves.toBeUndefined(); + }); + + it("logs an error containing the event id and type when invoked", async () => { + const request = buildRequest(); + const event = buildEvent(); + + await handleWebhook( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event as any, + ); + + expect(request.log.error).toHaveBeenCalledTimes(1); + expect(request.log.error).toHaveBeenCalledWith( + { eventId: event.id, eventType: event.type }, + expect.stringContaining( + "Stripe webhook received but no handler is configured", + ), + ); + }); +}); diff --git a/packages/stripe/src/constants.ts b/packages/stripe/src/constants.ts new file mode 100644 index 000000000..dcb8ef299 --- /dev/null +++ b/packages/stripe/src/constants.ts @@ -0,0 +1 @@ +export const ROUTE_STRIPE_WEBHOOK = "/payment/webhook"; diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts new file mode 100644 index 000000000..1df23cc0e --- /dev/null +++ b/packages/stripe/src/index.ts @@ -0,0 +1,15 @@ +import Stripe from "stripe"; + +import { StripeConfig } from "./types"; + +declare module "@prefabs.tech/fastify-config" { + interface ApiConfig { + stripe?: StripeConfig; + } +} + +export * from "./constants"; +export { default } from "./plugin"; +export type { StripeConfig } from "./types"; +export type StripeEvent = Stripe.Event; +export * from "./utils"; diff --git a/packages/stripe/src/middlewares/verifyStripeSignature.ts b/packages/stripe/src/middlewares/verifyStripeSignature.ts new file mode 100644 index 000000000..6e2c69385 --- /dev/null +++ b/packages/stripe/src/middlewares/verifyStripeSignature.ts @@ -0,0 +1,54 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import Stripe from "stripe"; + +import type { StripeConfig } from "../types"; + +export const createVerifyStripeSignature = + (stripeConfig: StripeConfig) => + async (request: FastifyRequest, reply: FastifyReply): Promise => { + const { log } = request.server; + const webhookSecret = stripeConfig.webhookSecret; + + if (!webhookSecret) { + log.error( + "Stripe webhook secret is not configured; rejecting webhook request.", + ); + + return reply.status(400).send({ error: "Webhook secret not configured" }); + } + + const signature = request.headers["stripe-signature"]; + + if (!signature) { + log.error("Missing stripe-signature header"); + + return reply + .status(400) + .send({ error: "Missing stripe-signature header" }); + } + + try { + const rawBody = request.rawBody; + + if (!rawBody) { + log.error("Raw body is not available for signature verification"); + + return reply.status(400).send({ + error: "Raw body is not available for signature verification", + }); + } + + const event = Stripe.webhooks.constructEvent( + rawBody, + signature, + webhookSecret, + ); + + request.stripeEvent = event; + } catch (error) { + log.error({ err: error }, "Stripe webhook signature verification failed"); + return reply + .status(400) + .send({ error: "Webhook signature verification failed" }); + } + }; diff --git a/packages/stripe/src/plugin.ts b/packages/stripe/src/plugin.ts new file mode 100644 index 000000000..47ec7610d --- /dev/null +++ b/packages/stripe/src/plugin.ts @@ -0,0 +1,26 @@ +import type { FastifyInstance, FastifyPluginAsync } from "fastify"; + +import FastifyPlugin from "fastify-plugin"; + +import type { StripeConfig } from "./types"; + +import webhookController from "./webhook/controller"; + +const plugin: FastifyPluginAsync = async ( + fastify: FastifyInstance, + options, +) => { + fastify.log.info("Registering Stripe plugin"); + + if (!options || Object.keys(options).length === 0) { + throw new Error( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", + ); + } + + if (options.enablePaymentWebhook) { + await fastify.register(webhookController, { stripeConfig: options }); + } +}; + +export default FastifyPlugin(plugin); diff --git a/packages/stripe/src/types/index.ts b/packages/stripe/src/types/index.ts new file mode 100644 index 000000000..c0f9d8f24 --- /dev/null +++ b/packages/stripe/src/types/index.ts @@ -0,0 +1,42 @@ +import type { FastifyPluginOptions } from "fastify"; + +import Stripe from "stripe"; + +import webhookHandler from "../webhook/handler"; + +declare module "fastify" { + interface FastifyRequest { + stripeEvent?: Stripe.Event; + } +} + +export type CreateSessionInput = { + cancelUrl?: string; + currency?: string; + mode?: Stripe.Checkout.SessionCreateParams.Mode; + productName: string; + quantity?: number; + successUrl?: string; + unitAmount: number; +}; + +export type StripeConfig = FastifyPluginOptions & { + allowPromotionCodes?: boolean; + apiKey: string; + clientConfig?: Stripe.StripeConfig; + defaultCurrency: string; + enablePaymentWebhook: boolean; + handlers?: { + webhook?: typeof webhookHandler; + }; + urls: { + cancel: string; + success: string; + }; + webhookPath?: string; + webhookSecret?: string; +}; + +export type WebhookControllerOptions = FastifyPluginOptions & { + stripeConfig: StripeConfig; +}; diff --git a/packages/stripe/src/utils/index.ts b/packages/stripe/src/utils/index.ts new file mode 100644 index 000000000..1fc71db55 --- /dev/null +++ b/packages/stripe/src/utils/index.ts @@ -0,0 +1,2 @@ +export { default as StripeClient } from "./stripeClient"; +export { default as registerRawBodyParser } from "./stripeRawBodyParser"; diff --git a/packages/stripe/src/utils/stripeClient.ts b/packages/stripe/src/utils/stripeClient.ts new file mode 100644 index 000000000..1f3a6fff0 --- /dev/null +++ b/packages/stripe/src/utils/stripeClient.ts @@ -0,0 +1,85 @@ +import { ApiConfig } from "@prefabs.tech/fastify-config"; +import Stripe from "stripe"; + +import { CreateSessionInput, StripeConfig } from "../types"; + +class StripeClient { + public stripe: Stripe; + protected _config: ApiConfig; + protected _stripeConfig: StripeConfig; + + constructor(config: ApiConfig) { + if (!config.stripe) { + throw new Error( + "StripeClient requires config.stripe to be set on the provided ApiConfig.", + ); + } + + this._config = config; + this._stripeConfig = config.stripe; + this.stripe = new Stripe(config.stripe.apiKey, config.stripe.clientConfig); + } + + public async createCheckoutSession( + input: CreateSessionInput, + metadata?: Record, + ): Promise> { + const mode = input.mode ?? "payment"; + + const parameters: Stripe.Checkout.SessionCreateParams = { + allow_promotion_codes: this._stripeConfig.allowPromotionCodes, + cancel_url: input.cancelUrl ?? this._stripeConfig.urls.cancel, + line_items: [ + { + price_data: { + currency: input.currency ?? this._stripeConfig.defaultCurrency, + product_data: { + name: input.productName, + }, + unit_amount: input.unitAmount, + }, + quantity: input.quantity ?? 1, + }, + ], + mode, + success_url: input.successUrl ?? this._stripeConfig.urls.success, + }; + + // Only populate metadata fields when metadata was actually supplied. + // Stripe rejects mode-specific `*_data` blocks for the wrong mode, so + // route metadata to the field that matches the selected mode. + if (metadata) { + parameters.metadata = metadata; + + switch (mode) { + case "payment": { + parameters.payment_intent_data = { metadata }; + break; + } + case "setup": { + parameters.setup_intent_data = { metadata }; + break; + } + case "subscription": { + parameters.subscription_data = { metadata }; + break; + } + } + } + + return this.stripe.checkout.sessions.create(parameters); + } + + public async getActivePromotionCode( + code: string, + ): Promise { + const codes = await this.stripe.promotionCodes.list({ + active: true, + code: code, + }); + + return codes.data[0]; + } +} + +export default StripeClient; diff --git a/packages/stripe/src/utils/stripeRawBodyParser.ts b/packages/stripe/src/utils/stripeRawBodyParser.ts new file mode 100644 index 000000000..40217436a --- /dev/null +++ b/packages/stripe/src/utils/stripeRawBodyParser.ts @@ -0,0 +1,48 @@ +import { FastifyInstance, FastifyRequest } from "fastify"; + +declare module "fastify" { + interface FastifyRequest { + rawBody?: Buffer; + } +} + +/** + * Registers an `application/json` content-type parser that captures the raw + * request buffer on `request.rawBody` while still parsing JSON for downstream + * handlers. Stripe's `webhooks.constructEvent` requires the exact raw bytes + * for signature verification. + * + * IMPORTANT — encapsulation contract: + * Fastify content-type parsers are scoped to the plugin context they are + * registered in. The webhook controller calls this function inside its own + * (non-`fastify-plugin`-wrapped) plugin scope, so the override stays local to + * the webhook route and does NOT bleed into other `application/json` routes + * on the parent instance. If you call this function on the top-level + * Fastify instance directly, the override applies to that whole scope. + * Do not wrap the webhook controller with `fastify-plugin` or this guarantee + * will be broken. + */ +const stripeRawBodyParser = (fastify: FastifyInstance): void => { + fastify.addContentTypeParser( + "application/json", + { parseAs: "buffer" }, + (request: FastifyRequest, body: Buffer, done) => { + request.rawBody = body; + + try { + const json = JSON.parse(body.toString()); + + // eslint-disable-next-line unicorn/no-null + done(null, json); + } catch (error) { + // Tag the error so Fastify's default error handler responds 400 + // instead of falling back to a generic 500. + const parseError = error as Error & { statusCode?: number }; + parseError.statusCode = 400; + done(parseError); + } + }, + ); +}; + +export default stripeRawBodyParser; diff --git a/packages/stripe/src/webhook/controller.ts b/packages/stripe/src/webhook/controller.ts new file mode 100644 index 000000000..0ff9f0c76 --- /dev/null +++ b/packages/stripe/src/webhook/controller.ts @@ -0,0 +1,65 @@ +import { + FastifyInstance, + type FastifyPluginAsync, + FastifyRequest, +} from "fastify"; + +import type { WebhookControllerOptions } from "../types"; + +import { ROUTE_STRIPE_WEBHOOK } from "../constants"; +import { createVerifyStripeSignature } from "../middlewares/verifyStripeSignature"; +import stripeRawBodyParser from "../utils/stripeRawBodyParser"; +import webhookHandler from "./handler"; + +const plugin: FastifyPluginAsync = async ( + fastify: FastifyInstance, + options, +) => { + fastify.log.info("Registering Stripe webhook route"); + + if (!options?.stripeConfig) { + throw new Error( + "Missing stripe configuration. Did you forget to pass { stripeConfig } to the Stripe webhook controller?", + ); + } + + const { stripeConfig } = options; + + if (!stripeConfig.handlers?.webhook) { + fastify.log.warn( + "config.stripe.handlers.webhook is not set; received webhooks will be acknowledged but not processed. Provide a handler to fulfill events.", + ); + } + + stripeRawBodyParser(fastify); + + fastify.post( + stripeConfig.webhookPath || ROUTE_STRIPE_WEBHOOK, + { preHandler: [createVerifyStripeSignature(stripeConfig)] }, + async (request: FastifyRequest, reply) => { + const event = request.stripeEvent; + + if (!event) { + // Should be unreachable: signature verification either sets the event + // or replies 400. Surface a clear 500 with context if it ever fires. + request.log.error( + "Stripe event not found on request after signature verification; refusing to dispatch.", + ); + + return reply.status(500).send({ + error: "Stripe event not found on request", + }); + } + + if (stripeConfig.handlers?.webhook) { + await stripeConfig.handlers.webhook(request, event); + + return; + } + + await webhookHandler(request, event); + }, + ); +}; + +export default plugin; diff --git a/packages/stripe/src/webhook/handler.ts b/packages/stripe/src/webhook/handler.ts new file mode 100644 index 000000000..419ca62a8 --- /dev/null +++ b/packages/stripe/src/webhook/handler.ts @@ -0,0 +1,20 @@ +import { FastifyRequest } from "fastify"; +import Stripe from "stripe"; + +// Default fallback handler used when `config.stripe.handlers.webhook` is not +// provided. We intentionally do NOT throw here: throwing yields a 500, which +// causes Stripe to retry indefinitely with exponential backoff. Instead we +// log an error so the misconfiguration is visible, then resolve so the route +// responds 200 and Stripe stops retrying. The plugin also warns at +// registration time when no custom handler is wired. +const handleWebhook = async ( + request: FastifyRequest, + event: Stripe.Event, +): Promise => { + request.log.error( + { eventId: event.id, eventType: event.type }, + "Stripe webhook received but no handler is configured (config.stripe.handlers.webhook). Acknowledging with 200 to suppress Stripe retries; event is discarded.", + ); +}; + +export default handleWebhook; diff --git a/packages/stripe/tsconfig.json b/packages/stripe/tsconfig.json new file mode 100644 index 000000000..09287764b --- /dev/null +++ b/packages/stripe/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@prefabs.tech/tsconfig/fastify.json", + "exclude": ["src/**/__test__/**/*"], + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/stripe/vite.config.ts b/packages/stripe/vite.config.ts new file mode 100644 index 000000000..750cf9dae --- /dev/null +++ b/packages/stripe/vite.config.ts @@ -0,0 +1,48 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, loadEnv } from "vite"; + +import { dependencies, peerDependencies } from "./package.json"; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => { + process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; + + return { + build: { + lib: { + entry: resolve(dirname(fileURLToPath(import.meta.url)), "src/index.ts"), + fileName: "prefabs-tech-fastify-stripe", + formats: ["cjs", "es"], + name: "PrefabsTechFastifyStripe", + }, + rollupOptions: { + external: [ + ...Object.keys(dependencies), + ...Object.keys(peerDependencies), + ], + output: { + exports: "named", + globals: { + "@prefabs.tech/fastify-config": "PrefabsTechFastifyConfig", + fastify: "Fastify", + "fastify-plugin": "FastifyPlugin", + stripe: "Stripe", + }, + }, + }, + target: "es2022", + }, + resolve: { + alias: { + "@/": new URL("src/", import.meta.url).pathname, + }, + }, + test: { + coverage: { + provider: "istanbul", + reporter: ["text", "json", "html"], + }, + }, + }; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72ce9811f..f07d2a40e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -481,6 +481,49 @@ importers: specifier: 3.25.76 version: 3.25.76 + packages/stripe: + dependencies: + stripe: + specifier: 20.3.1 + version: 20.3.1(@types/node@24.10.15) + devDependencies: + '@prefabs.tech/eslint-config': + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) + '@prefabs.tech/fastify-config': + specifier: 0.94.0 + version: 0.94.0(fastify-plugin@5.1.0)(fastify@5.8.5) + '@prefabs.tech/tsconfig': + specifier: 0.7.0 + version: 0.7.0 + '@types/node': + specifier: 24.10.15 + version: 24.10.15 + '@vitest/coverage-istanbul': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) + eslint: + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) + fastify: + specifier: 5.8.5 + version: 5.8.5 + fastify-plugin: + specifier: 5.1.0 + version: 5.1.0 + prettier: + specifier: 3.8.3 + version: 3.8.3 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) + packages/swagger: dependencies: '@fastify/swagger': @@ -1443,14 +1486,6 @@ packages: '@types/node': optional: true - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1580,6 +1615,13 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@prefabs.tech/eslint-config@0.7.0': + resolution: {integrity: sha512-kMLs+ksinlNKa5FfhTb2qNisnU/On+IGrRHbZ3aHq1INF8NsjVRB7Wm0Q9vdnBipco9YOady0qSx1goVnhhC3w==} + peerDependencies: + eslint: '>=9.0.0' + prettier: '>=3.3.3' + typescript: '>=4.9.5' + '@prefabs.tech/eslint-config@0.8.0': resolution: {integrity: sha512-E1vrgnsjuw9hXanhq18jQFUWrbrmNzr74cND1QA26gqiYKCtnXBol71BYLxJae3SDBPy9eiAyEuqiaep1CHzUg==} peerDependencies: @@ -1587,11 +1629,21 @@ packages: prettier: '>=3.3.3' typescript: '>=4.9.5' + '@prefabs.tech/fastify-config@0.94.0': + resolution: {integrity: sha512-x8d9MSQ/LDbzwN1e8paoH9UNp2Ev0192najA4SyOHACFxAnL0wXF+8Jn4iA3Ebt1Z2xF/RLZVdnWb2ixw+QlBQ==} + engines: {node: '>=20'} + peerDependencies: + fastify: '>=5.2.2' + fastify-plugin: '>=5.0.1' + '@prefabs.tech/postgres-migrations@5.4.3': resolution: {integrity: sha512-9cpcwgI0nZaUWmmybYuqhvroCEZHXy9IQmjdM83UF/yUXNu25ASZ09xfw96Q5Ixq0nYwtqkivorvpayBm+/XXg==} engines: {node: '>10.17.0'} hasBin: true + '@prefabs.tech/tsconfig@0.7.0': + resolution: {integrity: sha512-MiEvKeoNVPSy79tYQOkFeaBoVViW27JYfSiEbCBT+Fvuk1XEkXyBpSxlpdKQDV5bsAg0C5VNSjRh4qH3eDjA+w==} + '@prefabs.tech/tsconfig@0.8.0': resolution: {integrity: sha512-tllO+FL56JLAHE/j6jKbRlUifxIgB2U47vzdL670FpsPsH4vIFNpniW81/pD80ogMeLXqD7E40AHGMTUBaioww==} @@ -2492,9 +2544,6 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} - axios@1.12.2: - resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} - axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} @@ -4086,10 +4135,6 @@ packages: jsonify@0.0.1: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} - engines: {node: '>=12', npm: '>=6'} - jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -4103,9 +4148,6 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -4113,9 +4155,6 @@ packages: resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} engines: {node: '>=14'} - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -4289,10 +4328,6 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -5100,6 +5135,7 @@ packages: scmp@2.1.0: resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + deprecated: Just use Node.js's crypto.timingSafeEqual() secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -5119,11 +5155,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -5355,6 +5386,15 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@20.3.1: + resolution: {integrity: sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@2.2.3: resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} @@ -6526,7 +6566,7 @@ snapshots: '@conventional-changelog/git-client@1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.1)': dependencies: '@types/semver': 7.7.1 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.2.1 @@ -6686,7 +6726,7 @@ snapshots: dependencies: ajv: 8.20.0 ajv-formats: 3.0.1 - fast-uri: 3.1.0 + fast-uri: 3.1.1 '@fastify/busboy@3.2.0': {} @@ -7044,12 +7084,6 @@ snapshots: optionalDependencies: '@types/node': 24.10.15 - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7188,6 +7222,36 @@ snapshots: '@pkgr/core@0.2.9': {} + '@prefabs.tech/eslint-config@0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3)': + dependencies: + '@eslint/js': 9.39.4 + eslint: 9.39.4(jiti@2.6.1) + eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.32.0) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-n: 17.20.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-perfectionist: 5.9.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) + eslint-plugin-promise: 7.2.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-unicorn: 62.0.0(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-vue: 10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.4(jiti@2.6.1))) + globals: 17.3.0 + prettier: 3.8.3 + typescript: 5.9.3 + typescript-eslint: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.2.0(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - '@stylistic/eslint-plugin' + - '@types/eslint' + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + '@prefabs.tech/eslint-config@0.8.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3)': dependencies: '@eslint/js': 9.39.4 @@ -7218,6 +7282,11 @@ snapshots: - eslint-plugin-import-x - supports-color + '@prefabs.tech/fastify-config@0.94.0(fastify-plugin@5.1.0)(fastify@5.8.5)': + dependencies: + fastify: 5.8.5 + fastify-plugin: 5.1.0 + '@prefabs.tech/postgres-migrations@5.4.3': dependencies: pg: 8.20.0 @@ -7225,6 +7294,8 @@ snapshots: transitivePeerDependencies: - pg-native + '@prefabs.tech/tsconfig@0.7.0': {} + '@prefabs.tech/tsconfig@0.8.0': {} '@protobufjs/aspromise@1.1.2': @@ -7351,7 +7422,7 @@ snapshots: dependencies: '@slack/types': 2.20.0 '@types/node': 24.10.15 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) transitivePeerDependencies: - debug @@ -8226,15 +8297,7 @@ snapshots: axe-core@4.11.1: {} - axios@1.12.2(debug@4.4.3): - dependencies: - follow-redirects: 1.15.11(debug@4.4.3) - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - axios@1.13.5: + axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) form-data: 4.0.5 @@ -8488,7 +8551,7 @@ snapshots: conventional-commits-filter: 5.0.0 handlebars: 4.7.9 meow: 13.2.0 - semver: 7.7.3 + semver: 7.7.4 conventional-commits-filter@5.0.0: {} @@ -8700,7 +8763,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.7.3 + semver: 7.7.4 ejs@3.1.10: dependencies: @@ -8826,7 +8889,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 es-shim-unscopables@1.1.0: dependencies: @@ -9228,7 +9291,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.20.0 ajv-formats: 3.0.1 - fast-uri: 3.1.0 + fast-uri: 3.1.1 json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 @@ -9392,7 +9455,7 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.3 mime-types: 2.1.35 formdata-polyfill@4.0.10: @@ -9552,7 +9615,7 @@ snapshots: glob@13.0.0: dependencies: - minimatch: 10.1.1 + minimatch: 10.2.5 minipass: 7.1.2 path-scurry: 2.0.0 @@ -10027,7 +10090,7 @@ snapshots: '@babel/parser': 7.28.5 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -10141,19 +10204,6 @@ snapshots: jsonify@0.0.1: {} - jsonwebtoken@9.0.2: - dependencies: - jws: 3.2.2 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.3 - jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -10184,12 +10234,6 @@ snapshots: transitivePeerDependencies: - encoding - jwa@1.4.2: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -10206,11 +10250,6 @@ snapshots: transitivePeerDependencies: - supports-color - jws@3.2.2: - dependencies: - jwa: 1.4.2 - safe-buffer: 5.2.1 - jws@4.0.1: dependencies: jwa: 2.0.1 @@ -10316,7 +10355,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 math-intrinsics@1.1.0: {} @@ -10376,10 +10415,6 @@ snapshots: mime@3.0.0: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -10778,7 +10813,7 @@ snapshots: normalize-package-data@7.0.1: dependencies: hosted-git-info: 8.1.0 - semver: 7.7.3 + semver: 7.7.4 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -11382,8 +11417,6 @@ snapshots: semver@7.7.1: {} - semver@7.7.3: {} - semver@7.7.4: {} serialize-error@8.1.0: @@ -11681,6 +11714,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@20.3.1(@types/node@24.10.15): + optionalDependencies: + '@types/node': 24.10.15 + strnum@2.2.3: {} stubs@3.0.0: @@ -11814,10 +11851,10 @@ snapshots: twilio@4.23.0(debug@4.4.3): dependencies: - axios: 1.12.2(debug@4.4.3) + axios: 1.13.5(debug@4.4.3) dayjs: 1.11.18 https-proxy-agent: 5.0.1 - jsonwebtoken: 9.0.2 + jsonwebtoken: 9.0.3 qs: 6.14.0 scmp: 2.1.0 url-parse: 1.5.10