From 5f3e891b02fbc6933d93cfe48b2f8b80db19ddfb Mon Sep 17 00:00:00 2001 From: sudeep Date: Thu, 15 Jan 2026 13:52:08 +0545 Subject: [PATCH 01/16] feat: add fastify stripe package --- packages/stripe/.eslintignore | 5 + packages/stripe/.eslintrc.cjs | 4 + packages/stripe/.gitignore | 2 + packages/stripe/README.md | 161 ++++++++++++++++++ packages/stripe/package.json | 67 ++++++++ packages/stripe/src/constants.ts | 3 + packages/stripe/src/index.ts | 15 ++ .../src/middlewares/verifyStripeSignature.ts | 60 +++++++ packages/stripe/src/plugin.ts | 24 +++ packages/stripe/src/types/index.ts | 29 ++++ packages/stripe/src/utils/index.ts | 2 + packages/stripe/src/utils/stripeClient.ts | 48 ++++++ .../stripe/src/utils/stripeRawBodyParser.ts | 28 +++ packages/stripe/src/webhook/controller.ts | 39 +++++ packages/stripe/src/webhook/handler.ts | 13 ++ packages/stripe/tsconfig.json | 8 + packages/stripe/vite.config.ts | 55 ++++++ pnpm-lock.yaml | 89 ++++++++++ 18 files changed, 652 insertions(+) create mode 100644 packages/stripe/.eslintignore create mode 100644 packages/stripe/.eslintrc.cjs create mode 100644 packages/stripe/.gitignore create mode 100644 packages/stripe/README.md create mode 100644 packages/stripe/package.json create mode 100644 packages/stripe/src/constants.ts create mode 100644 packages/stripe/src/index.ts create mode 100644 packages/stripe/src/middlewares/verifyStripeSignature.ts create mode 100644 packages/stripe/src/plugin.ts create mode 100644 packages/stripe/src/types/index.ts create mode 100644 packages/stripe/src/utils/index.ts create mode 100644 packages/stripe/src/utils/stripeClient.ts create mode 100644 packages/stripe/src/utils/stripeRawBodyParser.ts create mode 100644 packages/stripe/src/webhook/controller.ts create mode 100644 packages/stripe/src/webhook/handler.ts create mode 100644 packages/stripe/tsconfig.json create mode 100644 packages/stripe/vite.config.ts diff --git a/packages/stripe/.eslintignore b/packages/stripe/.eslintignore new file mode 100644 index 000000000..c230b7a22 --- /dev/null +++ b/packages/stripe/.eslintignore @@ -0,0 +1,5 @@ +.eslintrc.cjs + +# Ignore artifacts: +coverage +dist diff --git a/packages/stripe/.eslintrc.cjs b/packages/stripe/.eslintrc.cjs new file mode 100644 index 000000000..7de245ade --- /dev/null +++ b/packages/stripe/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["@prefabs.tech/eslint-config/fastify"], +}; diff --git a/packages/stripe/.gitignore b/packages/stripe/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/packages/stripe/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/stripe/README.md b/packages/stripe/README.md new file mode 100644 index 000000000..d0d15111c --- /dev/null +++ b/packages/stripe/README.md @@ -0,0 +1,161 @@ +# @prefabs.tech/fastify-stripe + +A [Fastify](https://github.com/fastify/fastify) plugin for that provides easy integration of Stripe for payment processing. + +## Features + +- **Stripe Checkout Sessions**: Create Stripe checkout sessions for one-time payments +- **Webhook Handling**: Built-in webhook endpoint with signature verification +- **Custom Webhook Handlers**: Support for custom webhook event handlers +- **Configurable Routes**: Customizable webhook endpoint paths + +## Requirements + +- [@prefabs.tech/fastify-config](https://www.npmjs.com/package/@prefabs.tech/fastify-config) + +## Usage + +### Register Plugin + +Register the stripe plugin with your Fastify instance: + +```typescript +import stripePlugin from "@prefabs.tech/fastify-stripe"; +import configPlugin from "@prefabs.tech/fastify-config"; +import Fastify from "fastify"; + +import config from "./config"; + +const start = async () => { + // Create fastify instance + const fastify = Fastify({ + logger: config.logger, + }); + + // Register fastify-config plugin + await fastify.register(configPlugin, { config }); + + // Register stripe plugin + await fastify.register(stripePlugin); + + await fastify.listen({ + port: config.port, + host: "0.0.0.0", + }); +}; + +start(); +``` + +## Configuration + +Add Stripe configuration to your config: + +```typescript +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + // ...other config + stripe: { + apiKey: "sk_test_...", + defaultCurrency: "usd", + enablePaymentWebhook: true, + webhookPath: "/payment/webhook", // Optional, defaults to "/payment/webhook" + webhookSecret: "whsec_...", + redirectUrl: { + callbackWebhook: "https://your-domain.com/webhook", + cancel: "https://your-domain.com/cancel", + success: "https://your-domain.com/success", + }, + handlers: { + webhook: async (request, event) => { + // Handle Stripe events + switch (event.type) { + case "checkout.session.completed": + // Handle successful checkout + break; + case "payment_intent.succeeded": + // Handle successful payment + break; + // ... handle other events + } + }, + }, + }, +}; +``` + + +## Using the Stripe Client + +The package exports a `StripeClient` class for creating checkout sessions: + +```typescript +import { StripeClient } from "@prefabs.tech/fastify-stripe"; + +// Initialize the client with your config +const stripeClient = new StripeClient(config); + +// Create a checkout session +const session = await stripeClient.createCheckoutSession({ + productName: "Premium Subscription", + unitAmount: 2999, // Amount in cents ($29.99) + quantity: 1, + currency: "usd", // Optional, uses defaultCurrency if not provided + mode: "payment", // Optional: "payment" | "subscription" | "setup" + successUrl: "https://your-domain.com/success", // Optional, uses config if not provided + cancelUrl: "https://your-domain.com/cancel", // Optional, uses config if not provided +}, { + // Optional metadata + userId: "user_123", + orderId: "order_456", +}); +``` + +### Custom Webhook Handler + +You can provide a custom webhook handler to process Stripe events: + +```typescript +import type { FastifyRequest } from "fastify"; +import type { StripeEvent } from "@prefabs.tech/fastify-stripe"; + +const customWebhookHandler = async ( + request: FastifyRequest, + event: StripeEvent, +): Promise => { + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object; + // Fulfill the purchase + break; + } + case "payment_intent.succeeded": { + const paymentIntent = event.data.object; + // Handle successful payment + break; + } + case "payment_intent.payment_failed": { + const paymentIntent = event.data.object; + // Handle failed payment + break; + } + default: + console.log(`Unhandled event type: ${event.type}`); + } +}; + +// Add to config +const config = { + stripe: { + // ...other config + handlers: { + webhook: customWebhookHandler, + }, + }, +}; +``` + +## API Version + +This package uses Stripe API version: `2025-12-15.clover` diff --git a/packages/stripe/package.json b/packages/stripe/package.json new file mode 100644 index 000000000..be5510808 --- /dev/null +++ b/packages/stripe/package.json @@ -0,0 +1,67 @@ +{ + "name": "@prefabs.tech/fastify-stripe", + "version": "0.93.3", + "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", + "exports": { + ".": { + "import": "./dist/prefabs-tech-fastify-stripe.es.js", + "require": "./dist/prefabs-tech-fastify-stripe.cjs.js" + } + }, + "main": "./dist/prefabs-tech-fastify-stripe.cjs.js", + "module": "./dist/prefabs-tech-fastify-stripe.es.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", + "typecheck": "tsc --noEmit -p tsconfig.json --composite false" + }, + "dependencies": { + "stripe": "20.1.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@prefabs.tech/eslint-config": "0.2.0", + "@prefabs.tech/tsconfig": "0.2.0", + "@prefabs.tech/fastify-config": "0.93.3", + "@typescript-eslint/eslint-plugin": "8.46.2", + "@typescript-eslint/parser": "8.46.2", + "eslint": "8.57.1", + "eslint-config-prettier": "9.1.2", + "eslint-import-resolver-alias": "1.1.2", + "eslint-import-resolver-typescript": "3.10.1", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-n": "14.0.0", + "eslint-plugin-prettier": "5.5.4", + "eslint-plugin-promise": "7.2.1", + "eslint-plugin-unicorn": "56.0.1", + "fastify": "5.6.1", + "fastify-plugin": "5.1.0", + "prettier": "3.6.2", + "supertokens-node": "14.1.4", + "typescript": "5.9.3", + "vite": "6.4.1", + "vitest": "3.2.4" + }, + "peerDependencies": { + "@prefabs.tech/fastify-config": "0.93.3", + "fastify": ">=5.2.1", + "fastify-plugin": ">=5.0.1", + "supertokens-node": ">=14.1.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/stripe/src/constants.ts b/packages/stripe/src/constants.ts new file mode 100644 index 000000000..42d5c9eaf --- /dev/null +++ b/packages/stripe/src/constants.ts @@ -0,0 +1,3 @@ +export const ROUTE_STRIPE_WEBHOOK = "/payment/webhook"; + +export const STRIPE_API_VERSION = "2025-12-15.clover"; diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts new file mode 100644 index 000000000..2b90ed30b --- /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..88b517610 --- /dev/null +++ b/packages/stripe/src/middlewares/verifyStripeSignature.ts @@ -0,0 +1,60 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import Stripe from "stripe"; + +import { STRIPE_API_VERSION } from "../constants"; + +const verifyStripeSignature = async ( + request: FastifyRequest, + reply: FastifyReply, +): Promise => { + const { config, log } = request.server; + const webhookSecret = config.stripe.webhookSecret; + + if (!webhookSecret) { + log.error( + "Stripe webhook secret is not configured. Skipping signature verification.", + ); + + 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 stripe = new Stripe(config.stripe.apiKey, { + apiVersion: STRIPE_API_VERSION, + }); + + 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 as FastifyRequest & { stripeEvent: Stripe.Event }).stripeEvent = + event; + } catch (error) { + log.error({ err: error }, "Stripe webhook signature verification failed"); + return reply + .status(400) + .send({ error: "Webhook signature verification failed" }); + } +}; + +export default verifyStripeSignature; diff --git a/packages/stripe/src/plugin.ts b/packages/stripe/src/plugin.ts new file mode 100644 index 000000000..3f95420b0 --- /dev/null +++ b/packages/stripe/src/plugin.ts @@ -0,0 +1,24 @@ +import { FastifyInstance } from "fastify"; +import fastifyPlugin from "fastify-plugin"; + +import webhookController from "./webhook/controller"; + +const plugin = async (fastify: FastifyInstance) => { + const { config, log } = fastify; + + if (!config.stripe) { + log.warn( + "Stripe configuration is missing. Stripe plugin will not be registered.", + ); + + return; + } + + fastify.log.info("Registering Stripe plugin"); + + if (config.stripe.enablePaymentWebhook) { + await fastify.register(webhookController); + } +}; + +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..9f4ec41cf --- /dev/null +++ b/packages/stripe/src/types/index.ts @@ -0,0 +1,29 @@ +import Stripe from "stripe"; + +import webhookHandler from "../webhook/handler"; + +export type StripeConfig = { + apiKey: string; + defaultCurrency: string; + enablePaymentWebhook: boolean; + handlers?: { + webhook?: typeof webhookHandler; + }; + redirectUrl: { + callbackWebhook: string; + cancel: string; + success: string; + }; + webhookPath?: string; + webhookSecret?: string; +}; + +export type CreateSessionInput = { + cancelUrl?: string; + currency?: string; + mode?: Stripe.Checkout.SessionCreateParams.Mode; + productName: string; + quantity?: number; + successUrl?: string; + unitAmount: number; +}; 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..a27d70350 --- /dev/null +++ b/packages/stripe/src/utils/stripeClient.ts @@ -0,0 +1,48 @@ +import { ApiConfig } from "@prefabs.tech/fastify-config"; +import Stripe from "stripe"; + +import { STRIPE_API_VERSION } from "../constants"; +import { CreateSessionInput } from "../types"; + +class StripeClient { + protected _config: ApiConfig; + public stripe: Stripe; + + constructor(config: ApiConfig) { + this._config = config; + this.stripe = new Stripe(config.stripe.apiKey, { + apiVersion: STRIPE_API_VERSION, + }); + } + + public async createCheckoutSession( + input: CreateSessionInput, + metadata?: Record, + ): Promise> { + const session = await this.stripe.checkout.sessions.create({ + cancel_url: input.cancelUrl ?? this._config.stripe.redirectUrl.cancel, + line_items: [ + { + price_data: { + currency: input.currency ?? this._config.stripe.defaultCurrency, + product_data: { + name: input.productName, + }, + unit_amount: input.unitAmount, + }, + quantity: input.quantity ?? 1, + }, + ], + metadata: metadata, + payment_intent_data: { + metadata: metadata, + }, + mode: input.mode ?? "payment", + success_url: input.successUrl ?? this._config.stripe.redirectUrl.success, + }); + + return session; + } +} + +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..26ba9bd26 --- /dev/null +++ b/packages/stripe/src/utils/stripeRawBodyParser.ts @@ -0,0 +1,28 @@ +import { FastifyInstance, FastifyRequest } from "fastify"; + +declare module "fastify" { + interface FastifyRequest { + rawBody?: Buffer | string; + } +} + +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) { + done(error as Error); + } + }, + ); +}; + +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..8d983ef20 --- /dev/null +++ b/packages/stripe/src/webhook/controller.ts @@ -0,0 +1,39 @@ +import { FastifyInstance, FastifyRequest } from "fastify"; +import Stripe from "stripe"; + +import webhookHandler from "./handler"; +import { ROUTE_STRIPE_WEBHOOK } from "../constants"; +import verifyStripeSignature from "../middlewares/verifyStripeSignature"; +import stripeRawBodyParser from "../utils/stripeRawBodyParser"; + +const plugin = async (fastify: FastifyInstance) => { + if (fastify.config.stripe.enablePaymentWebhook) { + fastify.log.info("Registering Stripe webhook route"); + + stripeRawBodyParser(fastify); + + fastify.post( + fastify.config.stripe.webhookPath || ROUTE_STRIPE_WEBHOOK, + { preHandler: [verifyStripeSignature] }, + async (request: FastifyRequest) => { + const event = ( + request as FastifyRequest & { stripeEvent?: Stripe.Event } + ).stripeEvent; + + if (!event) { + throw new Error("Stripe event not found on request"); + } + + if (fastify.config.stripe.handlers?.webhook) { + await fastify.config.stripe.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..8a203e731 --- /dev/null +++ b/packages/stripe/src/webhook/handler.ts @@ -0,0 +1,13 @@ +import { FastifyRequest } from "fastify"; +import Stripe from "stripe"; + +const handleWebhook = async ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: FastifyRequest, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + event: Stripe.Event, +): Promise => { + throw new Error("Webhook handler not implemented"); +}; + +export default handleWebhook; diff --git a/packages/stripe/tsconfig.json b/packages/stripe/tsconfig.json new file mode 100644 index 000000000..78fcb4829 --- /dev/null +++ b/packages/stripe/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@prefabs.tech/tsconfig/fastify.json", + "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..35af45248 --- /dev/null +++ b/packages/stripe/vite.config.ts @@ -0,0 +1,55 @@ +import { resolve, dirname } 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-error-handler": + "PrefabsTechFastifyErrorHandler", + "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", + "@prefabs.tech/fastify-graphql": "PrefabsTechFastifyGraphql", + fastify: "Fastify", + "fastify-plugin": "FastifyPlugin", + stripe: "Stripe", + mercurius: "mercurius", + slonik: "Slonik", + zod: "zod", + }, + }, + }, + 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 016626b94..5f353a616 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -678,6 +678,79 @@ importers: specifier: 3.25.76 version: 3.25.76 + packages/stripe: + dependencies: + stripe: + specifier: 20.1.0 + version: 20.1.0(@types/node@24.10.0) + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@prefabs.tech/eslint-config': + specifier: 0.2.0 + version: 0.2.0(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0))(eslint-import-resolver-typescript@3.10.1)(eslint-plugin-import@2.32.0)(eslint-plugin-n@14.0.0(eslint@8.57.1))(eslint-plugin-prettier@5.5.4(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2))(eslint-plugin-promise@7.2.1(eslint@8.57.1))(eslint-plugin-unicorn@56.0.1(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2)(typescript@5.9.3) + '@prefabs.tech/fastify-config': + specifier: 0.93.3 + version: link:../config + '@prefabs.tech/tsconfig': + specifier: 0.2.0 + version: 0.2.0(@types/node@24.10.0) + '@typescript-eslint/eslint-plugin': + specifier: 8.46.2 + version: 8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 8.46.2 + version: 8.46.2(eslint@8.57.1)(typescript@5.9.3) + eslint: + specifier: 8.57.1 + version: 8.57.1 + eslint-config-prettier: + specifier: 9.1.2 + version: 9.1.2(eslint@8.57.1) + eslint-import-resolver-alias: + specifier: 1.1.2 + version: 1.1.2(eslint-plugin-import@2.32.0) + eslint-import-resolver-typescript: + specifier: 3.10.1 + version: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: + specifier: 2.32.0 + version: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-n: + specifier: 14.0.0 + version: 14.0.0(eslint@8.57.1) + eslint-plugin-prettier: + specifier: 5.5.4 + version: 5.5.4(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2) + eslint-plugin-promise: + specifier: 7.2.1 + version: 7.2.1(eslint@8.57.1) + eslint-plugin-unicorn: + specifier: 56.0.1 + version: 56.0.1(eslint@8.57.1) + fastify: + specifier: 5.6.1 + version: 5.6.1 + fastify-plugin: + specifier: 5.1.0 + version: 5.1.0 + prettier: + specifier: 3.6.2 + version: 3.6.2 + supertokens-node: + specifier: 14.1.4 + version: 14.1.4 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 6.4.1 + version: 6.4.1(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) + packages/swagger: dependencies: '@fastify/swagger': @@ -5401,6 +5474,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==} @@ -5681,6 +5755,15 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@20.1.0: + resolution: {integrity: sha512-o1VNRuMkY76ZCq92U3EH3/XHm/WHp7AerpzDs4Zyo8uE5mFL4QUcv/2SudWsSnhBSp4moO2+ZoGCZ7mT8crPmQ==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} @@ -12254,6 +12337,12 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@20.1.0(@types/node@24.10.0): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 24.10.0 + strnum@1.1.2: optional: true From bca19db16514958e8763a6681b9acfbc5be3a08c Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 6 Feb 2026 09:32:29 +0545 Subject: [PATCH 02/16] chore: sync stripe package with eslint changes --- packages/stripe/.eslintignore | 5 ---- packages/stripe/.eslintrc.cjs | 4 ---- packages/stripe/eslint.config.js | 3 +++ packages/stripe/package.json | 30 ++++++++---------------- packages/stripe/tsconfig.json | 3 +++ pnpm-lock.yaml | 40 ++++---------------------------- 6 files changed, 21 insertions(+), 64 deletions(-) delete mode 100644 packages/stripe/.eslintignore delete mode 100644 packages/stripe/.eslintrc.cjs create mode 100644 packages/stripe/eslint.config.js diff --git a/packages/stripe/.eslintignore b/packages/stripe/.eslintignore deleted file mode 100644 index c230b7a22..000000000 --- a/packages/stripe/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -.eslintrc.cjs - -# Ignore artifacts: -coverage -dist diff --git a/packages/stripe/.eslintrc.cjs b/packages/stripe/.eslintrc.cjs deleted file mode 100644 index 7de245ade..000000000 --- a/packages/stripe/.eslintrc.cjs +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ["@prefabs.tech/eslint-config/fastify"], -}; 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 index be5510808..df67a4dd3 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-stripe", - "version": "0.93.3", + "version": "0.93.4", "description": "Fastify stripe plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/stripe#readme", "repository": { @@ -11,18 +11,18 @@ "license": "MIT", "exports": { ".": { - "import": "./dist/prefabs-tech-fastify-stripe.es.js", - "require": "./dist/prefabs-tech-fastify-stripe.cjs.js" + "import": "./dist/prefabs-tech-fastify-stripe.js", + "require": "./dist/prefabs-tech-fastify-stripe.cjs" } }, - "main": "./dist/prefabs-tech-fastify-stripe.cjs.js", - "module": "./dist/prefabs-tech-fastify-stripe.es.js", + "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 ", + "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", "lint:fix": "eslint . --fix", "sort-package": "npx sort-package-json", @@ -33,20 +33,10 @@ "zod": "3.25.76" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.2.0", + "@prefabs.tech/eslint-config": "0.4.0", "@prefabs.tech/tsconfig": "0.2.0", - "@prefabs.tech/fastify-config": "0.93.3", - "@typescript-eslint/eslint-plugin": "8.46.2", - "@typescript-eslint/parser": "8.46.2", - "eslint": "8.57.1", - "eslint-config-prettier": "9.1.2", - "eslint-import-resolver-alias": "1.1.2", - "eslint-import-resolver-typescript": "3.10.1", - "eslint-plugin-import": "2.32.0", - "eslint-plugin-n": "14.0.0", - "eslint-plugin-prettier": "5.5.4", - "eslint-plugin-promise": "7.2.1", - "eslint-plugin-unicorn": "56.0.1", + "@prefabs.tech/fastify-config": "0.93.4", + "eslint": "9.39.2", "fastify": "5.6.1", "fastify-plugin": "5.1.0", "prettier": "3.6.2", @@ -56,7 +46,7 @@ "vitest": "3.2.4" }, "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.3", + "@prefabs.tech/fastify-config": "0.93.4", "fastify": ">=5.2.1", "fastify-plugin": ">=5.0.1", "supertokens-node": ">=14.1.3" diff --git a/packages/stripe/tsconfig.json b/packages/stripe/tsconfig.json index 78fcb4829..380daccb6 100644 --- a/packages/stripe/tsconfig.json +++ b/packages/stripe/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", + "exclude": [ + "src/**/__test__/**/*", + ], "compilerOptions": { "baseUrl": "./", "outDir": "dist", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6388df3bb..856791686 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,47 +478,17 @@ importers: version: 3.25.76 devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.2.0 - version: 0.2.0(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0))(eslint-import-resolver-typescript@3.10.1)(eslint-plugin-import@2.32.0)(eslint-plugin-n@14.0.0(eslint@8.57.1))(eslint-plugin-prettier@5.5.4(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2))(eslint-plugin-promise@7.2.1(eslint@8.57.1))(eslint-plugin-unicorn@56.0.1(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2)(typescript@5.9.3) + specifier: 0.4.0 + version: 0.4.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2)(typescript@5.9.3) '@prefabs.tech/fastify-config': - specifier: 0.93.3 + specifier: 0.93.4 version: link:../config '@prefabs.tech/tsconfig': specifier: 0.2.0 version: 0.2.0(@types/node@24.10.0) - '@typescript-eslint/eslint-plugin': - specifier: 8.46.2 - version: 8.46.2(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: 8.46.2 - version: 8.46.2(eslint@8.57.1)(typescript@5.9.3) eslint: - specifier: 8.57.1 - version: 8.57.1 - eslint-config-prettier: - specifier: 9.1.2 - version: 9.1.2(eslint@8.57.1) - eslint-import-resolver-alias: - specifier: 1.1.2 - version: 1.1.2(eslint-plugin-import@2.32.0) - eslint-import-resolver-typescript: - specifier: 3.10.1 - version: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) - eslint-plugin-import: - specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) - eslint-plugin-n: - specifier: 14.0.0 - version: 14.0.0(eslint@8.57.1) - eslint-plugin-prettier: - specifier: 5.5.4 - version: 5.5.4(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.6.2) - eslint-plugin-promise: - specifier: 7.2.1 - version: 7.2.1(eslint@8.57.1) - eslint-plugin-unicorn: - specifier: 56.0.1 - version: 56.0.1(eslint@8.57.1) + specifier: 9.39.2 + version: 9.39.2(jiti@2.6.1) fastify: specifier: 5.6.1 version: 5.6.1 From 14ddae0e565f80f6fc9692c7e46c2e1029c10007 Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 6 Feb 2026 09:55:53 +0545 Subject: [PATCH 03/16] feat: add promocode support --- packages/stripe/src/types/index.ts | 1 + packages/stripe/src/utils/stripeClient.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/stripe/src/types/index.ts b/packages/stripe/src/types/index.ts index 9f4ec41cf..0ab9eee97 100644 --- a/packages/stripe/src/types/index.ts +++ b/packages/stripe/src/types/index.ts @@ -3,6 +3,7 @@ import Stripe from "stripe"; import webhookHandler from "../webhook/handler"; export type StripeConfig = { + allowPromotionCodes: boolean; apiKey: string; defaultCurrency: string; enablePaymentWebhook: boolean; diff --git a/packages/stripe/src/utils/stripeClient.ts b/packages/stripe/src/utils/stripeClient.ts index a27d70350..905feac10 100644 --- a/packages/stripe/src/utils/stripeClient.ts +++ b/packages/stripe/src/utils/stripeClient.ts @@ -20,6 +20,7 @@ class StripeClient { metadata?: Record, ): Promise> { const session = await this.stripe.checkout.sessions.create({ + allow_promotion_codes: this._config.stripe.allowPromotionCodes, cancel_url: input.cancelUrl ?? this._config.stripe.redirectUrl.cancel, line_items: [ { @@ -43,6 +44,17 @@ class StripeClient { return session; } + + public async getActivePromotionCode( + code: string, + ): Promise { + const codes = await this.stripe.promotionCodes.list({ + active: true, + code: code, + }); + + return codes.data[0]; + } } export default StripeClient; From 98261ad316f1eb5965250564980501f39acfba42 Mon Sep 17 00:00:00 2001 From: sudeep Date: Mon, 23 Feb 2026 16:46:16 +0545 Subject: [PATCH 04/16] refactor: update stripe package and types --- packages/stripe/package.json | 15 +++++----- packages/stripe/src/constants.ts | 2 -- .../src/middlewares/verifyStripeSignature.ts | 6 +--- packages/stripe/src/types/index.ts | 6 ++-- packages/stripe/src/utils/stripeClient.ts | 9 ++---- pnpm-lock.yaml | 28 +++++++++---------- 6 files changed, 29 insertions(+), 37 deletions(-) diff --git a/packages/stripe/package.json b/packages/stripe/package.json index df67a4dd3..dd13d009c 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -9,6 +9,7 @@ "directory": "packages/stripe" }, "license": "MIT", + "type": "module", "exports": { ".": { "import": "./dist/prefabs-tech-fastify-stripe.js", @@ -29,24 +30,24 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { - "stripe": "20.1.0", + "stripe": "20.3.1", "zod": "3.25.76" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.4.0", - "@prefabs.tech/tsconfig": "0.2.0", - "@prefabs.tech/fastify-config": "0.93.4", + "@prefabs.tech/eslint-config": "0.5.0", + "@prefabs.tech/tsconfig": "0.5.0", + "@prefabs.tech/fastify-config": "0.93.5", "eslint": "9.39.2", - "fastify": "5.6.1", + "fastify": "5.7.4", "fastify-plugin": "5.1.0", - "prettier": "3.6.2", + "prettier": "3.8.1", "supertokens-node": "14.1.4", "typescript": "5.9.3", "vite": "6.4.1", "vitest": "3.2.4" }, "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.4", + "@prefabs.tech/fastify-config": "0.93.5", "fastify": ">=5.2.1", "fastify-plugin": ">=5.0.1", "supertokens-node": ">=14.1.3" diff --git a/packages/stripe/src/constants.ts b/packages/stripe/src/constants.ts index 42d5c9eaf..dcb8ef299 100644 --- a/packages/stripe/src/constants.ts +++ b/packages/stripe/src/constants.ts @@ -1,3 +1 @@ export const ROUTE_STRIPE_WEBHOOK = "/payment/webhook"; - -export const STRIPE_API_VERSION = "2025-12-15.clover"; diff --git a/packages/stripe/src/middlewares/verifyStripeSignature.ts b/packages/stripe/src/middlewares/verifyStripeSignature.ts index 88b517610..f17c557be 100644 --- a/packages/stripe/src/middlewares/verifyStripeSignature.ts +++ b/packages/stripe/src/middlewares/verifyStripeSignature.ts @@ -1,8 +1,6 @@ import { FastifyReply, FastifyRequest } from "fastify"; import Stripe from "stripe"; -import { STRIPE_API_VERSION } from "../constants"; - const verifyStripeSignature = async ( request: FastifyRequest, reply: FastifyReply, @@ -27,9 +25,7 @@ const verifyStripeSignature = async ( } try { - const stripe = new Stripe(config.stripe.apiKey, { - apiVersion: STRIPE_API_VERSION, - }); + const stripe = new Stripe(config.stripe.apiKey, config.stripe.clientConfig); const rawBody = request.rawBody; diff --git a/packages/stripe/src/types/index.ts b/packages/stripe/src/types/index.ts index 0ab9eee97..5629d67d6 100644 --- a/packages/stripe/src/types/index.ts +++ b/packages/stripe/src/types/index.ts @@ -3,15 +3,15 @@ import Stripe from "stripe"; import webhookHandler from "../webhook/handler"; export type StripeConfig = { - allowPromotionCodes: boolean; + allowPromotionCodes?: boolean; apiKey: string; + clientConfig?: Stripe.StripeConfig; defaultCurrency: string; enablePaymentWebhook: boolean; handlers?: { webhook?: typeof webhookHandler; }; - redirectUrl: { - callbackWebhook: string; + urls: { cancel: string; success: string; }; diff --git a/packages/stripe/src/utils/stripeClient.ts b/packages/stripe/src/utils/stripeClient.ts index 905feac10..fa65d5246 100644 --- a/packages/stripe/src/utils/stripeClient.ts +++ b/packages/stripe/src/utils/stripeClient.ts @@ -1,7 +1,6 @@ import { ApiConfig } from "@prefabs.tech/fastify-config"; import Stripe from "stripe"; -import { STRIPE_API_VERSION } from "../constants"; import { CreateSessionInput } from "../types"; class StripeClient { @@ -10,9 +9,7 @@ class StripeClient { constructor(config: ApiConfig) { this._config = config; - this.stripe = new Stripe(config.stripe.apiKey, { - apiVersion: STRIPE_API_VERSION, - }); + this.stripe = new Stripe(config.stripe.apiKey, config.stripe.clientConfig); } public async createCheckoutSession( @@ -21,7 +18,7 @@ class StripeClient { ): Promise> { const session = await this.stripe.checkout.sessions.create({ allow_promotion_codes: this._config.stripe.allowPromotionCodes, - cancel_url: input.cancelUrl ?? this._config.stripe.redirectUrl.cancel, + cancel_url: input.cancelUrl ?? this._config.stripe.urls.cancel, line_items: [ { price_data: { @@ -39,7 +36,7 @@ class StripeClient { metadata: metadata, }, mode: input.mode ?? "payment", - success_url: input.successUrl ?? this._config.stripe.redirectUrl.success, + success_url: input.successUrl ?? this._config.stripe.urls.success, }); return session; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf77b3f5e..8d59715d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -472,32 +472,32 @@ importers: dependencies: stripe: specifier: 20.1.0 - version: 20.1.0(@types/node@24.10.0) + version: 20.1.0(@types/node@24.10.13) zod: specifier: 3.25.76 version: 3.25.76 devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.4.0 - version: 0.4.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2)(typescript@5.9.3) + specifier: 0.5.0 + version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) '@prefabs.tech/fastify-config': - specifier: 0.93.4 + specifier: 0.93.5 version: link:../config '@prefabs.tech/tsconfig': - specifier: 0.2.0 - version: 0.2.0(@types/node@24.10.0) + specifier: 0.5.0 + version: 0.5.0(@types/node@24.10.13) eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) fastify: - specifier: 5.6.1 - version: 5.6.1 + specifier: 5.7.4 + version: 5.7.4 fastify-plugin: specifier: 5.1.0 version: 5.1.0 prettier: - specifier: 3.6.2 - version: 3.6.2 + specifier: 3.8.1 + version: 3.8.1 supertokens-node: specifier: 14.1.4 version: 14.1.4 @@ -506,10 +506,10 @@ importers: version: 5.9.3 vite: specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) + version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) packages/swagger: dependencies: @@ -11625,11 +11625,11 @@ snapshots: dependencies: js-tokens: 9.0.1 - stripe@20.1.0(@types/node@24.10.0): + stripe@20.1.0(@types/node@24.10.13): dependencies: qs: 6.14.0 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.13 strnum@1.1.2: optional: true From 69eb362db6686ce5943eb37bce0e51a163182f77 Mon Sep 17 00:00:00 2001 From: sudeep Date: Mon, 23 Feb 2026 17:28:14 +0545 Subject: [PATCH 05/16] chore: update readme --- packages/stripe/README.md | 11 ++++++++--- pnpm-lock.yaml | 12 +++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/stripe/README.md b/packages/stripe/README.md index d0d15111c..042132628 100644 --- a/packages/stripe/README.md +++ b/packages/stripe/README.md @@ -62,8 +62,9 @@ const config: ApiConfig = { enablePaymentWebhook: true, webhookPath: "/payment/webhook", // Optional, defaults to "/payment/webhook" webhookSecret: "whsec_...", - redirectUrl: { - callbackWebhook: "https://your-domain.com/webhook", + allowPromotionCodes: true, // Optional, enables promotion code support + clientConfig: {}, // Optional, custom Stripe client configuration + urls: { cancel: "https://your-domain.com/cancel", success: "https://your-domain.com/success", }, @@ -146,9 +147,13 @@ const customWebhookHandler = async ( }; // Add to config -const config = { +const config: ApiConfig = { stripe: { // ...other config + urls: { + cancel: "https://your-domain.com/cancel", + success: "https://your-domain.com/success", + }, handlers: { webhook: customWebhookHandler, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d59715d7..0605bab83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,8 +471,8 @@ importers: packages/stripe: dependencies: stripe: - specifier: 20.1.0 - version: 20.1.0(@types/node@24.10.13) + specifier: 20.3.1 + version: 20.3.1(@types/node@24.10.13) zod: specifier: 3.25.76 version: 3.25.76 @@ -5304,8 +5304,8 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - stripe@20.1.0: - resolution: {integrity: sha512-o1VNRuMkY76ZCq92U3EH3/XHm/WHp7AerpzDs4Zyo8uE5mFL4QUcv/2SudWsSnhBSp4moO2+ZoGCZ7mT8crPmQ==} + stripe@20.3.1: + resolution: {integrity: sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==} engines: {node: '>=16'} peerDependencies: '@types/node': '>=16' @@ -11625,9 +11625,7 @@ snapshots: dependencies: js-tokens: 9.0.1 - stripe@20.1.0(@types/node@24.10.13): - dependencies: - qs: 6.14.0 + stripe@20.3.1(@types/node@24.10.13): optionalDependencies: '@types/node': 24.10.13 From 553fdbad02abb151918a1f16e9a58caef2b5fe5c Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Tue, 12 May 2026 17:12:31 +0545 Subject: [PATCH 06/16] test(stripe): add Vitest suite, docs, and package tooling Add ANALYSIS/FEATURES/GUIDE, ignore coverage output, tune vite and lockfile for tests. --- packages/stripe/.gitignore | 1 + packages/stripe/ANALYSIS.md | 126 ++++ packages/stripe/FEATURES.md | 57 ++ packages/stripe/GUIDE.md | 463 +++++++++++++++ packages/stripe/package.json | 25 +- .../stripe/src/__test__/constants.test.ts | 9 + .../__test__/helpers/createStripeConfig.ts | 19 + packages/stripe/src/__test__/index.test.ts | 24 + packages/stripe/src/__test__/plugin.test.ts | 133 +++++ .../stripe/src/__test__/stripeClient.test.ts | 301 ++++++++++ .../src/__test__/stripeRawBodyParser.test.ts | 131 +++++ .../__test__/verifyStripeSignature.test.ts | 371 ++++++++++++ .../src/__test__/webhookController.test.ts | 201 +++++++ .../src/__test__/webhookHandler.test.ts | 12 + packages/stripe/src/plugin.ts | 4 +- packages/stripe/src/types/index.ts | 20 +- packages/stripe/src/utils/stripeClient.ts | 4 +- packages/stripe/src/webhook/controller.ts | 2 +- packages/stripe/vite.config.ts | 7 +- pnpm-lock.yaml | 538 ++---------------- 20 files changed, 1914 insertions(+), 534 deletions(-) create mode 100644 packages/stripe/ANALYSIS.md create mode 100644 packages/stripe/FEATURES.md create mode 100644 packages/stripe/GUIDE.md create mode 100644 packages/stripe/src/__test__/constants.test.ts create mode 100644 packages/stripe/src/__test__/helpers/createStripeConfig.ts create mode 100644 packages/stripe/src/__test__/index.test.ts create mode 100644 packages/stripe/src/__test__/plugin.test.ts create mode 100644 packages/stripe/src/__test__/stripeClient.test.ts create mode 100644 packages/stripe/src/__test__/stripeRawBodyParser.test.ts create mode 100644 packages/stripe/src/__test__/verifyStripeSignature.test.ts create mode 100644 packages/stripe/src/__test__/webhookController.test.ts create mode 100644 packages/stripe/src/__test__/webhookHandler.test.ts diff --git a/packages/stripe/.gitignore b/packages/stripe/.gitignore index b94707787..8f240a5eb 100644 --- a/packages/stripe/.gitignore +++ b/packages/stripe/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +/coverage diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md new file mode 100644 index 000000000..089223313 --- /dev/null +++ b/packages/stripe/ANALYSIS.md @@ -0,0 +1,126 @@ + + +# `@prefabs.tech/fastify-stripe` — Analysis + +Package: `packages/stripe` (v0.93.4) +Entry: `src/index.ts` → exports plugin (default), `StripeClient`, `registerRawBodyParser`, `ROUTE_STRIPE_WEBHOOK`, `StripeConfig`, `StripeEvent`. + +--- + +## Base Library Passthrough Analysis + +### `stripe` (Stripe Node SDK, v20.3.1) — PARTIAL PASSTHROUGH / MODIFIED + +- **Options type:** Custom subset (`StripeConfig`). Only the `clientConfig?: Stripe.StripeConfig` field is the SDK's own option type and is forwarded unmodified to `new Stripe(...)`. +- **Options passed:** + - Constructor: `new Stripe(config.stripe.apiKey, config.stripe.clientConfig)` — full passthrough of `clientConfig`. + - `stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)` — full passthrough. + - `stripe.checkout.sessions.create(...)` — **MODIFIED**. We synthesize a fixed shape from `CreateSessionInput`: one `line_items` entry built from `productName`/`unitAmount`/`quantity`/`currency`, hardcoded `payment_intent_data.metadata` mirroring top-level metadata, default `mode = "payment"`, and config-driven URLs/`allow_promotion_codes`. + - `stripe.promotionCodes.list(...)` — **MODIFIED**. Hardcoded `{ active: true, code }`; returns only `data[0]`. +- **Features restricted:** + - Checkout session creation: cannot pass arbitrary `Stripe.Checkout.SessionCreateParams` — no subscription line items by `price` ID, no multi-item carts, no tax rates / discounts / shipping options / locale / customer fields, no `automatic_payment_methods`, etc. Consumers are pinned to a one-product, flat-amount form. + - Promotion code lookup: forced to `active: true`, no pagination, only first match returned. + - Webhook dispatching: not an event-typed router — just a single `(request, event)` callback. Per-event-type wiring is left to the consumer's handler. +- **Features added:** + - Fastify plugin registration with `fastify-plugin` (no encapsulation). + - Module augmentation: `ApiConfig.stripe: StripeConfig` on `@prefabs.tech/fastify-config`. + - Module augmentation: `FastifyRequest.rawBody?: Buffer | string`. + - Conditional webhook route registration via `enablePaymentWebhook` flag. + - Configurable webhook route path (`webhookPath` with `/payment/webhook` default, exported as `ROUTE_STRIPE_WEBHOOK`). + - Built-in `verifyStripeSignature` preHandler with structured 400 responses and pino logging. + - Built-in raw-body content-type parser for `application/json` (writes `request.rawBody`). + - Pluggable webhook handler via `config.stripe.handlers.webhook`, with a sentinel default that throws "Webhook handler not implemented". + - `StripeClient` helper class with config-aware defaulting (`urls.success`, `urls.cancel`, `defaultCurrency`, `allowPromotionCodes`). + - Re-exports: `StripeEvent = Stripe.Event` type alias. + +### `zod` (v3.25.76) — UNUSED + +Declared in `dependencies` but **not imported anywhere in `src/`**. Either dead dependency or reserved for downstream consumer use through the bundler's externals list. + +### `supertokens-node` (peer ≥14.1.3) — UNUSED + +Declared in `peerDependencies` and `devDependencies` but **not imported anywhere in `src/`**. Likely a leftover from a sibling package's template or reserved for future auth-aware behavior. + +### `fastify`, `fastify-plugin`, `@prefabs.tech/fastify-config` — INFRASTRUCTURE + +Used directly for plugin registration, route declaration, content-type parsing, and config typing. No options wrapped — these are the host framework rather than a wrapped dependency. + +--- + +## Summary + +### Public exports + +- **`default` (plugin)** — `fastify-plugin`-wrapped Fastify plugin. Warns and no-ops when `config.stripe` is missing; conditionally registers the webhook controller when `enablePaymentWebhook` is set. +- **`StripeClient`** — class wrapping `Stripe` with config-driven `createCheckoutSession(input, metadata?)` and `getActivePromotionCode(code)` helpers. +- **`registerRawBodyParser`** (alias of `stripeRawBodyParser`) — installs a Fastify `application/json` content-type parser that retains the raw buffer on `request.rawBody`. +- **`ROUTE_STRIPE_WEBHOOK`** — `"/payment/webhook"` constant (default route). +- **`StripeConfig`** (type) — curated subset of plugin configuration (see `types/index.ts`). +- **`StripeEvent`** (type) — alias for `Stripe.Event`. + +### Internal subplugins / modules (not directly exported, but reachable via the default plugin) + +- **`webhookController`** — internal Fastify subplugin that wires raw body parser, `preHandler`, route handler, and dispatch. +- **`verifyStripeSignature`** — preHandler middleware verifying the `stripe-signature` header against the configured `webhookSecret`. +- **`webhookHandler`** (default) — throws `"Webhook handler not implemented"` sentinel. + +### Framework constructs added + +- `fastifyPlugin(plugin)` export — non-encapsulated registration at the top scope of the host Fastify instance. +- Two TypeScript module augmentations: + - `@prefabs.tech/fastify-config` → adds `stripe: StripeConfig` to `ApiConfig`. + - `fastify` → adds `rawBody?: Buffer | string` to `FastifyRequest`. +- `fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, ...)` — overrides Fastify's default JSON parsing for the *entire* instance once the webhook is enabled. +- `fastify.post(...)` route registration with `preHandler: [verifyStripeSignature]`. +- Nested plugin registration: `fastify.register(webhookController)`. +- Inline (non-module-augmented) cast: `request as FastifyRequest & { stripeEvent: Stripe.Event }` — the `stripeEvent` property is *not* declared via `declare module "fastify"`, only set and read through casts. + +### Conditional branches (feature flags / guards) + +1. `plugin.ts`: `if (!config.stripe)` → warn + early return. +2. `plugin.ts`: `if (config.stripe.enablePaymentWebhook)` → register webhook controller. +3. `webhook/controller.ts`: `if (fastify.config.stripe.enablePaymentWebhook)` → register route. **Redundant with #2** — the controller is only registered when the flag is already true. +4. `verifyStripeSignature.ts`: `if (!webhookSecret)` → 400 `"Webhook secret not configured"`. +5. `verifyStripeSignature.ts`: `if (!signature)` → 400 `"Missing stripe-signature header"`. +6. `verifyStripeSignature.ts`: `if (!rawBody)` → 400 `"Raw body is not available for signature verification"`. +7. `verifyStripeSignature.ts`: `try/catch` around `constructEvent` → 400 `"Webhook signature verification failed"`. +8. `webhook/controller.ts`: `if (!event)` → `throw new Error("Stripe event not found on request")`. +9. `webhook/controller.ts`: `if (fastify.config.stripe.handlers?.webhook)` → call custom handler; else fall through to default `webhookHandler`. +10. Five `??` defaults inside `StripeClient.createCheckoutSession` (see Defaults). + +### Defaults + +- `webhookPath` → `ROUTE_STRIPE_WEBHOOK = "/payment/webhook"`. +- `createCheckoutSession`: + - `quantity` → `1` + - `mode` → `"payment"` + - `currency` → `config.stripe.defaultCurrency` + - `successUrl` → `config.stripe.urls.success` + - `cancelUrl` → `config.stripe.urls.cancel` + - `allow_promotion_codes` → `config.stripe.allowPromotionCodes` (passed as-is; `undefined` if unset). +- `getActivePromotionCode` → returns `codes.data[0]` (no fallback; `undefined` when no match). +- Default webhook handler → throws "Webhook handler not implemented" (sentinel, forces consumer to wire `handlers.webhook`). + +### Ours vs theirs at a glance + +| File | "Ours" | "Theirs" | +|---|---|---| +| `index.ts` | Module augmentation of `ApiConfig`; re-exports | — | +| `constants.ts` | Default route path | — | +| `plugin.ts` | Missing-config guard + warn, `enablePaymentWebhook` gate, `fastify-plugin` wrap | — | +| `types/index.ts` | `StripeConfig`, `CreateSessionInput` (curated subsets) | `Stripe.Event` re-export | +| `utils/stripeClient.ts` | Config-aware defaults, flat-input → line_items synthesis, dual metadata placement, single-result helper | `new Stripe(...)`, `sessions.create`, `promotionCodes.list` | +| `utils/stripeRawBodyParser.ts` | Augment `FastifyRequest`, save raw buffer, JSON-parse, error-forward via `done` | `addContentTypeParser`, `JSON.parse` | +| `middlewares/verifyStripeSignature.ts` | Three validation guards, structured 400s, pino logging, attach event to request | `stripe.webhooks.constructEvent` | +| `webhook/controller.ts` | Route registration, raw-body parser install, custom-vs-default dispatch | `fastify.post`, `fastify.register` | +| `webhook/handler.ts` | Sentinel "not implemented" error | — | + +### Notable behaviors / gotchas (for downstream test / doc skills) + +- **Two Stripe client instantiations.** `StripeClient.constructor` creates one; `verifyStripeSignature` creates a fresh `new Stripe(...)` *on every request* just to call `webhooks.constructEvent`. The crypto check doesn't require a fresh SDK instance per request — this is wasteful. +- **Global content-type parser side-effect.** `stripeRawBodyParser` overrides Fastify's default `application/json` parser *for the whole instance* the moment the webhook controller is registered. Any other route on the same instance that reads `application/json` will also get a raw buffer attached to `request.rawBody`. This is implicit and easy to miss. +- **Redundant `enablePaymentWebhook` check** in `webhook/controller.ts` — the controller is only registered when the flag is true (see plugin.ts). +- **`stripeEvent` is cast, not module-augmented** — TypeScript users reading `request.stripeEvent` outside this package will get type errors unless they replicate the cast. +- **Default handler is a sentinel that throws** rather than a no-op — calling the webhook without configuring `handlers.webhook` is a hard error (500 response after preHandler success). +- **Unused dependencies:** `zod` (runtime) and `supertokens-node` (peer + dev) are declared but never imported in `src/`. +- **API version claim:** `README.md` says "API version 2025-12-15.clover", but the source never pins an `apiVersion` in `clientConfig` — Stripe SDK v20.3.1 just uses its compiled default. The README claim is not enforced by code and should be treated as informational only. diff --git a/packages/stripe/FEATURES.md b/packages/stripe/FEATURES.md new file mode 100644 index 000000000..8c70a0def --- /dev/null +++ b/packages/stripe/FEATURES.md @@ -0,0 +1,57 @@ + + +# `@prefabs.tech/fastify-stripe` — Features + +## Plugin Registration + +1. The plugin is exported via `fastify-plugin`, so all registrations attach to the top-level (non-encapsulated) Fastify instance. +2. When `config.stripe` is missing, the plugin logs a warning (`"Stripe configuration is missing. Stripe plugin will not be registered."`) and returns without registering anything. +3. When `config.stripe` is present, the plugin logs `"Registering Stripe plugin"` at `info` level. +4. The webhook controller is registered only when `config.stripe.enablePaymentWebhook` is truthy. + +## Configuration & Type Exports + +5. Module augmentation of `@prefabs.tech/fastify-config` adds `stripe: StripeConfig` to `ApiConfig`. +6. Module augmentation of `fastify` adds `rawBody?: Buffer | string` to `FastifyRequest`. +7. `StripeConfig` type is exported (curated subset; not a direct passthrough of any Stripe SDK type). +8. `StripeEvent` type is exported as an alias for `Stripe.Event`. +9. `CreateSessionInput` type describes the checkout helper input shape. +10. `ROUTE_STRIPE_WEBHOOK` constant (`"/payment/webhook"`) is exported as the default webhook route path. + +## Webhook Endpoint + +11. A `POST` route is registered at `config.stripe.webhookPath`, falling back to `ROUTE_STRIPE_WEBHOOK` (`/payment/webhook`) when unset. +12. The webhook controller logs `"Registering Stripe webhook route"` at `info` level on registration. +13. The verified Stripe event is dispatched to `config.stripe.handlers.webhook` when defined. +14. When `config.stripe.handlers.webhook` is not defined, the request falls through to the default handler, which throws `"Webhook handler not implemented"`. +15. The route handler throws `"Stripe event not found on request"` when the preHandler did not attach `request.stripeEvent` (defensive guard). + +## Webhook Signature Verification + +The `verifyStripeSignature` preHandler runs on the webhook route and performs the following checks in order: + +16. Responds with 400 `{ error: "Webhook secret not configured" }` and logs an error when `config.stripe.webhookSecret` is unset. +17. Responds with 400 `{ error: "Missing stripe-signature header" }` and logs an error when the `stripe-signature` request header is absent. +18. Responds with 400 `{ error: "Raw body is not available for signature verification" }` and logs an error when `request.rawBody` is unset. +19. Responds with 400 `{ error: "Webhook signature verification failed" }` and logs the underlying error when `stripe.webhooks.constructEvent` throws. +20. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (via inline type cast — not module-augmented). + +## Raw Body Parser + +21. `registerRawBodyParser(fastify)` registers a Fastify content-type parser for `application/json` that captures the request buffer to `request.rawBody` and parses JSON for downstream handlers. +22. JSON parse errors are forwarded through `done(error)` so Fastify produces a standard 400 response. +23. The raw body parser is installed automatically by the webhook controller, which means it applies **globally** to every `application/json` route on the same Fastify instance (not only the webhook route). + +## `StripeClient` Helper + +24. `new StripeClient(config)` constructs a `Stripe` SDK client using `config.stripe.apiKey` and forwards `config.stripe.clientConfig` unmodified. +25. The raw `Stripe` SDK instance is exposed as `client.stripe` for direct SDK calls. +26. `createCheckoutSession(input, metadata?)` synthesizes a Checkout session containing exactly one `line_items` entry built from `productName`, `unitAmount`, `quantity`, and `currency`. +27. `createCheckoutSession` defaults `quantity` to `1` when `input.quantity` is unset. +28. `createCheckoutSession` defaults `mode` to `"payment"` when `input.mode` is unset. +29. `createCheckoutSession` defaults `currency` to `config.stripe.defaultCurrency` when `input.currency` is unset. +30. `createCheckoutSession` defaults `success_url` to `config.stripe.urls.success` when `input.successUrl` is unset. +31. `createCheckoutSession` defaults `cancel_url` to `config.stripe.urls.cancel` when `input.cancelUrl` is unset. +32. `createCheckoutSession` forwards `config.stripe.allowPromotionCodes` as the `allow_promotion_codes` parameter (passed as-is — `undefined` is allowed). +33. `createCheckoutSession` writes the `metadata` argument onto **both** `session.metadata` and `session.payment_intent_data.metadata`. +34. `getActivePromotionCode(code)` calls `promotionCodes.list({ active: true, code })` and returns only the first matching `Stripe.PromotionCode` (or `undefined` when there is no match). diff --git a/packages/stripe/GUIDE.md b/packages/stripe/GUIDE.md new file mode 100644 index 000000000..69e066b0a --- /dev/null +++ b/packages/stripe/GUIDE.md @@ -0,0 +1,463 @@ +# `@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 stripe fastify fastify-plugin +``` + +```bash +pnpm add @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config stripe fastify fastify-plugin +``` + +Peer dependencies enforced by `package.json`: `fastify >= 5.2.1`, `fastify-plugin >= 5.0.1`, `@prefabs.tech/fastify-config 0.93.5`, `supertokens-node >= 14.1.3`. (Note: `supertokens-node` is declared as a peer but is not actually imported by this package.) + +### For monorepo development + +```bash +pnpm install +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 the Stripe plugin. The plugin reads everything from `fastify.config.stripe` — there are no register-time options. + +```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); + +await fastify.listen({ port: 3000, host: "0.0.0.0" }); +``` + +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 a *global* `application/json` content-type parser on the Fastify instance. Every JSON route on the same instance will get `request.rawBody` populated. 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.** Our `verifyStripeSignature` preHandler is a thin wrapper that calls `stripe.webhooks.constructEvent(rawBody, signature, secret)` and attaches the result to the request. +- **`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 `fastify.config.stripe` and `request.rawBody` are typed without manual declaration. + +--- + +## Features + +### Plugin registration and missing-config guard + +`stripePlugin` is `fastify-plugin`-wrapped, so its decorations attach to the top-level Fastify instance. If `config.stripe` is missing entirely, the plugin logs a warning and returns — registering the plugin against a config that doesn't have Stripe is safe and never throws. + +```typescript +const config: ApiConfig = { + // ...no `stripe` block +}; + +await fastify.register(configPlugin, { config }); +await fastify.register(stripePlugin); +``` + +Server log: + +``` +WARN: Stripe configuration is missing. Stripe plugin will not be registered. +``` + +### 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, calling the webhook will return a 500 because the default handler throws `"Webhook handler not implemented"`. + +```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 (`verifyStripeSignature`) + +The webhook route runs the `verifyStripeSignature` preHandler before invoking your handler. It validates the `stripe-signature` header against `config.stripe.webhookSecret` using `stripe.webhooks.constructEvent`. All failures return HTTP 400 with a `{ error }` body and log an error line: + +| Condition | Status | Response body | +| ------------------------------------------------------ | ------ | ------------------------------------------------------------------- | +| `config.stripe.webhookSecret` unset | 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 inline cast, so if you read it from outside the package you need to repeat the cast: + +```typescript +import type { FastifyRequest } from "fastify"; +import type Stripe from "stripe"; + +function readEvent(request: FastifyRequest) { + const event = (request as FastifyRequest & { stripeEvent?: Stripe.Event }) + .stripeEvent; + // ... +} +``` + +### 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 normal handlers. The parser is applied to the Fastify instance globally — so any `application/json` route on the same instance will have `request.rawBody` populated. + +If you want to install the parser without enabling the webhook route (e.g. for a custom raw-body-aware endpoint), import it directly: + +```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). + +```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). + +The optional `metadata` argument is written to **both** `session.metadata` and `session.payment_intent_data.metadata`, so it surfaces on the Checkout session *and* on the resulting PaymentIntent: + +```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's default export brings two ambient TypeScript augmentations into scope: + +- `ApiConfig` (from `@prefabs.tech/fastify-config`) gains a `stripe: StripeConfig` field. +- `FastifyRequest` gains an optional `rawBody?: Buffer | string`. + +You do not need to redeclare either — just import the plugin once in your entry file. + +--- + +## 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/package.json b/packages/stripe/package.json index dd13d009c..818c700ed 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-stripe", - "version": "0.93.4", + "version": "0.94.0", "description": "Fastify stripe plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/stripe#readme", "repository": { @@ -27,6 +27,7 @@ "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": { @@ -34,23 +35,25 @@ "zod": "3.25.76" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/tsconfig": "0.5.0", - "@prefabs.tech/fastify-config": "0.93.5", - "eslint": "9.39.2", - "fastify": "5.7.4", + "@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.1", + "prettier": "3.8.3", "supertokens-node": "14.1.4", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4" }, "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.5", - "fastify": ">=5.2.1", + "@prefabs.tech/fastify-config": "0.94.0", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1", - "supertokens-node": ">=14.1.3" + "supertokens-node": ">=14.1.4" }, "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..ab83c9196 --- /dev/null +++ b/packages/stripe/src/__test__/plugin.test.ts @@ -0,0 +1,133 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +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("does not throw when config.stripe is missing", async () => { + await expect(fastify.register(plugin)).resolves.not.toThrow(); + }); + + it("warns when config.stripe is missing", async () => { + const warnSpy = vi.spyOn(fastify.log, "warn"); + await fastify.register(plugin); + await fastify.ready(); + expect(warnSpy).toHaveBeenCalledWith( + "Stripe configuration is missing. Stripe plugin will not be registered.", + ); + }); + + it("does not register the webhook route when config.stripe is missing", async () => { + await fastify.register(plugin); + await fastify.ready(); + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + false, + ); + }); +}); + +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 present", async () => { + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: false }), + }); + const infoSpy = vi.spyOn(fastify.log, "info"); + + await fastify.register(plugin); + await fastify.ready(); + + expect(infoSpy).toHaveBeenCalledWith("Registering Stripe plugin"); + }); + + it("does not register the webhook route when enablePaymentWebhook is false", async () => { + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: false }), + }); + + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + false, + ); + }); + + it("registers the webhook route when enablePaymentWebhook is true", async () => { + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + }); + + await fastify.register(plugin); + 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 }); + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("registers without encapsulation so the route is reachable on the top-level instance", async () => { + await fastify.register(plugin); + 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..5d02a9617 --- /dev/null +++ b/packages/stripe/src/__test__/stripeClient.test.ts @@ -0,0 +1,301 @@ +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); + }); +}); + +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", 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); + }); + + it("metadata is undefined on both placements when 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.metadata).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..3116fc618 --- /dev/null +++ b/packages/stripe/src/__test__/stripeRawBodyParser.test.ts @@ -0,0 +1,131 @@ +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("forwards JSON parse errors via done(error) so the route handler does not run", 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", + }); + + // NOTE: FEATURES.md item 22 claims this produces a 400, but because our + // custom parser does not decorate the SyntaxError with `statusCode: 400`, + // Fastify's default error handler treats it as an unhandled error and + // returns 500. Reported as a concern, see Output Summary. + expect(res.statusCode).toBeGreaterThanOrEqual(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 () => { + // NOTE: FEATURES.md item 23 and ANALYSIS.md both claim the raw body parser + // applies globally to every application/json route on the same Fastify + // instance. That is incorrect: 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. Reported as a concern, see Output Summary. + fastify = Fastify({ logger: false }); + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + }); + + await fastify.register(plugin); + + 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..4ba00cec7 --- /dev/null +++ b/packages/stripe/src/__test__/verifyStripeSignature.test.ts @@ -0,0 +1,371 @@ +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 = vi.fn().mockImplementation(() => ({ + 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 buildFastify = (stripeOverrides: Record = {}) => { + const fastify = Fastify({ logger: { level: "silent" } }); + fastify.decorate("config", { + stripe: createStripeConfig(stripeOverrides), + }); + return fastify; +}; + +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 = buildFastify({ + enablePaymentWebhook: true, + webhookSecret: undefined, + }); + + await fastify.register(plugin); + await fastify.ready(); + + 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 = buildFastify({ + enablePaymentWebhook: true, + webhookSecret: undefined, + }); + const errorSpy = vi.spyOn(fastify.log, "error"); + + await fastify.register(plugin); + await fastify.ready(); + + 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. Skipping signature verification.", + ); + }); +}); + +describe("verifyStripeSignature — signature header missing", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + constructEventMock.mockReturnValue(SAMPLE_EVENT); + fastify = buildFastify({ enablePaymentWebhook: true }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("responds with 400 and 'Missing stripe-signature header' when the header is absent", async () => { + await fastify.register(plugin); + await fastify.ready(); + + 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 fastify.register(plugin); + await fastify.ready(); + + 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 = buildFastify({ enablePaymentWebhook: true }); + }); + + 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 fastify.register(plugin); + await fastify.ready(); + + 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 fastify.register(plugin); + await fastify.ready(); + + 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 }); + fastify.decorate("config", { + stripe: createStripeConfig({ + enablePaymentWebhook: true, + handlers: { webhook: webhookHandlerMock }, + }), + }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("attaches the verified Stripe.Event to the request before the route handler runs", async () => { + await fastify.register(plugin); + await fastify.ready(); + + 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 fastify.register(plugin); + await fastify.ready(); + + 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"); + }); + + it("constructs a fresh Stripe client per request using config.apiKey and clientConfig", async () => { + await fastify.close(); + const clientConfig = { apiVersion: "2025-12-15.clover" as const }; + fastify = Fastify({ logger: false }); + fastify.decorate("config", { + stripe: createStripeConfig({ + apiKey: "sk_custom_key", + clientConfig, + enablePaymentWebhook: true, + handlers: { webhook: webhookHandlerMock }, + }), + }); + + await fastify.register(plugin); + await fastify.ready(); + + await fastify.inject({ + headers: { + "content-type": "application/json", + "stripe-signature": "t=1,v1=sig", + }, + method: "POST", + payload: JSON.stringify({}), + url: "/payment/webhook", + }); + + expect(stripeMock).toHaveBeenCalledWith("sk_custom_key", clientConfig); + }); +}); + +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("verifyStripeSignature — 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 { default: verifyStripeSignature } = + await import("../middlewares/verifyStripeSignature"); + + 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..4afeb65c6 --- /dev/null +++ b/packages/stripe/src/__test__/webhookController.test.ts @@ -0,0 +1,201 @@ +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 = vi.fn().mockImplementation(() => ({ + 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 }); + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + }); + + await fastify.register(plugin); + 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 }); + fastify.decorate("config", { + stripe: createStripeConfig({ + enablePaymentWebhook: true, + webhookPath: "/custom/webhook", + }), + }); + + await fastify.register(plugin); + 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" } }); + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + }); + const infoSpy = vi.spyOn(fastify.log, "info"); + + await fastify.register(plugin); + 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 }); + fastify.decorate("config", { + stripe: createStripeConfig({ + enablePaymentWebhook: true, + handlers: { webhook: webhookHandlerMock }, + }), + }); + + await fastify.register(plugin); + 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("returns 500 with 'Webhook handler not implemented' when no custom handler is configured", async () => { + fastify = Fastify({ logger: false }); + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + }); + + await fastify.register(plugin); + await fastify.ready(); + + const res = await injectWebhook(fastify, "/payment/webhook"); + + expect(res.statusCode).toBe(500); + expect(res.json().message).toBe("Webhook handler not implemented"); + }); + + it("does not call the default handler when handlers.webhook is configured", async () => { + const webhookHandlerMock = vi.fn().mockResolvedValue(); + fastify = Fastify({ logger: false }); + fastify.decorate("config", { + stripe: createStripeConfig({ + enablePaymentWebhook: true, + handlers: { webhook: webhookHandlerMock }, + }), + }); + + await fastify.register(plugin); + await fastify.ready(); + + const res = await injectWebhook(fastify, "/payment/webhook"); + + expect(res.statusCode).toBe(200); + expect(webhookHandlerMock).toHaveBeenCalled(); + }); +}); + +describe("webhookController — defensive guard", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("returns 500 with '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 }); + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + }); + + await fastify.register(plugin); + await fastify.ready(); + + const res = await injectWebhook(fastify, "/payment/webhook"); + + expect(res.statusCode).toBe(500); + expect(res.json().message).toBe("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..0bbd045ba --- /dev/null +++ b/packages/stripe/src/__test__/webhookHandler.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import handleWebhook from "../webhook/handler"; + +describe("default webhookHandler (sentinel)", () => { + it("throws 'Webhook handler not implemented' so consumers who forget to wire handlers.webhook get a hard failure", async () => { + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handleWebhook({} as any, {} as any), + ).rejects.toThrow("Webhook handler not implemented"); + }); +}); diff --git a/packages/stripe/src/plugin.ts b/packages/stripe/src/plugin.ts index 27be3aaa2..c678a1aad 100644 --- a/packages/stripe/src/plugin.ts +++ b/packages/stripe/src/plugin.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance } from "fastify"; + import fastifyPlugin from "fastify-plugin"; import webhookController from "./webhook/controller"; -import type { FastifyInstance } from "fastify"; - const plugin = async (fastify: FastifyInstance) => { const { config, log } = fastify; diff --git a/packages/stripe/src/types/index.ts b/packages/stripe/src/types/index.ts index 5629d67d6..caa53ae2a 100644 --- a/packages/stripe/src/types/index.ts +++ b/packages/stripe/src/types/index.ts @@ -2,6 +2,16 @@ import Stripe from "stripe"; import webhookHandler from "../webhook/handler"; +export type CreateSessionInput = { + cancelUrl?: string; + currency?: string; + mode?: Stripe.Checkout.SessionCreateParams.Mode; + productName: string; + quantity?: number; + successUrl?: string; + unitAmount: number; +}; + export type StripeConfig = { allowPromotionCodes?: boolean; apiKey: string; @@ -18,13 +28,3 @@ export type StripeConfig = { webhookPath?: string; webhookSecret?: string; }; - -export type CreateSessionInput = { - cancelUrl?: string; - currency?: string; - mode?: Stripe.Checkout.SessionCreateParams.Mode; - productName: string; - quantity?: number; - successUrl?: string; - unitAmount: number; -}; diff --git a/packages/stripe/src/utils/stripeClient.ts b/packages/stripe/src/utils/stripeClient.ts index fa65d5246..bce001ff1 100644 --- a/packages/stripe/src/utils/stripeClient.ts +++ b/packages/stripe/src/utils/stripeClient.ts @@ -4,8 +4,8 @@ import Stripe from "stripe"; import { CreateSessionInput } from "../types"; class StripeClient { - protected _config: ApiConfig; public stripe: Stripe; + protected _config: ApiConfig; constructor(config: ApiConfig) { this._config = config; @@ -32,10 +32,10 @@ class StripeClient { }, ], metadata: metadata, + mode: input.mode ?? "payment", payment_intent_data: { metadata: metadata, }, - mode: input.mode ?? "payment", success_url: input.successUrl ?? this._config.stripe.urls.success, }); diff --git a/packages/stripe/src/webhook/controller.ts b/packages/stripe/src/webhook/controller.ts index 8d983ef20..3fa1af1fd 100644 --- a/packages/stripe/src/webhook/controller.ts +++ b/packages/stripe/src/webhook/controller.ts @@ -1,10 +1,10 @@ import { FastifyInstance, FastifyRequest } from "fastify"; import Stripe from "stripe"; -import webhookHandler from "./handler"; import { ROUTE_STRIPE_WEBHOOK } from "../constants"; import verifyStripeSignature from "../middlewares/verifyStripeSignature"; import stripeRawBodyParser from "../utils/stripeRawBodyParser"; +import webhookHandler from "./handler"; const plugin = async (fastify: FastifyInstance) => { if (fastify.config.stripe.enablePaymentWebhook) { diff --git a/packages/stripe/vite.config.ts b/packages/stripe/vite.config.ts index 35af45248..6d2302a9d 100644 --- a/packages/stripe/vite.config.ts +++ b/packages/stripe/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -27,13 +26,13 @@ export default defineConfig(({ mode }) => { globals: { "@prefabs.tech/fastify-error-handler": "PrefabsTechFastifyErrorHandler", - "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", "@prefabs.tech/fastify-graphql": "PrefabsTechFastifyGraphql", + "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", - stripe: "Stripe", mercurius: "mercurius", slonik: "Slonik", + stripe: "Stripe", zod: "zod", }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cde69e79d..ca5de4eb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -491,26 +491,32 @@ importers: version: 3.25.76 devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + 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.93.5 - version: 0.93.5(fastify-plugin@5.1.0)(fastify@5.7.4) + specifier: 0.94.0 + version: link:../config '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.15) + 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.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 supertokens-node: specifier: 14.1.4 version: 14.1.4 @@ -518,8 +524,8 @@ importers: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) + 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) @@ -1186,10 +1192,6 @@ packages: resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.4': resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1619,13 +1621,6 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@prefabs.tech/eslint-config@0.5.0': - resolution: {integrity: sha512-GFzcgTUqi25770aeQ7I89dk9y/ct5PEnUzburmISIewufejcTHcQtqU9EVGmuYECg8ZXRB6OyStULTeCKapAtA==} - peerDependencies: - eslint: '>=9.0.0' - prettier: '>=3.3.3' - typescript: '>=4.9.5' - '@prefabs.tech/eslint-config@0.7.0': resolution: {integrity: sha512-kMLs+ksinlNKa5FfhTb2qNisnU/On+IGrRHbZ3aHq1INF8NsjVRB7Wm0Q9vdnBipco9YOady0qSx1goVnhhC3w==} peerDependencies: @@ -1633,21 +1628,11 @@ packages: prettier: '>=3.3.3' typescript: '>=4.9.5' - '@prefabs.tech/fastify-config@0.93.5': - resolution: {integrity: sha512-jUStwFcjA8YpcE7tDQ/I6rdkuTcc0VjOg+KaT5/Q6t9/f6eq7P8Z2ycKj5fOWYuMmGKlqZdH85ZFKMPyaluSHA==} - engines: {node: '>=20'} - peerDependencies: - fastify: '>=5.2.1' - 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.5.0': - resolution: {integrity: sha512-lpu9UPVDpbpMKlClhImF8x0YIqSm6dTtBpVK8/BFkhfOrJR1hp5l1EBlvbkzEL2hxx1vx/cQ43dEDobAZvBBQA==} - '@prefabs.tech/tsconfig@0.7.0': resolution: {integrity: sha512-MiEvKeoNVPSy79tYQOkFeaBoVViW27JYfSiEbCBT+Fvuk1XEkXyBpSxlpdKQDV5bsAg0C5VNSjRh4qH3eDjA+w==} @@ -2388,14 +2373,6 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vue/tsconfig@0.1.3': - resolution: {integrity: sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg==} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - '@whatwg-node/promise-helpers@1.3.2': resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} engines: {node: '>=16.0.0'} @@ -3286,16 +3263,6 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3410,9 +3377,6 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - fastify@5.7.4: - resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} - fastify@5.8.5: resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} @@ -4928,11 +4892,6 @@ packages: resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} - engines: {node: '>=14'} - hasBin: true - prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -5682,46 +5641,6 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@6.4.2: resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6749,11 +6668,6 @@ snapshots: '@esbuild/win32-x64@0.25.11': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': - dependencies: - eslint: 9.39.2(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -6791,8 +6705,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.39.2': {} - '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -6808,7 +6720,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': {} @@ -7304,35 +7216,6 @@ snapshots: '@pkgr/core@0.2.9': {} - '@prefabs.tech/eslint-config@0.5.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)': - dependencies: - '@eslint/js': 9.39.2 - eslint: 9.39.2(jiti@2.6.1) - eslint-config-prettier: 10.1.8(eslint@9.39.2(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.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-n: 17.20.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) - eslint-plugin-promise: 7.2.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-unicorn: 62.0.0(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-vue: 10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))) - globals: 17.3.0 - prettier: 3.8.1 - typescript: 5.9.3 - typescript-eslint: 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - vue-eslint-parser: 10.2.0(eslint@9.39.2(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.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 @@ -7363,11 +7246,6 @@ snapshots: - eslint-plugin-import-x - supports-color - '@prefabs.tech/fastify-config@0.93.5(fastify-plugin@5.1.0)(fastify@5.7.4)': - dependencies: - fastify: 5.7.4 - fastify-plugin: 5.1.0 - '@prefabs.tech/postgres-migrations@5.4.3': dependencies: pg: 8.20.0 @@ -7375,12 +7253,6 @@ snapshots: transitivePeerDependencies: - pg-native - '@prefabs.tech/tsconfig@0.5.0(@types/node@24.10.15)': - dependencies: - '@vue/tsconfig': 0.1.3(@types/node@24.10.15) - transitivePeerDependencies: - - '@types/node' - '@prefabs.tech/tsconfig@0.7.0': {} '@protobufjs/aspromise@1.1.2': @@ -7996,22 +7868,6 @@ snapshots: '@types/validator@13.15.10': {} - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - eslint: 9.39.2(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/eslint-plugin@8.58.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))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -8028,18 +7884,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.58.0 @@ -8070,18 +7914,6 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.58.0 @@ -8104,24 +7936,13 @@ snapshots: '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -8255,10 +8076,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vue/tsconfig@0.1.3(@types/node@24.10.15)': - optionalDependencies: - '@types/node': 24.10.15 - '@whatwg-node/promise-helpers@1.3.2': dependencies: tslib: 2.8.1 @@ -8962,7 +8779,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -9033,7 +8850,7 @@ snapshots: es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 es-to-primitive@1.3.0: dependencies: @@ -9082,20 +8899,11 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-compat-utils@0.5.1(eslint@9.39.2(jiti@2.6.1)): - dependencies: - eslint: 9.39.2(jiti@2.6.1) - semver: 7.7.4 - eslint-compat-utils@0.5.1(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) semver: 7.7.4 - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): - dependencies: - eslint: 9.39.2(jiti@2.6.1) - eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -9119,21 +8927,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): - dependencies: - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-context: 0.1.9(unrs-resolver@1.11.1) - get-tsconfig: 4.13.1 - is-bun-module: 2.0.0 - stable-hash-x: 0.2.0 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 4.4.3 @@ -9149,17 +8942,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 @@ -9171,13 +8953,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-es-x@7.8.0(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - eslint: 9.39.2(jiti@2.6.1) - eslint-compat-utils: 0.5.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-es-x@7.8.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9185,35 +8960,6 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - 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)): dependencies: '@rtsao/scc': 1.1.0 @@ -9226,7 +8972,7 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.2 + hasown: 2.0.3 is-core-module: 2.16.1 is-glob: 4.0.3 minimatch: 3.1.5 @@ -9243,25 +8989,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): - dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.11.1 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 9.39.2(jiti@2.6.1) - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): dependencies: aria-query: 5.3.2 @@ -9273,7 +9000,7 @@ snapshots: damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 eslint: 9.39.4(jiti@2.6.1) - hasown: 2.0.2 + hasown: 2.0.3 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.5 @@ -9281,23 +9008,6 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-n@17.20.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - enhanced-resolve: 5.19.0 - eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-es-x: 7.8.0(eslint@9.39.2(jiti@2.6.1)) - get-tsconfig: 4.13.1 - globals: 15.15.0 - ignore: 5.3.2 - minimatch: 9.0.5 - semver: 7.7.4 - ts-declaration-location: 1.0.7(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - - typescript - eslint-plugin-n@17.20.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9324,15 +9034,6 @@ snapshots: - supports-color - typescript - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): - dependencies: - eslint: 9.39.2(jiti@2.6.1) - prettier: 3.8.1 - prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 - optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - 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): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -9342,27 +9043,11 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-promise@7.2.1(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-promise@7.2.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 - eslint: 9.39.2(jiti@2.6.1) - hermes-parser: 0.25.1 - zod: 3.25.76 - zod-validation-error: 4.0.2(zod@3.25.76) - transitivePeerDependencies: - - supports-color - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/core': 7.28.5 @@ -9374,28 +9059,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.2 - eslint: 9.39.2(jiti@2.6.1) - estraverse: 5.3.0 - hasown: 2.0.3 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.5 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.5 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -9418,28 +9081,6 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-unicorn@62.0.0(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@eslint/plugin-kit': 0.4.1 - change-case: 5.4.4 - ci-info: 4.4.0 - clean-regexp: 1.0.0 - core-js-compat: 3.48.0 - eslint: 9.39.2(jiti@2.6.1) - esquery: 1.6.0 - find-up-simple: 1.0.1 - globals: 16.5.0 - indent-string: 5.0.0 - is-builtin-module: 5.0.0 - jsesc: 3.1.0 - pluralize: 8.0.0 - regexp-tree: 0.1.27 - regjsparser: 0.13.0 - semver: 7.7.4 - strip-indent: 4.1.1 - eslint-plugin-unicorn@62.0.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -9462,19 +9103,6 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-vue@10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - eslint: 9.39.2(jiti@2.6.1) - natural-compare: 1.4.0 - nth-check: 2.1.1 - postcss-selector-parser: 7.1.1 - semver: 7.7.4 - vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@2.6.1)) - xml-name-validator: 4.0.0 - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - 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))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9499,47 +9127,6 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.2(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.2 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.15.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - eslint@9.39.4(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9661,7 +9248,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 @@ -9694,24 +9281,6 @@ snapshots: fastify-plugin@5.1.0: {} - fastify@5.7.4: - dependencies: - '@fastify/ajv-compiler': 4.0.5 - '@fastify/error': 4.2.0 - '@fastify/fast-json-stringify-compiler': 5.0.3 - '@fastify/proxy-addr': 5.1.0 - abstract-logging: 2.0.1 - avvio: 9.2.0 - fast-json-stringify: 6.3.0 - find-my-way: 9.5.0 - light-my-request: 6.6.0 - pino: 10.3.1 - process-warning: 5.0.0 - rfdc: 1.4.1 - secure-json-parse: 4.1.0 - semver: 7.7.4 - toad-cache: 3.7.0 - fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -9863,7 +9432,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.3 is-callable: 1.2.7 functional-red-black-tree@1.0.1: {} @@ -10312,7 +9881,7 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.3 side-channel: 1.1.0 ipaddr.js@2.4.0: {} @@ -10352,13 +9921,13 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 is-callable@1.2.7: {} is-core-module@2.16.1: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 is-data-view@1.0.2: dependencies: @@ -10419,7 +9988,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 is-set@2.0.3: {} @@ -10478,7 +10047,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 @@ -10743,7 +10312,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 math-intrinsics@1.1.0: {} @@ -11534,8 +11103,6 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.8.1: {} - prettier@3.8.3: {} pretty-ms@7.0.1: @@ -12302,17 +11869,6 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - typescript-eslint@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.58.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))(typescript@5.9.3) @@ -12431,20 +11987,6 @@ snapshots: - tsx - yaml - vite@6.4.1(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): - dependencies: - esbuild: 0.25.11 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.52.5 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.15 - fsevents: 2.3.3 - jiti: 2.6.1 - yaml: 2.8.1 - vite@6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: esbuild: 0.25.11 @@ -12500,18 +12042,6 @@ snapshots: - tsx - yaml - vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1)): - dependencies: - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - vue-eslint-parser@10.2.0(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 4.4.3 From af998cc71d589892aa24a76cff9735f20f9a9d12 Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Tue, 12 May 2026 18:06:03 +0545 Subject: [PATCH 07/16] refactor(stripe): fix mode-aware metadata, scope webhook errors, drop unused deps - Route checkout metadata to the data block matching the session mode (payment_intent_data / subscription_data / setup_intent_data) so Stripe no longer rejects subscription/setup sessions. - Reuse the static Stripe.webhooks.constructEvent instead of constructing a new Stripe client per webhook request. - Tag raw-body JSON parse errors with statusCode 400 so Fastify responds 400 instead of 500. - Declare request.stripeEvent via module augmentation; drop inline casts. - Remove redundant enablePaymentWebhook guard inside the webhook controller. - Drop unused zod dependency and unused supertokens-node peer/dev dependency. - Sync ANALYSIS, FEATURES, GUIDE, README, and tests with new behavior. --- packages/stripe/ANALYSIS.md | 146 ++---- packages/stripe/FEATURES.md | 15 +- packages/stripe/GUIDE.md | 27 +- packages/stripe/README.md | 14 +- packages/stripe/package.json | 7 +- .../stripe/src/__test__/stripeClient.test.ts | 36 +- .../src/__test__/stripeRawBodyParser.test.ts | 17 +- .../__test__/verifyStripeSignature.test.ts | 33 +- .../src/__test__/webhookController.test.ts | 4 +- .../src/middlewares/verifyStripeSignature.ts | 7 +- packages/stripe/src/types/index.ts | 6 + packages/stripe/src/utils/stripeClient.ts | 30 +- .../stripe/src/utils/stripeRawBodyParser.ts | 6 +- packages/stripe/src/webhook/controller.ts | 39 +- pnpm-lock.yaml | 462 +++++++++++++++++- 15 files changed, 626 insertions(+), 223 deletions(-) diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md index 089223313..063c81a8d 100644 --- a/packages/stripe/ANALYSIS.md +++ b/packages/stripe/ANALYSIS.md @@ -1,126 +1,54 @@ -# `@prefabs.tech/fastify-stripe` — Analysis - -Package: `packages/stripe` (v0.93.4) -Entry: `src/index.ts` → exports plugin (default), `StripeClient`, `registerRawBodyParser`, `ROUTE_STRIPE_WEBHOOK`, `StripeConfig`, `StripeEvent`. - ---- - ## Base Library Passthrough Analysis -### `stripe` (Stripe Node SDK, v20.3.1) — PARTIAL PASSTHROUGH / MODIFIED - -- **Options type:** Custom subset (`StripeConfig`). Only the `clientConfig?: Stripe.StripeConfig` field is the SDK's own option type and is forwarded unmodified to `new Stripe(...)`. -- **Options passed:** - - Constructor: `new Stripe(config.stripe.apiKey, config.stripe.clientConfig)` — full passthrough of `clientConfig`. - - `stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)` — full passthrough. - - `stripe.checkout.sessions.create(...)` — **MODIFIED**. We synthesize a fixed shape from `CreateSessionInput`: one `line_items` entry built from `productName`/`unitAmount`/`quantity`/`currency`, hardcoded `payment_intent_data.metadata` mirroring top-level metadata, default `mode = "payment"`, and config-driven URLs/`allow_promotion_codes`. - - `stripe.promotionCodes.list(...)` — **MODIFIED**. Hardcoded `{ active: true, code }`; returns only `data[0]`. -- **Features restricted:** - - Checkout session creation: cannot pass arbitrary `Stripe.Checkout.SessionCreateParams` — no subscription line items by `price` ID, no multi-item carts, no tax rates / discounts / shipping options / locale / customer fields, no `automatic_payment_methods`, etc. Consumers are pinned to a one-product, flat-amount form. - - Promotion code lookup: forced to `active: true`, no pagination, only first match returned. - - Webhook dispatching: not an event-typed router — just a single `(request, event)` callback. Per-event-type wiring is left to the consumer's handler. -- **Features added:** - - Fastify plugin registration with `fastify-plugin` (no encapsulation). - - Module augmentation: `ApiConfig.stripe: StripeConfig` on `@prefabs.tech/fastify-config`. - - Module augmentation: `FastifyRequest.rawBody?: Buffer | string`. - - Conditional webhook route registration via `enablePaymentWebhook` flag. - - Configurable webhook route path (`webhookPath` with `/payment/webhook` default, exported as `ROUTE_STRIPE_WEBHOOK`). - - Built-in `verifyStripeSignature` preHandler with structured 400 responses and pino logging. - - Built-in raw-body content-type parser for `application/json` (writes `request.rawBody`). - - Pluggable webhook handler via `config.stripe.handlers.webhook`, with a sentinel default that throws "Webhook handler not implemented". - - `StripeClient` helper class with config-aware defaulting (`urls.success`, `urls.cancel`, `defaultCurrency`, `allowPromotionCodes`). - - Re-exports: `StripeEvent = Stripe.Event` type alias. - -### `zod` (v3.25.76) — UNUSED - -Declared in `dependencies` but **not imported anywhere in `src/`**. Either dead dependency or reserved for downstream consumer use through the bundler's externals list. - -### `supertokens-node` (peer ≥14.1.3) — UNUSED +### stripe — [PARTIAL PASSTHROUGH / MODIFIED] -Declared in `peerDependencies` and `devDependencies` but **not imported anywhere in `src/`**. Likely a leftover from a sibling package's template or reserved for future auth-aware behavior. - -### `fastify`, `fastify-plugin`, `@prefabs.tech/fastify-config` — INFRASTRUCTURE - -Used directly for plugin registration, route declaration, content-type parsing, and config typing. No options wrapped — these are the host framework rather than a wrapped dependency. - ---- +- Options type: [imported from base library for `clientConfig` (`Stripe.StripeConfig`) | custom subset for checkout options (`CreateSessionInput`)] +- Options passed: [`clientConfig` is passed unmodified to the `Stripe` constructor. For `createCheckoutSession`, input options are transformed into a specific `line_items` structure and defaults are applied. `allowPromotionCodes` is passed through as `allow_promotion_codes`.] +- Features restricted: [`StripeClient.createCheckoutSession` restricts creating sessions to a single product with flat parameters. `StripeClient.getActivePromotionCode` restricts lookup to `active: true` and returns only the first match. Full features are still available via the exposed `client.stripe` property.] +- Features added: [Configurable webhook route registration, signature verification middleware with formatted errors, raw body parser for `application/json` (scoped to the webhook controller's plugin encapsulation), default parameter injection for checkout sessions, mode-aware metadata routing (`payment_intent_data` / `subscription_data` / `setup_intent_data`), typed Fastify request augmentations.] ## Summary -### Public exports - -- **`default` (plugin)** — `fastify-plugin`-wrapped Fastify plugin. Warns and no-ops when `config.stripe` is missing; conditionally registers the webhook controller when `enablePaymentWebhook` is set. -- **`StripeClient`** — class wrapping `Stripe` with config-driven `createCheckoutSession(input, metadata?)` and `getActivePromotionCode(code)` helpers. -- **`registerRawBodyParser`** (alias of `stripeRawBodyParser`) — installs a Fastify `application/json` content-type parser that retains the raw buffer on `request.rawBody`. -- **`ROUTE_STRIPE_WEBHOOK`** — `"/payment/webhook"` constant (default route). -- **`StripeConfig`** (type) — curated subset of plugin configuration (see `types/index.ts`). -- **`StripeEvent`** (type) — alias for `Stripe.Event`. - -### Internal subplugins / modules (not directly exported, but reachable via the default plugin) - -- **`webhookController`** — internal Fastify subplugin that wires raw body parser, `preHandler`, route handler, and dispatch. -- **`verifyStripeSignature`** — preHandler middleware verifying the `stripe-signature` header against the configured `webhookSecret`. -- **`webhookHandler`** (default) — throws `"Webhook handler not implemented"` sentinel. - -### Framework constructs added +### Exports & Functions -- `fastifyPlugin(plugin)` export — non-encapsulated registration at the top scope of the host Fastify instance. -- Two TypeScript module augmentations: - - `@prefabs.tech/fastify-config` → adds `stripe: StripeConfig` to `ApiConfig`. - - `fastify` → adds `rawBody?: Buffer | string` to `FastifyRequest`. -- `fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, ...)` — overrides Fastify's default JSON parsing for the *entire* instance once the webhook is enabled. -- `fastify.post(...)` route registration with `preHandler: [verifyStripeSignature]`. -- Nested plugin registration: `fastify.register(webhookController)`. -- Inline (non-module-augmented) cast: `request as FastifyRequest & { stripeEvent: Stripe.Event }` — the `stripeEvent` property is *not* declared via `declare module "fastify"`, only set and read through casts. +- `default` (stripePlugin): Registers the plugin and optionally the webhook controller if enabled in config. +- `ROUTE_STRIPE_WEBHOOK`: Constant for the default webhook path (`"/payment/webhook"`). +- `StripeClient`: Helper class holding the `Stripe` SDK client and exposing simplified checkout session creation and promotion code lookup. + - `createCheckoutSession`: Synthesizes a one-product Checkout session and applies config defaults. + - `getActivePromotionCode`: Looks up an active promotion code by its string. +- `registerRawBodyParser`: Helper function to add an `application/json` parser that retains `request.rawBody`. +- Type exports: `StripeConfig`, `StripeEvent`, `CreateSessionInput`. -### Conditional branches (feature flags / guards) +### Framework Constructs Added -1. `plugin.ts`: `if (!config.stripe)` → warn + early return. -2. `plugin.ts`: `if (config.stripe.enablePaymentWebhook)` → register webhook controller. -3. `webhook/controller.ts`: `if (fastify.config.stripe.enablePaymentWebhook)` → register route. **Redundant with #2** — the controller is only registered when the flag is already true. -4. `verifyStripeSignature.ts`: `if (!webhookSecret)` → 400 `"Webhook secret not configured"`. -5. `verifyStripeSignature.ts`: `if (!signature)` → 400 `"Missing stripe-signature header"`. -6. `verifyStripeSignature.ts`: `if (!rawBody)` → 400 `"Raw body is not available for signature verification"`. -7. `verifyStripeSignature.ts`: `try/catch` around `constructEvent` → 400 `"Webhook signature verification failed"`. -8. `webhook/controller.ts`: `if (!event)` → `throw new Error("Stripe event not found on request")`. -9. `webhook/controller.ts`: `if (fastify.config.stripe.handlers?.webhook)` → call custom handler; else fall through to default `webhookHandler`. -10. Five `??` defaults inside `StripeClient.createCheckoutSession` (see Defaults). +- Fastify plugin wrapping (`fastify-plugin`) so registrations attach to the top-level instance. +- Fastify module augmentations for `FastifyRequest` (adds `rawBody` and `stripeEvent`). +- Fastify route (`fastify.post`) for the webhook endpoint. +- Fastify `preHandler` (`verifyStripeSignature`) for webhook route middleware. +- Fastify content-type parser (`fastify.addContentTypeParser`) for raw body retention. +- Module augmentation for `@prefabs.tech/fastify-config` `ApiConfig` (adds `stripe`). -### Defaults +### Hooks or Lifecycle Registrations -- `webhookPath` → `ROUTE_STRIPE_WEBHOOK = "/payment/webhook"`. -- `createCheckoutSession`: - - `quantity` → `1` - - `mode` → `"payment"` - - `currency` → `config.stripe.defaultCurrency` - - `successUrl` → `config.stripe.urls.success` - - `cancelUrl` → `config.stripe.urls.cancel` - - `allow_promotion_codes` → `config.stripe.allowPromotionCodes` (passed as-is; `undefined` if unset). -- `getActivePromotionCode` → returns `codes.data[0]` (no fallback; `undefined` when no match). -- Default webhook handler → throws "Webhook handler not implemented" (sentinel, forces consumer to wire `handlers.webhook`). +- Fastify `preHandler` hook on the webhook route (`verifyStripeSignature`). +- Fastify `addContentTypeParser` for `application/json` registered inside the webhook controller's plugin scope (scoped — does not leak to parent). -### Ours vs theirs at a glance +### Conditional Branches -| File | "Ours" | "Theirs" | -|---|---|---| -| `index.ts` | Module augmentation of `ApiConfig`; re-exports | — | -| `constants.ts` | Default route path | — | -| `plugin.ts` | Missing-config guard + warn, `enablePaymentWebhook` gate, `fastify-plugin` wrap | — | -| `types/index.ts` | `StripeConfig`, `CreateSessionInput` (curated subsets) | `Stripe.Event` re-export | -| `utils/stripeClient.ts` | Config-aware defaults, flat-input → line_items synthesis, dual metadata placement, single-result helper | `new Stripe(...)`, `sessions.create`, `promotionCodes.list` | -| `utils/stripeRawBodyParser.ts` | Augment `FastifyRequest`, save raw buffer, JSON-parse, error-forward via `done` | `addContentTypeParser`, `JSON.parse` | -| `middlewares/verifyStripeSignature.ts` | Three validation guards, structured 400s, pino logging, attach event to request | `stripe.webhooks.constructEvent` | -| `webhook/controller.ts` | Route registration, raw-body parser install, custom-vs-default dispatch | `fastify.post`, `fastify.register` | -| `webhook/handler.ts` | Sentinel "not implemented" error | — | +- If `config.stripe` is missing: Logs a warning and returns without registering anything. +- If `config.stripe.enablePaymentWebhook` is truthy: Registers the webhook controller route and raw body parser. +- If `config.stripe.webhookPath` is defined: Uses it instead of the default `ROUTE_STRIPE_WEBHOOK`. +- If `config.stripe.handlers?.webhook` is defined: Delegates the Stripe event to this custom handler. Otherwise, falls through to a default handler that throws `"Webhook handler not implemented"`. +- In `verifyStripeSignature`: Early returns HTTP 400 if `webhookSecret` is missing, `stripe-signature` header is missing, `request.rawBody` is missing, or `Stripe.webhooks.constructEvent` throws an error. -### Notable behaviors / gotchas (for downstream test / doc skills) +### Default Values -- **Two Stripe client instantiations.** `StripeClient.constructor` creates one; `verifyStripeSignature` creates a fresh `new Stripe(...)` *on every request* just to call `webhooks.constructEvent`. The crypto check doesn't require a fresh SDK instance per request — this is wasteful. -- **Global content-type parser side-effect.** `stripeRawBodyParser` overrides Fastify's default `application/json` parser *for the whole instance* the moment the webhook controller is registered. Any other route on the same instance that reads `application/json` will also get a raw buffer attached to `request.rawBody`. This is implicit and easy to miss. -- **Redundant `enablePaymentWebhook` check** in `webhook/controller.ts` — the controller is only registered when the flag is true (see plugin.ts). -- **`stripeEvent` is cast, not module-augmented** — TypeScript users reading `request.stripeEvent` outside this package will get type errors unless they replicate the cast. -- **Default handler is a sentinel that throws** rather than a no-op — calling the webhook without configuring `handlers.webhook` is a hard error (500 response after preHandler success). -- **Unused dependencies:** `zod` (runtime) and `supertokens-node` (peer + dev) are declared but never imported in `src/`. -- **API version claim:** `README.md` says "API version 2025-12-15.clover", but the source never pins an `apiVersion` in `clientConfig` — Stripe SDK v20.3.1 just uses its compiled default. The README claim is not enforced by code and should be treated as informational only. +- Webhook Path: `"/payment/webhook"` +- `createCheckoutSession` defaults: + - `quantity`: `1` + - `mode`: `"payment"` + - `currency`: Falls back to `config.stripe.defaultCurrency` + - `successUrl`: Falls back to `config.stripe.urls.success` + - `cancelUrl`: Falls back to `config.stripe.urls.cancel` diff --git a/packages/stripe/FEATURES.md b/packages/stripe/FEATURES.md index 8c70a0def..9db36b4b6 100644 --- a/packages/stripe/FEATURES.md +++ b/packages/stripe/FEATURES.md @@ -12,7 +12,7 @@ ## Configuration & Type Exports 5. Module augmentation of `@prefabs.tech/fastify-config` adds `stripe: StripeConfig` to `ApiConfig`. -6. Module augmentation of `fastify` adds `rawBody?: Buffer | string` to `FastifyRequest`. +6. Module augmentation of `fastify` adds `rawBody?: Buffer | string` and `stripeEvent?: Stripe.Event` to `FastifyRequest`. 7. `StripeConfig` type is exported (curated subset; not a direct passthrough of any Stripe SDK type). 8. `StripeEvent` type is exported as an alias for `Stripe.Event`. 9. `CreateSessionInput` type describes the checkout helper input shape. @@ -34,13 +34,13 @@ The `verifyStripeSignature` preHandler runs on the webhook route and performs th 17. Responds with 400 `{ error: "Missing stripe-signature header" }` and logs an error when the `stripe-signature` request header is absent. 18. Responds with 400 `{ error: "Raw body is not available for signature verification" }` and logs an error when `request.rawBody` is unset. 19. Responds with 400 `{ error: "Webhook signature verification failed" }` and logs the underlying error when `stripe.webhooks.constructEvent` throws. -20. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (via inline type cast — not module-augmented). +20. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (available via module augmentation). ## Raw Body Parser 21. `registerRawBodyParser(fastify)` registers a Fastify content-type parser for `application/json` that captures the request buffer to `request.rawBody` and parses JSON for downstream handlers. -22. JSON parse errors are forwarded through `done(error)` so Fastify produces a standard 400 response. -23. The raw body parser is installed automatically by the webhook controller, which means it applies **globally** to every `application/json` route on the same Fastify instance (not only the webhook route). +22. JSON parse errors are tagged with `statusCode: 400` and forwarded through `done(error)`, so Fastify's default error handler produces a 400 response. +23. When the webhook controller installs the raw body parser, the parser is scoped to the webhook controller's plugin encapsulation. It applies to the webhook route but does **not** bleed into other `application/json` routes registered on the parent Fastify instance. Calling `registerRawBodyParser(fastify)` directly on the parent installs it on that instance instead. ## `StripeClient` Helper @@ -53,5 +53,10 @@ The `verifyStripeSignature` preHandler runs on the webhook route and performs th 30. `createCheckoutSession` defaults `success_url` to `config.stripe.urls.success` when `input.successUrl` is unset. 31. `createCheckoutSession` defaults `cancel_url` to `config.stripe.urls.cancel` when `input.cancelUrl` is unset. 32. `createCheckoutSession` forwards `config.stripe.allowPromotionCodes` as the `allow_promotion_codes` parameter (passed as-is — `undefined` is allowed). -33. `createCheckoutSession` writes the `metadata` argument onto **both** `session.metadata` and `session.payment_intent_data.metadata`. +33. `createCheckoutSession` always writes the `metadata` argument onto `session.metadata`. It additionally writes it onto the mode-specific data block: + - `mode: "payment"` → `payment_intent_data.metadata` + - `mode: "subscription"` → `subscription_data.metadata` + - `mode: "setup"` → `setup_intent_data.metadata` + + Only the block matching the selected mode is set; the others are left unset so Stripe does not reject the call. 34. `getActivePromotionCode(code)` calls `promotionCodes.list({ active: true, code })` and returns only the first matching `Stripe.PromotionCode` (or `undefined` when there is no match). diff --git a/packages/stripe/GUIDE.md b/packages/stripe/GUIDE.md index 69e066b0a..40e459be4 100644 --- a/packages/stripe/GUIDE.md +++ b/packages/stripe/GUIDE.md @@ -16,12 +16,13 @@ npm install @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config stripe fas pnpm add @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config stripe fastify fastify-plugin ``` -Peer dependencies enforced by `package.json`: `fastify >= 5.2.1`, `fastify-plugin >= 5.0.1`, `@prefabs.tech/fastify-config 0.93.5`, `supertokens-node >= 14.1.3`. (Note: `supertokens-node` is declared as a peer but is not actually imported by this package.) +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 @@ -67,7 +68,7 @@ await fastify.listen({ port: 3000, host: "0.0.0.0" }); 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 a *global* `application/json` content-type parser on the Fastify instance. Every JSON route on the same instance will get `request.rawBody` populated. See the [Raw Body Parser](#raw-body-parser-registerrawbodyparser) section. +> **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. --- @@ -194,24 +195,24 @@ The webhook route runs the `verifyStripeSignature` preHandler before invoking yo | `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 inline cast, so if you read it from outside the package you need to repeat the cast: +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"; -import type Stripe from "stripe"; function readEvent(request: FastifyRequest) { - const event = (request as FastifyRequest & { stripeEvent?: Stripe.Event }) - .stripeEvent; + const event = request.stripeEvent; // ... } ``` ### 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 normal handlers. The parser is applied to the Fastify instance globally — so any `application/json` route on the same instance will have `request.rawBody` populated. +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. -If you want to install the parser without enabling the webhook route (e.g. for a custom raw-body-aware endpoint), import it directly: +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"; @@ -284,7 +285,15 @@ console.log(session.url); `config.stripe.allowPromotionCodes` is forwarded as `allow_promotion_codes` (passing `undefined` is fine — Stripe ignores it). -The optional `metadata` argument is written to **both** `session.metadata` and `session.payment_intent_data.metadata`, so it surfaces on the Checkout session *and* on the resulting PaymentIntent: +The optional `metadata` argument is always written to `session.metadata`, and additionally 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: ```typescript await client.createCheckoutSession( diff --git a/packages/stripe/README.md b/packages/stripe/README.md index 042132628..d969d0d77 100644 --- a/packages/stripe/README.md +++ b/packages/stripe/README.md @@ -163,4 +163,16 @@ const config: ApiConfig = { ## API Version -This package uses Stripe API version: `2025-12-15.clover` +This package does not pin a Stripe API version — it forwards `config.stripe.clientConfig` (including `apiVersion`) unmodified to the [`Stripe`](https://github.com/stripe/stripe-node) constructor. When `apiVersion` is omitted, the default pinned by the installed `stripe` SDK is used. For production deployments, pin `apiVersion` explicitly via `clientConfig.apiVersion` so SDK upgrades don't silently change the API version: + +```typescript +const config: ApiConfig = { + stripe: { + apiKey: process.env.STRIPE_API_KEY!, + // ... + clientConfig: { + apiVersion: "2026-01-28.clover", + }, + }, +}; +``` diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 818c700ed..3b6b883ed 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -31,8 +31,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { - "stripe": "20.3.1", - "zod": "3.25.76" + "stripe": "20.3.1" }, "devDependencies": { "@prefabs.tech/eslint-config": "0.7.0", @@ -44,7 +43,6 @@ "fastify": "5.8.5", "fastify-plugin": "5.1.0", "prettier": "3.8.3", - "supertokens-node": "14.1.4", "typescript": "5.9.3", "vite": "6.4.2", "vitest": "3.2.4" @@ -52,8 +50,7 @@ "peerDependencies": { "@prefabs.tech/fastify-config": "0.94.0", "fastify": ">=5.2.2", - "fastify-plugin": ">=5.0.1", - "supertokens-node": ">=14.1.4" + "fastify-plugin": ">=5.0.1" }, "engines": { "node": ">=20" diff --git a/packages/stripe/src/__test__/stripeClient.test.ts b/packages/stripe/src/__test__/stripeClient.test.ts index 5d02a9617..6b4144094 100644 --- a/packages/stripe/src/__test__/stripeClient.test.ts +++ b/packages/stripe/src/__test__/stripeClient.test.ts @@ -218,7 +218,7 @@ describe("StripeClient — createCheckoutSession synthesis", async () => { ).toBeUndefined(); }); - it("writes metadata onto both session.metadata and payment_intent_data.metadata", async () => { + 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( @@ -229,9 +229,11 @@ describe("StripeClient — createCheckoutSession synthesis", async () => { 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("metadata is undefined on both placements when not provided", async () => { + it("metadata is undefined on both session and payment_intent_data placements when not provided", async () => { const client = buildClient(); await client.createCheckoutSession({ productName: "Hat", @@ -243,6 +245,36 @@ describe("StripeClient — createCheckoutSession synthesis", async () => { expect(arguments_.payment_intent_data.metadata).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); diff --git a/packages/stripe/src/__test__/stripeRawBodyParser.test.ts b/packages/stripe/src/__test__/stripeRawBodyParser.test.ts index 3116fc618..f263bb374 100644 --- a/packages/stripe/src/__test__/stripeRawBodyParser.test.ts +++ b/packages/stripe/src/__test__/stripeRawBodyParser.test.ts @@ -63,7 +63,7 @@ describe("stripeRawBodyParser — direct registration", () => { expect(receivedBody).toEqual({ foo: "bar", n: 42 }); }); - it("forwards JSON parse errors via done(error) so the route handler does not run", async () => { + 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()); @@ -74,11 +74,7 @@ describe("stripeRawBodyParser — direct registration", () => { url: "/test", }); - // NOTE: FEATURES.md item 22 claims this produces a 400, but because our - // custom parser does not decorate the SyntaxError with `statusCode: 400`, - // Fastify's default error handler treats it as an unhandled error and - // returns 500. Reported as a concern, see Output Summary. - expect(res.statusCode).toBeGreaterThanOrEqual(400); + expect(res.statusCode).toBe(400); expect(handlerSpy).not.toHaveBeenCalled(); }); }); @@ -97,12 +93,9 @@ describe("stripeRawBodyParser — scoping when installed by the webhook controll }); it("does NOT apply the raw body parser to routes registered outside the webhook controller scope", async () => { - // NOTE: FEATURES.md item 23 and ANALYSIS.md both claim the raw body parser - // applies globally to every application/json route on the same Fastify - // instance. That is incorrect: 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. Reported as a concern, see Output Summary. + // 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 }); fastify.decorate("config", { stripe: createStripeConfig({ enablePaymentWebhook: true }), diff --git a/packages/stripe/src/__test__/verifyStripeSignature.test.ts b/packages/stripe/src/__test__/verifyStripeSignature.test.ts index 4ba00cec7..b85e55126 100644 --- a/packages/stripe/src/__test__/verifyStripeSignature.test.ts +++ b/packages/stripe/src/__test__/verifyStripeSignature.test.ts @@ -6,9 +6,9 @@ import createStripeConfig from "./helpers/createStripeConfig"; const { constructEventMock, stripeMock } = vi.hoisted(() => { const constructEventMock = vi.fn(); - const stripeMock = vi.fn().mockImplementation(() => ({ + const stripeMock = Object.assign(vi.fn(), { webhooks: { constructEvent: constructEventMock }, - })); + }); return { constructEventMock, stripeMock }; }); @@ -268,35 +268,6 @@ describe("verifyStripeSignature — success", async () => { expect(signature).toBe("t=1,v1=expected"); expect(secret).toBe("whsec_test_dummy"); }); - - it("constructs a fresh Stripe client per request using config.apiKey and clientConfig", async () => { - await fastify.close(); - const clientConfig = { apiVersion: "2025-12-15.clover" as const }; - fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ - apiKey: "sk_custom_key", - clientConfig, - enablePaymentWebhook: true, - handlers: { webhook: webhookHandlerMock }, - }), - }); - - await fastify.register(plugin); - await fastify.ready(); - - await fastify.inject({ - headers: { - "content-type": "application/json", - "stripe-signature": "t=1,v1=sig", - }, - method: "POST", - payload: JSON.stringify({}), - url: "/payment/webhook", - }); - - expect(stripeMock).toHaveBeenCalledWith("sk_custom_key", clientConfig); - }); }); const buildBareRequest = () => ({ diff --git a/packages/stripe/src/__test__/webhookController.test.ts b/packages/stripe/src/__test__/webhookController.test.ts index 4afeb65c6..fc029008d 100644 --- a/packages/stripe/src/__test__/webhookController.test.ts +++ b/packages/stripe/src/__test__/webhookController.test.ts @@ -6,9 +6,9 @@ import createStripeConfig from "./helpers/createStripeConfig"; const { constructEventMock, stripeMock } = vi.hoisted(() => { const constructEventMock = vi.fn(); - const stripeMock = vi.fn().mockImplementation(() => ({ + const stripeMock = Object.assign(vi.fn(), { webhooks: { constructEvent: constructEventMock }, - })); + }); return { constructEventMock, stripeMock }; }); diff --git a/packages/stripe/src/middlewares/verifyStripeSignature.ts b/packages/stripe/src/middlewares/verifyStripeSignature.ts index f17c557be..8f4ecd2b0 100644 --- a/packages/stripe/src/middlewares/verifyStripeSignature.ts +++ b/packages/stripe/src/middlewares/verifyStripeSignature.ts @@ -25,8 +25,6 @@ const verifyStripeSignature = async ( } try { - const stripe = new Stripe(config.stripe.apiKey, config.stripe.clientConfig); - const rawBody = request.rawBody; if (!rawBody) { @@ -37,14 +35,13 @@ const verifyStripeSignature = async ( }); } - const event = stripe.webhooks.constructEvent( + const event = Stripe.webhooks.constructEvent( rawBody, signature, webhookSecret, ); - (request as FastifyRequest & { stripeEvent: Stripe.Event }).stripeEvent = - event; + request.stripeEvent = event; } catch (error) { log.error({ err: error }, "Stripe webhook signature verification failed"); return reply diff --git a/packages/stripe/src/types/index.ts b/packages/stripe/src/types/index.ts index caa53ae2a..3d7575403 100644 --- a/packages/stripe/src/types/index.ts +++ b/packages/stripe/src/types/index.ts @@ -2,6 +2,12 @@ import Stripe from "stripe"; import webhookHandler from "../webhook/handler"; +declare module "fastify" { + interface FastifyRequest { + stripeEvent?: Stripe.Event; + } +} + export type CreateSessionInput = { cancelUrl?: string; currency?: string; diff --git a/packages/stripe/src/utils/stripeClient.ts b/packages/stripe/src/utils/stripeClient.ts index bce001ff1..06c19b227 100644 --- a/packages/stripe/src/utils/stripeClient.ts +++ b/packages/stripe/src/utils/stripeClient.ts @@ -16,7 +16,9 @@ class StripeClient { input: CreateSessionInput, metadata?: Record, ): Promise> { - const session = await this.stripe.checkout.sessions.create({ + const mode = input.mode ?? "payment"; + + const parameters: Stripe.Checkout.SessionCreateParams = { allow_promotion_codes: this._config.stripe.allowPromotionCodes, cancel_url: input.cancelUrl ?? this._config.stripe.urls.cancel, line_items: [ @@ -32,14 +34,28 @@ class StripeClient { }, ], metadata: metadata, - mode: input.mode ?? "payment", - payment_intent_data: { - metadata: metadata, - }, + mode, success_url: input.successUrl ?? this._config.stripe.urls.success, - }); + }; + + // Stripe rejects mode-specific `*_data` blocks for the wrong mode, so + // route metadata to the field that matches the selected mode. + switch (mode) { + case "payment": { + parameters.payment_intent_data = { metadata: metadata }; + break; + } + case "setup": { + parameters.setup_intent_data = { metadata: metadata }; + break; + } + case "subscription": { + parameters.subscription_data = { metadata: metadata }; + break; + } + } - return session; + return this.stripe.checkout.sessions.create(parameters); } public async getActivePromotionCode( diff --git a/packages/stripe/src/utils/stripeRawBodyParser.ts b/packages/stripe/src/utils/stripeRawBodyParser.ts index 26ba9bd26..3e1c16afd 100644 --- a/packages/stripe/src/utils/stripeRawBodyParser.ts +++ b/packages/stripe/src/utils/stripeRawBodyParser.ts @@ -19,7 +19,11 @@ const stripeRawBodyParser = (fastify: FastifyInstance): void => { // eslint-disable-next-line unicorn/no-null done(null, json); } catch (error) { - done(error as 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); } }, ); diff --git a/packages/stripe/src/webhook/controller.ts b/packages/stripe/src/webhook/controller.ts index 3fa1af1fd..3289822da 100644 --- a/packages/stripe/src/webhook/controller.ts +++ b/packages/stripe/src/webhook/controller.ts @@ -1,5 +1,4 @@ import { FastifyInstance, FastifyRequest } from "fastify"; -import Stripe from "stripe"; import { ROUTE_STRIPE_WEBHOOK } from "../constants"; import verifyStripeSignature from "../middlewares/verifyStripeSignature"; @@ -7,33 +6,29 @@ import stripeRawBodyParser from "../utils/stripeRawBodyParser"; import webhookHandler from "./handler"; const plugin = async (fastify: FastifyInstance) => { - if (fastify.config.stripe.enablePaymentWebhook) { - fastify.log.info("Registering Stripe webhook route"); + fastify.log.info("Registering Stripe webhook route"); - stripeRawBodyParser(fastify); + stripeRawBodyParser(fastify); - fastify.post( - fastify.config.stripe.webhookPath || ROUTE_STRIPE_WEBHOOK, - { preHandler: [verifyStripeSignature] }, - async (request: FastifyRequest) => { - const event = ( - request as FastifyRequest & { stripeEvent?: Stripe.Event } - ).stripeEvent; + fastify.post( + fastify.config.stripe.webhookPath || ROUTE_STRIPE_WEBHOOK, + { preHandler: [verifyStripeSignature] }, + async (request: FastifyRequest) => { + const event = request.stripeEvent; - if (!event) { - throw new Error("Stripe event not found on request"); - } + if (!event) { + throw new Error("Stripe event not found on request"); + } - if (fastify.config.stripe.handlers?.webhook) { - await fastify.config.stripe.handlers.webhook(request, event); + if (fastify.config.stripe.handlers?.webhook) { + await fastify.config.stripe.handlers.webhook(request, event); - return; - } + return; + } - await webhookHandler(request, event); - }, - ); - } + await webhookHandler(request, event); + }, + ); }; export default plugin; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca5de4eb6..c00914a7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -486,13 +486,10 @@ importers: stripe: specifier: 20.3.1 version: 20.3.1(@types/node@24.10.15) - zod: - specifier: 3.25.76 - version: 3.25.76 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) + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/fastify-config': specifier: 0.94.0 version: link:../config @@ -504,10 +501,10 @@ importers: 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)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)) eslint: specifier: 9.39.4 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4 fastify: specifier: 5.8.5 version: 5.8.5 @@ -517,18 +514,15 @@ importers: prettier: specifier: 3.8.3 version: 3.8.3 - supertokens-node: - specifier: 14.1.4 - version: 14.1.4 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) + version: 6.4.2(@types/node@24.10.15) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15) packages/swagger: dependencies: @@ -6673,6 +6667,11 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.2': {} '@eslint/config-array@0.21.2': @@ -7246,6 +7245,36 @@ snapshots: - eslint-plugin-import-x - supports-color + '@prefabs.tech/eslint-config@0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(prettier@3.8.3)(typescript@5.9.3)': + dependencies: + '@eslint/js': 9.39.4 + eslint: 9.39.4 + eslint-config-prettier: 10.1.8(eslint@9.39.4) + 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) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4) + eslint-plugin-n: 17.20.0(eslint@9.39.4)(typescript@5.9.3) + eslint-plugin-perfectionist: 5.9.0(eslint@9.39.4)(typescript@5.9.3) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.3) + eslint-plugin-promise: 7.2.1(eslint@9.39.4) + eslint-plugin-react: 7.37.5(eslint@9.39.4) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4) + eslint-plugin-unicorn: 62.0.0(eslint@9.39.4) + eslint-plugin-vue: 10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.2.0(eslint@9.39.4)) + globals: 17.3.0 + prettier: 3.8.3 + typescript: 5.9.3 + typescript-eslint: 8.58.0(eslint@9.39.4)(typescript@5.9.3) + vue-eslint-parser: 10.2.0(eslint@9.39.4) + transitivePeerDependencies: + - '@stylistic/eslint-plugin' + - '@types/eslint' + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + '@prefabs.tech/postgres-migrations@5.4.3': dependencies: pg: 8.20.0 @@ -7884,6 +7913,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.58.0 @@ -7896,6 +7941,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) @@ -7926,6 +7983,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.58.0': {} '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': @@ -7954,6 +8023,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.58.0': dependencies: '@typescript-eslint/types': 8.58.0 @@ -8034,6 +8114,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-istanbul@3.2.4(vitest@3.2.4(@types/node@24.10.15))': + dependencies: + '@istanbuljs/schema': 0.1.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magicast: 0.3.5 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@24.10.15) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -8050,6 +8146,14 @@ snapshots: optionalDependencies: vite: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) + '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.10.15))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.2(@types/node@24.10.15) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -8904,10 +9008,19 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) semver: 7.7.4 + eslint-compat-utils@0.5.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + semver: 7.7.4 + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) + eslint-config-prettier@10.1.8(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: get-tsconfig: 4.13.1 @@ -8917,7 +9030,7 @@ snapshots: eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0): dependencies: - 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-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) eslint-import-resolver-node@0.3.9: dependencies: @@ -8942,6 +9055,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4): + dependencies: + debug: 4.4.3 + eslint: 9.39.4 + eslint-import-context: 0.1.9(unrs-resolver@1.11.1) + get-tsconfig: 4.13.1 + is-bun-module: 2.0.0 + stable-hash-x: 0.2.0 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + transitivePeerDependencies: + - supports-color + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 @@ -8953,6 +9081,17 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) + transitivePeerDependencies: + - supports-color + eslint-plugin-es-x@7.8.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -8960,6 +9099,13 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-es-x@7.8.0(eslint@9.39.4): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + eslint: 9.39.4 + eslint-compat-utils: 0.5.1(eslint@9.39.4) + 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)): dependencies: '@rtsao/scc': 1.1.0 @@ -8989,6 +9135,35 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + hasown: 2.0.3 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): dependencies: aria-query: 5.3.2 @@ -9008,6 +9183,25 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + eslint-plugin-n@17.20.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9025,6 +9219,23 @@ snapshots: - supports-color - typescript + eslint-plugin-n@17.20.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + enhanced-resolve: 5.19.0 + eslint: 9.39.4 + eslint-plugin-es-x: 7.8.0(eslint@9.39.4) + get-tsconfig: 4.13.1 + globals: 15.15.0 + ignore: 5.3.2 + minimatch: 9.0.5 + semver: 7.7.4 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + eslint-plugin-perfectionist@5.9.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) @@ -9034,6 +9245,15 @@ snapshots: - supports-color - typescript + eslint-plugin-perfectionist@5.9.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + 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): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -9043,11 +9263,25 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.3): + dependencies: + eslint: 9.39.4 + prettier: 3.8.3 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.12 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@9.39.4) + eslint-plugin-promise@7.2.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) eslint: 9.39.4(jiti@2.6.1) + eslint-plugin-promise@7.2.1(eslint@9.39.4): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + eslint: 9.39.4 + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/core': 7.28.5 @@ -9059,6 +9293,17 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4): + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + eslint: 9.39.4 + hermes-parser: 0.25.1 + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -9081,6 +9326,28 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.37.5(eslint@9.39.4): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.2 + eslint: 9.39.4 + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + eslint-plugin-unicorn@62.0.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -9103,6 +9370,28 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 + eslint-plugin-unicorn@62.0.0(eslint@9.39.4): + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint/plugin-kit': 0.4.1 + change-case: 5.4.4 + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.48.0 + eslint: 9.39.4 + esquery: 1.6.0 + find-up-simple: 1.0.1 + globals: 16.5.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.0 + semver: 7.7.4 + strip-indent: 4.1.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))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9116,6 +9405,19 @@ snapshots: optionalDependencies: '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-vue@10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.2.0(eslint@9.39.4)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + eslint: 9.39.4 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.7.4 + vue-eslint-parser: 10.2.0(eslint@9.39.4) + xml-name-validator: 4.0.0 + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -9127,6 +9429,45 @@ snapshots: eslint-visitor-keys@5.0.1: {} + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + eslint@9.39.4(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -11880,6 +12221,17 @@ snapshots: transitivePeerDependencies: - supports-color + typescript-eslint@8.58.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} uglify-js@3.19.3: {} @@ -11966,6 +12318,27 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@24.10.15): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.2(@types/node@24.10.15) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: cac: 6.7.14 @@ -11987,6 +12360,18 @@ snapshots: - tsx - yaml + vite@6.4.2(@types/node@24.10.15): + dependencies: + esbuild: 0.25.11 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.5 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.15 + fsevents: 2.3.3 + vite@6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: esbuild: 0.25.11 @@ -12001,6 +12386,47 @@ snapshots: jiti: 2.6.1 yaml: 2.8.1 + vitest@3.2.4(@types/node@24.10.15): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.10.15)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.2(@types/node@24.10.15) + vite-node: 3.2.4(@types/node@24.10.15) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 @@ -12054,6 +12480,18 @@ snapshots: transitivePeerDependencies: - supports-color + vue-eslint-parser@10.2.0(eslint@9.39.4): + dependencies: + debug: 4.4.3 + eslint: 9.39.4 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + walk-up-path@3.0.1: {} web-resource-inliner@6.0.1: From 4b33c39e1247a1e7123cbe44eced9eb4bb1f0e1d Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Tue, 12 May 2026 18:23:41 +0545 Subject: [PATCH 08/16] refactor(stripe): make config.stripe optional and harden webhook fallbacks Treat config.stripe as optional in ApiConfig and guard every consumer (StripeClient, webhook controller, verifyStripeSignature) so the plugin is safe to load without a Stripe block. Default webhook handler now logs an error and resolves instead of throwing, so missing handler config no longer triggers infinite Stripe retries; the controller also warns at registration time and replies 500 (rather than throwing) if a verified event is somehow absent. Only populate session/mode-specific metadata when metadata is actually provided. Updates tests, ANALYSIS/FEATURES/ GUIDE/README accordingly, documents the raw-body parser's plugin-scope contract, and trims unused rollup globals. --- packages/stripe/ANALYSIS.md | 2 +- packages/stripe/FEATURES.md | 53 ++++++++------- packages/stripe/GUIDE.md | 19 ++++-- packages/stripe/README.md | 2 +- .../stripe/src/__test__/stripeClient.test.ts | 15 ++++- .../__test__/verifyStripeSignature.test.ts | 2 +- .../src/__test__/webhookController.test.ts | 66 +++++++++++++++++-- .../src/__test__/webhookHandler.test.ts | 43 ++++++++++-- packages/stripe/src/index.ts | 2 +- .../src/middlewares/verifyStripeSignature.ts | 4 +- packages/stripe/src/utils/stripeClient.ts | 48 +++++++++----- .../stripe/src/utils/stripeRawBodyParser.ts | 18 ++++- packages/stripe/src/webhook/controller.ts | 37 +++++++++-- packages/stripe/src/webhook/handler.ts | 13 +++- packages/stripe/tsconfig.json | 6 +- packages/stripe/vite.config.ts | 8 +-- 16 files changed, 251 insertions(+), 87 deletions(-) diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md index 063c81a8d..16b9feb83 100644 --- a/packages/stripe/ANALYSIS.md +++ b/packages/stripe/ANALYSIS.md @@ -40,7 +40,7 @@ - If `config.stripe` is missing: Logs a warning and returns without registering anything. - If `config.stripe.enablePaymentWebhook` is truthy: Registers the webhook controller route and raw body parser. - If `config.stripe.webhookPath` is defined: Uses it instead of the default `ROUTE_STRIPE_WEBHOOK`. -- If `config.stripe.handlers?.webhook` is defined: Delegates the Stripe event to this custom handler. Otherwise, falls through to a default handler that throws `"Webhook handler not implemented"`. +- If `config.stripe.handlers?.webhook` is defined: Delegates the Stripe event to this custom handler. Otherwise, falls through to a default handler that logs an error containing the event id and type and resolves (responds 200) so Stripe stops retrying. The plugin also warns at registration time when a webhook handler is not configured. - In `verifyStripeSignature`: Early returns HTTP 400 if `webhookSecret` is missing, `stripe-signature` header is missing, `request.rawBody` is missing, or `Stripe.webhooks.constructEvent` throws an error. ### Default Values diff --git a/packages/stripe/FEATURES.md b/packages/stripe/FEATURES.md index 9db36b4b6..4a737029c 100644 --- a/packages/stripe/FEATURES.md +++ b/packages/stripe/FEATURES.md @@ -11,8 +11,8 @@ ## Configuration & Type Exports -5. Module augmentation of `@prefabs.tech/fastify-config` adds `stripe: StripeConfig` to `ApiConfig`. -6. Module augmentation of `fastify` adds `rawBody?: Buffer | string` and `stripeEvent?: Stripe.Event` to `FastifyRequest`. +5. Module augmentation of `@prefabs.tech/fastify-config` adds `stripe?: StripeConfig` to `ApiConfig` (optional, matching the plugin's runtime tolerance for missing config). +6. Module augmentation of `fastify` adds `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` to `FastifyRequest`. 7. `StripeConfig` type is exported (curated subset; not a direct passthrough of any Stripe SDK type). 8. `StripeEvent` type is exported as an alias for `Stripe.Event`. 9. `CreateSessionInput` type describes the checkout helper input shape. @@ -22,41 +22,44 @@ 11. A `POST` route is registered at `config.stripe.webhookPath`, falling back to `ROUTE_STRIPE_WEBHOOK` (`/payment/webhook`) when unset. 12. The webhook controller logs `"Registering Stripe webhook route"` at `info` level on registration. -13. The verified Stripe event is dispatched to `config.stripe.handlers.webhook` when defined. -14. When `config.stripe.handlers.webhook` is not defined, the request falls through to the default handler, which throws `"Webhook handler not implemented"`. -15. The route handler throws `"Stripe event not found on request"` when the preHandler did not attach `request.stripeEvent` (defensive guard). +13. When the controller is registered with `enablePaymentWebhook: true` but `config.stripe.handlers.webhook` is unset, the controller logs a warning at registration time (`"config.stripe.handlers.webhook is not set; received webhooks will be acknowledged but not processed. Provide a handler to fulfill events."`). +14. The verified Stripe event is dispatched to `config.stripe.handlers.webhook` when defined. +15. When `config.stripe.handlers.webhook` is not defined, the request falls through to the default handler, which logs an error with `{ eventId, eventType }` and resolves so the route responds `200` (instead of throwing 500 and triggering Stripe's retry backoff). +16. The route responds with `500 { error: "Stripe event not found on request" }` and logs an error when the preHandler did not attach `request.stripeEvent` (defensive guard — should be unreachable). +17. If the webhook controller is registered directly (not via the parent plugin) on a Fastify instance whose `config.stripe` is unset, it logs an error (`"Stripe webhook controller registered without config.stripe; skipping route registration."`) and registers no route. ## Webhook Signature Verification The `verifyStripeSignature` preHandler runs on the webhook route and performs the following checks in order: -16. Responds with 400 `{ error: "Webhook secret not configured" }` and logs an error when `config.stripe.webhookSecret` is unset. -17. Responds with 400 `{ error: "Missing stripe-signature header" }` and logs an error when the `stripe-signature` request header is absent. -18. Responds with 400 `{ error: "Raw body is not available for signature verification" }` and logs an error when `request.rawBody` is unset. -19. Responds with 400 `{ error: "Webhook signature verification failed" }` and logs the underlying error when `stripe.webhooks.constructEvent` throws. -20. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (available via module augmentation). +18. Responds with 400 `{ error: "Webhook secret not configured" }` and logs an error (`"Stripe webhook secret is not configured; rejecting webhook request."`) when `config.stripe.webhookSecret` is unset. +19. Responds with 400 `{ error: "Missing stripe-signature header" }` and logs an error when the `stripe-signature` request header is absent. +20. Responds with 400 `{ error: "Raw body is not available for signature verification" }` and logs an error when `request.rawBody` is unset. +21. Responds with 400 `{ error: "Webhook signature verification failed" }` and logs the underlying error when `stripe.webhooks.constructEvent` throws. +22. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (available via module augmentation). ## Raw Body Parser -21. `registerRawBodyParser(fastify)` registers a Fastify content-type parser for `application/json` that captures the request buffer to `request.rawBody` and parses JSON for downstream handlers. -22. JSON parse errors are tagged with `statusCode: 400` and forwarded through `done(error)`, so Fastify's default error handler produces a 400 response. -23. When the webhook controller installs the raw body parser, the parser is scoped to the webhook controller's plugin encapsulation. It applies to the webhook route but does **not** bleed into other `application/json` routes registered on the parent Fastify instance. Calling `registerRawBodyParser(fastify)` directly on the parent installs it on that instance instead. +23. `registerRawBodyParser(fastify)` registers a Fastify content-type parser for `application/json` that captures the request buffer to `request.rawBody` and parses JSON for downstream handlers. +24. JSON parse errors are tagged with `statusCode: 400` and forwarded through `done(error)`, so Fastify's default error handler produces a 400 response. +25. When the webhook controller installs the raw body parser, the parser is scoped to the webhook controller's plugin encapsulation. It applies to the webhook route but does **not** bleed into other `application/json` routes registered on the parent Fastify instance. Calling `registerRawBodyParser(fastify)` directly on the parent installs it on that instance instead. ## `StripeClient` Helper -24. `new StripeClient(config)` constructs a `Stripe` SDK client using `config.stripe.apiKey` and forwards `config.stripe.clientConfig` unmodified. -25. The raw `Stripe` SDK instance is exposed as `client.stripe` for direct SDK calls. -26. `createCheckoutSession(input, metadata?)` synthesizes a Checkout session containing exactly one `line_items` entry built from `productName`, `unitAmount`, `quantity`, and `currency`. -27. `createCheckoutSession` defaults `quantity` to `1` when `input.quantity` is unset. -28. `createCheckoutSession` defaults `mode` to `"payment"` when `input.mode` is unset. -29. `createCheckoutSession` defaults `currency` to `config.stripe.defaultCurrency` when `input.currency` is unset. -30. `createCheckoutSession` defaults `success_url` to `config.stripe.urls.success` when `input.successUrl` is unset. -31. `createCheckoutSession` defaults `cancel_url` to `config.stripe.urls.cancel` when `input.cancelUrl` is unset. -32. `createCheckoutSession` forwards `config.stripe.allowPromotionCodes` as the `allow_promotion_codes` parameter (passed as-is — `undefined` is allowed). -33. `createCheckoutSession` always writes the `metadata` argument onto `session.metadata`. It additionally writes it onto the mode-specific data block: +26. `new StripeClient(config)` throws `"StripeClient requires config.stripe to be set on the provided ApiConfig."` when `config.stripe` is unset. +27. `new StripeClient(config)` constructs a `Stripe` SDK client using `config.stripe.apiKey` and forwards `config.stripe.clientConfig` unmodified. +28. The raw `Stripe` SDK instance is exposed as `client.stripe` for direct SDK calls. +29. `createCheckoutSession(input, metadata?)` synthesizes a Checkout session containing exactly one `line_items` entry built from `productName`, `unitAmount`, `quantity`, and `currency`. +30. `createCheckoutSession` defaults `quantity` to `1` when `input.quantity` is unset. +31. `createCheckoutSession` defaults `mode` to `"payment"` when `input.mode` is unset. +32. `createCheckoutSession` defaults `currency` to `config.stripe.defaultCurrency` when `input.currency` is unset. +33. `createCheckoutSession` defaults `success_url` to `config.stripe.urls.success` when `input.successUrl` is unset. +34. `createCheckoutSession` defaults `cancel_url` to `config.stripe.urls.cancel` when `input.cancelUrl` is unset. +35. `createCheckoutSession` forwards `config.stripe.allowPromotionCodes` as the `allow_promotion_codes` parameter (passed as-is — `undefined` is allowed). +36. `createCheckoutSession` writes the `metadata` argument onto `session.metadata` only when `metadata` is provided. When provided, it additionally writes it onto the mode-specific data block: - `mode: "payment"` → `payment_intent_data.metadata` - `mode: "subscription"` → `subscription_data.metadata` - `mode: "setup"` → `setup_intent_data.metadata` - Only the block matching the selected mode is set; the others are left unset so Stripe does not reject the call. -34. `getActivePromotionCode(code)` calls `promotionCodes.list({ active: true, code })` and returns only the first matching `Stripe.PromotionCode` (or `undefined` when there is no match). + Only the block matching the selected mode is set; the others are left unset so Stripe does not reject the call. When `metadata` is not provided, no mode-specific `*_data` block is set. +37. `getActivePromotionCode(code)` calls `promotionCodes.list({ active: true, code })` and returns only the first matching `Stripe.PromotionCode` (or `undefined` when there is no match). diff --git a/packages/stripe/GUIDE.md b/packages/stripe/GUIDE.md index 40e459be4..4b38265d7 100644 --- a/packages/stripe/GUIDE.md +++ b/packages/stripe/GUIDE.md @@ -152,7 +152,9 @@ console.log(ROUTE_STRIPE_WEBHOOK); // "/payment/webhook" ### 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, calling the webhook will return a 500 because the default handler throws `"Webhook handler not implemented"`. +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"; @@ -255,6 +257,13 @@ function logEvent(event: StripeEvent) { - `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"; @@ -285,7 +294,7 @@ console.log(session.url); `config.stripe.allowPromotionCodes` is forwarded as `allow_promotion_codes` (passing `undefined` is fine — Stripe ignores it). -The optional `metadata` argument is always written to `session.metadata`, and additionally to the mode-specific data block so it surfaces on the downstream object too: +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 | | ---------------- | ------------------------------------ | @@ -293,7 +302,7 @@ The optional `metadata` argument is always written to `session.metadata`, and ad | `"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: +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( @@ -340,8 +349,8 @@ if (promo) { Importing this package's default export brings two ambient TypeScript augmentations into scope: -- `ApiConfig` (from `@prefabs.tech/fastify-config`) gains a `stripe: StripeConfig` field. -- `FastifyRequest` gains an optional `rawBody?: Buffer | string`. +- `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 either — just import the plugin once in your entry file. diff --git a/packages/stripe/README.md b/packages/stripe/README.md index d969d0d77..3e4bdd131 100644 --- a/packages/stripe/README.md +++ b/packages/stripe/README.md @@ -115,7 +115,7 @@ const session = await stripeClient.createCheckoutSession({ ### Custom Webhook Handler -You can provide a custom webhook handler to process Stripe events: +When `enablePaymentWebhook` is `true` you should provide a `handlers.webhook` function to process Stripe events. If you don't, 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 — so Stripe does not retry the delivery indefinitely, but the misconfiguration is loud in the logs. ```typescript import type { FastifyRequest } from "fastify"; diff --git a/packages/stripe/src/__test__/stripeClient.test.ts b/packages/stripe/src/__test__/stripeClient.test.ts index 6b4144094..7a6f12494 100644 --- a/packages/stripe/src/__test__/stripeClient.test.ts +++ b/packages/stripe/src/__test__/stripeClient.test.ts @@ -57,6 +57,15 @@ describe("StripeClient — constructor", async () => { 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 () => { @@ -233,7 +242,7 @@ describe("StripeClient — createCheckoutSession synthesis", async () => { expect(arguments_.setup_intent_data).toBeUndefined(); }); - it("metadata is undefined on both session and payment_intent_data placements when not provided", async () => { + 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", @@ -242,7 +251,9 @@ describe("StripeClient — createCheckoutSession synthesis", async () => { const arguments_ = sessionsCreateMock.mock.calls[0][0]; expect(arguments_.metadata).toBeUndefined(); - expect(arguments_.payment_intent_data.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 () => { diff --git a/packages/stripe/src/__test__/verifyStripeSignature.test.ts b/packages/stripe/src/__test__/verifyStripeSignature.test.ts index b85e55126..656ae9f3b 100644 --- a/packages/stripe/src/__test__/verifyStripeSignature.test.ts +++ b/packages/stripe/src/__test__/verifyStripeSignature.test.ts @@ -86,7 +86,7 @@ describe("verifyStripeSignature — webhookSecret missing", async () => { }); expect(errorSpy).toHaveBeenCalledWith( - "Stripe webhook secret is not configured. Skipping signature verification.", + "Stripe webhook secret is not configured; rejecting webhook request.", ); }); }); diff --git a/packages/stripe/src/__test__/webhookController.test.ts b/packages/stripe/src/__test__/webhookController.test.ts index fc029008d..c528f9f31 100644 --- a/packages/stripe/src/__test__/webhookController.test.ts +++ b/packages/stripe/src/__test__/webhookController.test.ts @@ -129,7 +129,7 @@ describe("webhookController — dispatch", async () => { expect(webhookHandlerMock.mock.calls[0][1]).toEqual(SAMPLE_EVENT); }); - it("returns 500 with 'Webhook handler not implemented' when no custom handler is configured", async () => { + it("responds 200 with the default fallback handler when no custom handler is configured (to suppress Stripe retries)", async () => { fastify = Fastify({ logger: false }); fastify.decorate("config", { stripe: createStripeConfig({ enablePaymentWebhook: true }), @@ -140,8 +140,41 @@ describe("webhookController — dispatch", async () => { const res = await injectWebhook(fastify, "/payment/webhook"); - expect(res.statusCode).toBe(500); - expect(res.json().message).toBe("Webhook handler not implemented"); + 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" } }); + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + }); + const warnSpy = vi.spyOn(fastify.log, "warn"); + + await fastify.register(plugin); + 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" } }); + fastify.decorate("config", { + stripe: createStripeConfig({ + enablePaymentWebhook: true, + handlers: { webhook: webhookHandlerMock }, + }), + }); + const warnSpy = vi.spyOn(fastify.log, "warn"); + + await fastify.register(plugin); + 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 () => { @@ -164,8 +197,9 @@ describe("webhookController — dispatch", async () => { }); }); -describe("webhookController — defensive guard", async () => { +describe("webhookController — defensive guards", async () => { const { default: plugin } = await import("../plugin"); + const { default: webhookController } = await import("../webhook/controller"); let fastify: FastifyInstance; @@ -177,7 +211,25 @@ describe("webhookController — defensive guard", async () => { await fastify.close(); }); - it("returns 500 with 'Stripe event not found on request' when preHandler did not attach the event", async () => { + it("logs an error and does NOT register the route when registered directly without config.stripe", async () => { + fastify = Fastify({ logger: { level: "silent" } }); + fastify.decorate("config", {} as unknown as FastifyInstance["config"]); + const errorSpy = vi.spyOn(fastify.log, "error"); + + await fastify.register(webhookController); + await fastify.ready(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Stripe webhook controller registered without config.stripe", + ), + ); + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + false, + ); + }); + + 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. @@ -196,6 +248,8 @@ describe("webhookController — defensive guard", async () => { const res = await injectWebhook(fastify, "/payment/webhook"); expect(res.statusCode).toBe(500); - expect(res.json().message).toBe("Stripe event not found on request"); + 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 index 0bbd045ba..cc3348fdf 100644 --- a/packages/stripe/src/__test__/webhookHandler.test.ts +++ b/packages/stripe/src/__test__/webhookHandler.test.ts @@ -1,12 +1,45 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import handleWebhook from "../webhook/handler"; -describe("default webhookHandler (sentinel)", () => { - it("throws 'Webhook handler not implemented' so consumers who forget to wire handlers.webhook get a hard failure", async () => { +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 - handleWebhook({} as any, {} as any), - ).rejects.toThrow("Webhook handler not implemented"); + 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/index.ts b/packages/stripe/src/index.ts index 2b90ed30b..1df23cc0e 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -4,7 +4,7 @@ import { StripeConfig } from "./types"; declare module "@prefabs.tech/fastify-config" { interface ApiConfig { - stripe: StripeConfig; + stripe?: StripeConfig; } } diff --git a/packages/stripe/src/middlewares/verifyStripeSignature.ts b/packages/stripe/src/middlewares/verifyStripeSignature.ts index 8f4ecd2b0..101b661c7 100644 --- a/packages/stripe/src/middlewares/verifyStripeSignature.ts +++ b/packages/stripe/src/middlewares/verifyStripeSignature.ts @@ -6,11 +6,11 @@ const verifyStripeSignature = async ( reply: FastifyReply, ): Promise => { const { config, log } = request.server; - const webhookSecret = config.stripe.webhookSecret; + const webhookSecret = config.stripe?.webhookSecret; if (!webhookSecret) { log.error( - "Stripe webhook secret is not configured. Skipping signature verification.", + "Stripe webhook secret is not configured; rejecting webhook request.", ); return reply.status(400).send({ error: "Webhook secret not configured" }); diff --git a/packages/stripe/src/utils/stripeClient.ts b/packages/stripe/src/utils/stripeClient.ts index 06c19b227..1f3a6fff0 100644 --- a/packages/stripe/src/utils/stripeClient.ts +++ b/packages/stripe/src/utils/stripeClient.ts @@ -1,14 +1,22 @@ import { ApiConfig } from "@prefabs.tech/fastify-config"; import Stripe from "stripe"; -import { CreateSessionInput } from "../types"; +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); } @@ -19,12 +27,12 @@ class StripeClient { const mode = input.mode ?? "payment"; const parameters: Stripe.Checkout.SessionCreateParams = { - allow_promotion_codes: this._config.stripe.allowPromotionCodes, - cancel_url: input.cancelUrl ?? this._config.stripe.urls.cancel, + allow_promotion_codes: this._stripeConfig.allowPromotionCodes, + cancel_url: input.cancelUrl ?? this._stripeConfig.urls.cancel, line_items: [ { price_data: { - currency: input.currency ?? this._config.stripe.defaultCurrency, + currency: input.currency ?? this._stripeConfig.defaultCurrency, product_data: { name: input.productName, }, @@ -33,25 +41,29 @@ class StripeClient { quantity: input.quantity ?? 1, }, ], - metadata: metadata, mode, - success_url: input.successUrl ?? this._config.stripe.urls.success, + 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. - switch (mode) { - case "payment": { - parameters.payment_intent_data = { metadata: metadata }; - break; - } - case "setup": { - parameters.setup_intent_data = { metadata: metadata }; - break; - } - case "subscription": { - parameters.subscription_data = { metadata: metadata }; - break; + 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; + } } } diff --git a/packages/stripe/src/utils/stripeRawBodyParser.ts b/packages/stripe/src/utils/stripeRawBodyParser.ts index 3e1c16afd..40217436a 100644 --- a/packages/stripe/src/utils/stripeRawBodyParser.ts +++ b/packages/stripe/src/utils/stripeRawBodyParser.ts @@ -2,10 +2,26 @@ import { FastifyInstance, FastifyRequest } from "fastify"; declare module "fastify" { interface FastifyRequest { - rawBody?: Buffer | string; + 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", diff --git a/packages/stripe/src/webhook/controller.ts b/packages/stripe/src/webhook/controller.ts index 3289822da..089d9cc31 100644 --- a/packages/stripe/src/webhook/controller.ts +++ b/packages/stripe/src/webhook/controller.ts @@ -8,20 +8,47 @@ import webhookHandler from "./handler"; const plugin = async (fastify: FastifyInstance) => { fastify.log.info("Registering Stripe webhook route"); + // `stripe` is guaranteed by the parent plugin (which only registers this + // controller when `config.stripe` is set), but we narrow it once locally + // so the rest of the function stays free of `!` non-null assertions. + const stripeConfig = fastify.config.stripe; + + if (!stripeConfig) { + fastify.log.error( + "Stripe webhook controller registered without config.stripe; skipping route registration.", + ); + + return; + } + + 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( - fastify.config.stripe.webhookPath || ROUTE_STRIPE_WEBHOOK, + stripeConfig.webhookPath || ROUTE_STRIPE_WEBHOOK, { preHandler: [verifyStripeSignature] }, - async (request: FastifyRequest) => { + async (request: FastifyRequest, reply) => { const event = request.stripeEvent; if (!event) { - throw new Error("Stripe event not found on request"); + // Should be unreachable: verifyStripeSignature 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 (fastify.config.stripe.handlers?.webhook) { - await fastify.config.stripe.handlers.webhook(request, event); + if (stripeConfig.handlers?.webhook) { + await stripeConfig.handlers.webhook(request, event); return; } diff --git a/packages/stripe/src/webhook/handler.ts b/packages/stripe/src/webhook/handler.ts index 8a203e731..419ca62a8 100644 --- a/packages/stripe/src/webhook/handler.ts +++ b/packages/stripe/src/webhook/handler.ts @@ -1,13 +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 ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars request: FastifyRequest, - // eslint-disable-next-line @typescript-eslint/no-unused-vars event: Stripe.Event, ): Promise => { - throw new Error("Webhook handler not implemented"); + 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 index 380daccb6..09287764b 100644 --- a/packages/stripe/tsconfig.json +++ b/packages/stripe/tsconfig.json @@ -1,11 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", - "exclude": [ - "src/**/__test__/**/*", - ], + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { "baseUrl": "./", - "outDir": "dist", + "outDir": "dist" }, "include": ["src/**/*.ts"] } diff --git a/packages/stripe/vite.config.ts b/packages/stripe/vite.config.ts index 6d2302a9d..750cf9dae 100644 --- a/packages/stripe/vite.config.ts +++ b/packages/stripe/vite.config.ts @@ -24,16 +24,10 @@ export default defineConfig(({ mode }) => { output: { exports: "named", globals: { - "@prefabs.tech/fastify-error-handler": - "PrefabsTechFastifyErrorHandler", - "@prefabs.tech/fastify-graphql": "PrefabsTechFastifyGraphql", - "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", + "@prefabs.tech/fastify-config": "PrefabsTechFastifyConfig", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", - mercurius: "mercurius", - slonik: "Slonik", stripe: "Stripe", - zod: "zod", }, }, }, From 584ff70c413b45428c3ae4f4e3e91646d254a0fd Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Tue, 12 May 2026 20:27:25 +0545 Subject: [PATCH 09/16] docs(stripe): expand ANALYSIS.md with comprehensive code classification - Add structured "ours vs theirs" classification with 36 custom logic items - Document all framework constructs (module augmentations, plugin, routes) - Detail conditional branches and default values - Include completeness checklist --- packages/stripe/ANALYSIS.md | 186 +++++++++++++++++++++++++++++------- 1 file changed, 152 insertions(+), 34 deletions(-) diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md index 16b9feb83..0a0dc8502 100644 --- a/packages/stripe/ANALYSIS.md +++ b/packages/stripe/ANALYSIS.md @@ -1,54 +1,172 @@ +# `@prefabs.tech/fastify-stripe` — Package Analysis + ## Base Library Passthrough Analysis -### stripe — [PARTIAL PASSTHROUGH / MODIFIED] +### `stripe` (Stripe Node SDK) — PARTIAL PASSTHROUGH / MODIFIED + +- **Options type:** Partial import — `clientConfig` is imported from `Stripe.StripeConfig` and passed through; other SDK features are accessed via the exposed `client.stripe` instance. +- **Options passed:** `clientConfig` is forwarded unmodified to `new Stripe(apiKey, clientConfig)`. All other SDK configuration happens through the curated `StripeConfig` type defined by this package. +- **Features restricted:** + - `checkout.sessions.create` is wrapped with a simplified surface (`CreateSessionInput`) that supports only single-product sessions. Multi-product sessions, tax rates, shipping options, and other advanced features require direct SDK access via `client.stripe`. + - `promotionCodes.list` is wrapped to hardcode `active: true` and return only the first result. +- **Features added:** + - Fastify plugin with automatic webhook endpoint registration + - Signature verification preHandler with structured 400 responses + - Raw-body content-type parser scoped to webhook routes + - Config-aware `StripeClient` helper with defaults for currency, URLs, and promotion codes + - Module augmentations for `fastify.config.stripe`, `request.rawBody`, and `request.stripeEvent` + - Missing-config guard (plugin warns and skips registration when `config.stripe` is absent) + - Webhook handler warning system (logs at registration when no custom handler is provided) + - Default fallback webhook handler (returns 200 to prevent Stripe retries) + - Mode-specific metadata routing for checkout sessions + +--- + +## Code Classification: "Ours" vs "Theirs" + +### OURS — Custom Logic + +**Plugin Registration (`src/plugin.ts`)** +1. Missing-config guard: checks `config.stripe` existence, logs warning and returns early if missing +2. Conditional webhook controller registration based on `config.stripe.enablePaymentWebhook` +3. `fastify-plugin` wrapping for non-encapsulated registration + +**Type System (`src/types/index.ts`, `src/index.ts`)** +4. Module augmentation of `@prefabs.tech/fastify-config` to add `stripe?: StripeConfig` +5. Module augmentation of `fastify` to add `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` +6. Curated `StripeConfig` type (not a direct passthrough of Stripe SDK types) +7. `CreateSessionInput` type for simplified checkout session creation + +**Webhook Controller (`src/webhook/controller.ts`)** +8. Registration-time warning when `config.stripe.handlers.webhook` is unset but `enablePaymentWebhook` is true +9. Defensive guard logging error when controller registers without `config.stripe` +10. Route path defaults to `ROUTE_STRIPE_WEBHOOK` when `config.stripe.webhookPath` is unset +11. Conditional dispatch: calls custom handler if provided, otherwise falls back to default handler +12. Error response when `request.stripeEvent` is missing after verification (defensive, should be unreachable) + +**Signature Verification (`src/middlewares/verifyStripeSignature.ts`)** +13. Structured 400 responses with specific error messages for each failure mode +14. Error logging for all verification failures +15. Guards: missing secret, missing signature header, missing raw body +16. Attaches verified event to `request.stripeEvent` on success + +**Raw Body Parser (`src/utils/stripeRawBodyParser.ts`)** +17. Custom `application/json` parser that captures `request.rawBody` while still parsing JSON +18. JSON parse errors tagged with `statusCode: 400` for proper HTTP response +19. Scoped to plugin encapsulation (doesn't affect parent instance routes) + +**Default Webhook Handler (`src/webhook/handler.ts`)** +20. Intentionally returns without throwing to avoid 500 response and Stripe retry loop +21. Logs error with `eventId` and `eventType` for visibility +22. Acknowledges event with 200 to suppress Stripe retries + +**StripeClient Wrapper (`src/utils/stripeClient.ts`)** +23. Constructor throws when `config.stripe` is unset +24. `createCheckoutSession`: defaults `quantity` to `1` +25. `createCheckoutSession`: defaults `mode` to `"payment"` +26. `createCheckoutSession`: defaults `currency` to `config.stripe.defaultCurrency` +27. `createCheckoutSession`: defaults `successUrl` to `config.stripe.urls.success` +28. `createCheckoutSession`: defaults `cancelUrl` to `config.stripe.urls.cancel` +29. `createCheckoutSession`: forwards `config.stripe.allowPromotionCodes` as `allow_promotion_codes` +30. `createCheckoutSession`: synthesizes single `line_items` entry from flat input +31. `createCheckoutSession`: mode-specific metadata routing (payment → `payment_intent_data.metadata`, subscription → `subscription_data.metadata`, setup → `setup_intent_data.metadata`) +32. `createCheckoutSession`: omits all `*_data` blocks when metadata is not provided +33. `getActivePromotionCode`: hardcodes `active: true` filter +34. `getActivePromotionCode`: returns only first match (no pagination) + +**Constants & Exports (`src/constants.ts`, `src/index.ts`)** +35. `ROUTE_STRIPE_WEBHOOK` constant exported for consumer reference +36. Re-exports: `StripeConfig`, `StripeEvent` (aliased from `Stripe.Event`), `CreateSessionInput` -- Options type: [imported from base library for `clientConfig` (`Stripe.StripeConfig`) | custom subset for checkout options (`CreateSessionInput`)] -- Options passed: [`clientConfig` is passed unmodified to the `Stripe` constructor. For `createCheckoutSession`, input options are transformed into a specific `line_items` structure and defaults are applied. `allowPromotionCodes` is passed through as `allow_promotion_codes`.] -- Features restricted: [`StripeClient.createCheckoutSession` restricts creating sessions to a single product with flat parameters. `StripeClient.getActivePromotionCode` restricts lookup to `active: true` and returns only the first match. Full features are still available via the exposed `client.stripe` property.] -- Features added: [Configurable webhook route registration, signature verification middleware with formatted errors, raw body parser for `application/json` (scoped to the webhook controller's plugin encapsulation), default parameter injection for checkout sessions, mode-aware metadata routing (`payment_intent_data` / `subscription_data` / `setup_intent_data`), typed Fastify request augmentations.] +### THEIRS — Direct Passthrough + +**Stripe SDK Usage** +1. `new Stripe(apiKey, clientConfig)` — `clientConfig` passed unmodified +2. `stripe.webhooks.constructEvent(rawBody, signature, secret)` — called directly with no transformation +3. `stripe.checkout.sessions.create(params)` — called directly after parameter synthesis +4. `stripe.promotionCodes.list({ active, code })` — called directly with hardcoded filter +5. Raw `Stripe` SDK instance exposed as `client.stripe` for direct access to all SDK features + +--- ## Summary -### Exports & Functions +### Public Exports -- `default` (stripePlugin): Registers the plugin and optionally the webhook controller if enabled in config. -- `ROUTE_STRIPE_WEBHOOK`: Constant for the default webhook path (`"/payment/webhook"`). -- `StripeClient`: Helper class holding the `Stripe` SDK client and exposing simplified checkout session creation and promotion code lookup. - - `createCheckoutSession`: Synthesizes a one-product Checkout session and applies config defaults. - - `getActivePromotionCode`: Looks up an active promotion code by its string. -- `registerRawBodyParser`: Helper function to add an `application/json` parser that retains `request.rawBody`. -- Type exports: `StripeConfig`, `StripeEvent`, `CreateSessionInput`. +**Default Export:** `stripePlugin` — Fastify plugin wrapped with `fastify-plugin` for non-encapsulated registration + +**Named Exports:** +- `ROUTE_STRIPE_WEBHOOK` — constant string `"/payment/webhook"` +- `StripeClient` — class for config-aware Stripe operations +- `registerRawBodyParser` — function to install raw-body parser on any Fastify instance +- `StripeConfig` — type for `config.stripe` shape +- `StripeEvent` — re-exported `Stripe.Event` type +- `CreateSessionInput` — type for `createCheckoutSession` input ### Framework Constructs Added -- Fastify plugin wrapping (`fastify-plugin`) so registrations attach to the top-level instance. -- Fastify module augmentations for `FastifyRequest` (adds `rawBody` and `stripeEvent`). -- Fastify route (`fastify.post`) for the webhook endpoint. -- Fastify `preHandler` (`verifyStripeSignature`) for webhook route middleware. -- Fastify content-type parser (`fastify.addContentTypeParser`) for raw body retention. -- Module augmentation for `@prefabs.tech/fastify-config` `ApiConfig` (adds `stripe`). +1. **Module Augmentations:** + - `@prefabs.tech/fastify-config` gains `ApiConfig.stripe?: StripeConfig` + - `fastify` gains `FastifyRequest.rawBody?: Buffer` and `FastifyRequest.stripeEvent?: Stripe.Event` + +2. **Fastify Plugin:** Non-encapsulated plugin (via `fastify-plugin`) that registers webhook routes on the parent instance -### Hooks or Lifecycle Registrations +3. **Content-Type Parser:** `application/json` parser registered inside webhook controller scope (does not affect parent routes) -- Fastify `preHandler` hook on the webhook route (`verifyStripeSignature`). -- Fastify `addContentTypeParser` for `application/json` registered inside the webhook controller's plugin scope (scoped — does not leak to parent). +4. **PreHandler:** `verifyStripeSignature` runs before webhook route handler + +5. **Route:** `POST` route at `config.stripe.webhookPath` (defaults to `/payment/webhook`) when `enablePaymentWebhook` is true ### Conditional Branches -- If `config.stripe` is missing: Logs a warning and returns without registering anything. -- If `config.stripe.enablePaymentWebhook` is truthy: Registers the webhook controller route and raw body parser. -- If `config.stripe.webhookPath` is defined: Uses it instead of the default `ROUTE_STRIPE_WEBHOOK`. -- If `config.stripe.handlers?.webhook` is defined: Delegates the Stripe event to this custom handler. Otherwise, falls through to a default handler that logs an error containing the event id and type and resolves (responds 200) so Stripe stops retrying. The plugin also warns at registration time when a webhook handler is not configured. -- In `verifyStripeSignature`: Early returns HTTP 400 if `webhookSecret` is missing, `stripe-signature` header is missing, `request.rawBody` is missing, or `Stripe.webhooks.constructEvent` throws an error. +1. **Plugin Registration:** + - If `config.stripe` is missing → log warning, skip registration + - If `config.stripe.enablePaymentWebhook` is truthy → register webhook controller + - If `config.stripe.enablePaymentWebhook` is falsy → skip webhook controller + +2. **Webhook Controller:** + - If `config.stripe` is missing → log error, skip route registration + - If `config.stripe.handlers.webhook` is missing → log warning at registration time + - If `config.stripe.handlers.webhook` is set → dispatch to custom handler + - If `config.stripe.handlers.webhook` is unset → dispatch to default handler (logs error, returns 200) + +3. **Signature Verification:** + - If `config.stripe.webhookSecret` is missing → return 400 + - If `stripe-signature` header is missing → return 400 + - If `request.rawBody` is missing → return 400 + - If `Stripe.webhooks.constructEvent` throws → return 400 + +4. **StripeClient Constructor:** + - If `config.stripe` is missing → throw error + +5. **createCheckoutSession Metadata:** + - If `metadata` is provided → write to `session.metadata` and mode-specific `*_data` block + - If `metadata` is not provided → omit all metadata fields + +6. **createCheckoutSession Mode Routing:** + - `mode: "payment"` → metadata to `payment_intent_data.metadata` + - `mode: "subscription"` → metadata to `subscription_data.metadata` + - `mode: "setup"` → metadata to `setup_intent_data.metadata` ### Default Values -- Webhook Path: `"/payment/webhook"` -- `createCheckoutSession` defaults: - - `quantity`: `1` - - `mode`: `"payment"` - - `currency`: Falls back to `config.stripe.defaultCurrency` - - `successUrl`: Falls back to `config.stripe.urls.success` - - `cancelUrl`: Falls back to `config.stripe.urls.cancel` +| Field | Default Value | Applied When | +| --------------------------------- | --------------------------------------- | ------------------------------- | +| `webhookPath` | `/payment/webhook` | `config.stripe.webhookPath` unset | +| `CreateSessionInput.quantity` | `1` | `input.quantity` unset | +| `CreateSessionInput.mode` | `"payment"` | `input.mode` unset | +| `CreateSessionInput.currency` | `config.stripe.defaultCurrency` | `input.currency` unset | +| `CreateSessionInput.successUrl` | `config.stripe.urls.success` | `input.successUrl` unset | +| `CreateSessionInput.cancelUrl` | `config.stripe.urls.cancel` | `input.cancelUrl` unset | + +--- + +## Completeness Checklist + +- ✅ Classified every public export as "ours" or "theirs" +- ✅ Listed every framework construct added (module augmentations, plugin, parser, preHandler, route) +- ✅ Identified every conditional branch (plugin registration, webhook dispatch, signature verification, metadata handling, mode routing) +- ✅ Documented default values for all options we define +- ✅ Produced passthrough classification for wrapped dependency (`stripe`) From 47a629aa16e900c3ce1ff17ab861f7f8f1ccef8e Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Thu, 14 May 2026 13:24:12 +0545 Subject: [PATCH 10/16] feat(stripe): resolve config at register time with fastify.config fallback Prefer plugin register options; fall back to fastify.config.stripe with a recommendation warning. Pass resolved config into webhook controller and createVerifyStripeSignature. Update package docs and tests. --- packages/stripe/ANALYSIS.md | 99 ++++++++++--------- packages/stripe/FEATURES.md | 75 +++++++------- packages/stripe/GUIDE.md | 21 ++-- packages/stripe/README.md | 19 +++- packages/stripe/src/__test__/plugin.test.ts | 78 ++++++++++++++- .../__test__/verifyStripeSignature.test.ts | 7 +- .../src/__test__/webhookController.test.ts | 2 +- .../src/middlewares/verifyStripeSignature.ts | 85 ++++++++-------- packages/stripe/src/plugin.ts | 26 ++++- packages/stripe/src/webhook/controller.ts | 24 +++-- 10 files changed, 282 insertions(+), 154 deletions(-) diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md index 0a0dc8502..3015480a1 100644 --- a/packages/stripe/ANALYSIS.md +++ b/packages/stripe/ANALYSIS.md @@ -17,7 +17,8 @@ - Raw-body content-type parser scoped to webhook routes - Config-aware `StripeClient` helper with defaults for currency, URLs, and promotion codes - Module augmentations for `fastify.config.stripe`, `request.rawBody`, and `request.stripeEvent` - - Missing-config guard (plugin warns and skips registration when `config.stripe` is absent) + - Missing-config guard (plugin warns and skips registration when Stripe config does not resolve) + - Register-time options with legacy `fastify.config.stripe` fallback (same ergonomics as graphql/slonik/mailer; Stripe does not throw when config is still missing after fallback) - Webhook handler warning system (logs at registration when no custom handler is provided) - Default fallback webhook handler (returns 200 to prevent Stripe retries) - Mode-specific metadata routing for checkout sessions @@ -29,56 +30,59 @@ ### OURS — Custom Logic **Plugin Registration (`src/plugin.ts`)** -1. Missing-config guard: checks `config.stripe` existence, logs warning and returns early if missing -2. Conditional webhook controller registration based on `config.stripe.enablePaymentWebhook` -3. `fastify-plugin` wrapping for non-encapsulated registration +1. Resolves Stripe config: register options when non-empty; otherwise warns and reads `fastify.config?.stripe` (legacy path) +2. Missing-config guard: logs warning and returns early if no config resolves (does not throw) +3. Conditional webhook controller registration when resolved `enablePaymentWebhook` is truthy, passing `{ stripeConfig }` into the controller +4. `fastify-plugin` wrapping for non-encapsulated registration **Type System (`src/types/index.ts`, `src/index.ts`)** -4. Module augmentation of `@prefabs.tech/fastify-config` to add `stripe?: StripeConfig` -5. Module augmentation of `fastify` to add `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` -6. Curated `StripeConfig` type (not a direct passthrough of Stripe SDK types) -7. `CreateSessionInput` type for simplified checkout session creation +5. Module augmentation of `@prefabs.tech/fastify-config` to add `stripe?: StripeConfig` +6. Module augmentation of `fastify` to add `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` +7. Curated `StripeConfig` type (not a direct passthrough of Stripe SDK types) +8. `CreateSessionInput` type for simplified checkout session creation **Webhook Controller (`src/webhook/controller.ts`)** -8. Registration-time warning when `config.stripe.handlers.webhook` is unset but `enablePaymentWebhook` is true -9. Defensive guard logging error when controller registers without `config.stripe` -10. Route path defaults to `ROUTE_STRIPE_WEBHOOK` when `config.stripe.webhookPath` is unset -11. Conditional dispatch: calls custom handler if provided, otherwise falls back to default handler -12. Error response when `request.stripeEvent` is missing after verification (defensive, should be unreachable) +9. Accepts optional `{ stripeConfig }` at register time; falls back to `fastify.config?.stripe` when the controller is registered directly +10. Registration-time warning when `handlers.webhook` is unset but `enablePaymentWebhook` is true +11. Defensive guard logging error when neither register options nor `fastify.config.stripe` provides config +12. Route path defaults to `ROUTE_STRIPE_WEBHOOK` when resolved `webhookPath` is unset +13. Conditional dispatch: calls custom handler if provided, otherwise falls back to default handler +14. Error response when `request.stripeEvent` is missing after verification (defensive, should be unreachable) **Signature Verification (`src/middlewares/verifyStripeSignature.ts`)** -13. Structured 400 responses with specific error messages for each failure mode -14. Error logging for all verification failures -15. Guards: missing secret, missing signature header, missing raw body -16. Attaches verified event to `request.stripeEvent` on success +15. `createVerifyStripeSignature(stripeConfig)` returns a preHandler that closes over `webhookSecret` (does not read `request.server.config.stripe`) +16. Structured 400 responses with specific error messages for each failure mode +17. Error logging for all verification failures +18. Guards: missing secret, missing signature header, missing raw body +19. Attaches verified event to `request.stripeEvent` on success **Raw Body Parser (`src/utils/stripeRawBodyParser.ts`)** -17. Custom `application/json` parser that captures `request.rawBody` while still parsing JSON -18. JSON parse errors tagged with `statusCode: 400` for proper HTTP response -19. Scoped to plugin encapsulation (doesn't affect parent instance routes) +20. Custom `application/json` parser that captures `request.rawBody` while still parsing JSON +21. JSON parse errors tagged with `statusCode: 400` for proper HTTP response +22. Scoped to plugin encapsulation (doesn't affect parent instance routes) **Default Webhook Handler (`src/webhook/handler.ts`)** -20. Intentionally returns without throwing to avoid 500 response and Stripe retry loop -21. Logs error with `eventId` and `eventType` for visibility -22. Acknowledges event with 200 to suppress Stripe retries +23. Intentionally returns without throwing to avoid 500 response and Stripe retry loop +24. Logs error with `eventId` and `eventType` for visibility +25. Acknowledges event with 200 to suppress Stripe retries **StripeClient Wrapper (`src/utils/stripeClient.ts`)** -23. Constructor throws when `config.stripe` is unset -24. `createCheckoutSession`: defaults `quantity` to `1` -25. `createCheckoutSession`: defaults `mode` to `"payment"` -26. `createCheckoutSession`: defaults `currency` to `config.stripe.defaultCurrency` -27. `createCheckoutSession`: defaults `successUrl` to `config.stripe.urls.success` -28. `createCheckoutSession`: defaults `cancelUrl` to `config.stripe.urls.cancel` -29. `createCheckoutSession`: forwards `config.stripe.allowPromotionCodes` as `allow_promotion_codes` -30. `createCheckoutSession`: synthesizes single `line_items` entry from flat input -31. `createCheckoutSession`: mode-specific metadata routing (payment → `payment_intent_data.metadata`, subscription → `subscription_data.metadata`, setup → `setup_intent_data.metadata`) -32. `createCheckoutSession`: omits all `*_data` blocks when metadata is not provided -33. `getActivePromotionCode`: hardcodes `active: true` filter -34. `getActivePromotionCode`: returns only first match (no pagination) +26. Constructor throws when `config.stripe` is unset +27. `createCheckoutSession`: defaults `quantity` to `1` +28. `createCheckoutSession`: defaults `mode` to `"payment"` +29. `createCheckoutSession`: defaults `currency` to `config.stripe.defaultCurrency` +30. `createCheckoutSession`: defaults `successUrl` to `config.stripe.urls.success` +31. `createCheckoutSession`: defaults `cancelUrl` to `config.stripe.urls.cancel` +32. `createCheckoutSession`: forwards `config.stripe.allowPromotionCodes` as `allow_promotion_codes` +33. `createCheckoutSession`: synthesizes single `line_items` entry from flat input +34. `createCheckoutSession`: mode-specific metadata routing (payment → `payment_intent_data.metadata`, subscription → `subscription_data.metadata`, setup → `setup_intent_data.metadata`) +35. `createCheckoutSession`: omits all `*_data` blocks when metadata is not provided +36. `getActivePromotionCode`: hardcodes `active: true` filter +37. `getActivePromotionCode`: returns only first match (no pagination) **Constants & Exports (`src/constants.ts`, `src/index.ts`)** -35. `ROUTE_STRIPE_WEBHOOK` constant exported for consumer reference -36. Re-exports: `StripeConfig`, `StripeEvent` (aliased from `Stripe.Event`), `CreateSessionInput` +38. `ROUTE_STRIPE_WEBHOOK` constant exported for consumer reference +39. Re-exports: `StripeConfig`, `StripeEvent` (aliased from `Stripe.Event`), `CreateSessionInput` ### THEIRS — Direct Passthrough @@ -115,25 +119,26 @@ 3. **Content-Type Parser:** `application/json` parser registered inside webhook controller scope (does not affect parent routes) -4. **PreHandler:** `verifyStripeSignature` runs before webhook route handler +4. **PreHandler:** `createVerifyStripeSignature(resolvedConfig)` runs before webhook route handler -5. **Route:** `POST` route at `config.stripe.webhookPath` (defaults to `/payment/webhook`) when `enablePaymentWebhook` is true +5. **Route:** `POST` route at resolved `webhookPath` (defaults to `/payment/webhook`) when `enablePaymentWebhook` is true ### Conditional Branches 1. **Plugin Registration:** - - If `config.stripe` is missing → log warning, skip registration - - If `config.stripe.enablePaymentWebhook` is truthy → register webhook controller - - If `config.stripe.enablePaymentWebhook` is falsy → skip webhook controller + - If register options are empty → warn, then read `fastify.config?.stripe` + - If Stripe config still missing → log warning, skip registration (no throw) + - If resolved `enablePaymentWebhook` is truthy → register webhook controller with `{ stripeConfig }` + - If resolved `enablePaymentWebhook` is falsy → skip webhook controller 2. **Webhook Controller:** - - If `config.stripe` is missing → log error, skip route registration - - If `config.stripe.handlers.webhook` is missing → log warning at registration time - - If `config.stripe.handlers.webhook` is set → dispatch to custom handler - - If `config.stripe.handlers.webhook` is unset → dispatch to default handler (logs error, returns 200) + - If neither `{ stripeConfig }` nor `fastify.config.stripe` is set → log error, skip route registration + - If `handlers.webhook` is missing → log warning at registration time + - If `handlers.webhook` is set → dispatch to custom handler + - If `handlers.webhook` is unset → dispatch to default handler (logs error, returns 200) 3. **Signature Verification:** - - If `config.stripe.webhookSecret` is missing → return 400 + - If resolved `webhookSecret` is missing → return 400 - If `stripe-signature` header is missing → return 400 - If `request.rawBody` is missing → return 400 - If `Stripe.webhooks.constructEvent` throws → return 400 diff --git a/packages/stripe/FEATURES.md b/packages/stripe/FEATURES.md index 4a737029c..6dad1f7ad 100644 --- a/packages/stripe/FEATURES.md +++ b/packages/stripe/FEATURES.md @@ -5,61 +5,62 @@ ## Plugin Registration 1. The plugin is exported via `fastify-plugin`, so all registrations attach to the top-level (non-encapsulated) Fastify instance. -2. When `config.stripe` is missing, the plugin logs a warning (`"Stripe configuration is missing. Stripe plugin will not be registered."`) and returns without registering anything. -3. When `config.stripe` is present, the plugin logs `"Registering Stripe plugin"` at `info` level. -4. The webhook controller is registered only when `config.stripe.enablePaymentWebhook` is truthy. +2. Register-time options are recommended: `fastify.register(stripePlugin, config.stripe)` (same convention as graphql/slonik/mailer). If register options are empty, the plugin logs `"The stripe plugin now recommends passing stripe options directly to the plugin."` then reads from `fastify.config.stripe`. +3. When no Stripe configuration resolves after that step, the plugin logs (`"Stripe configuration is missing. Stripe plugin will not be registered."`) and returns without registering anything — **it does not throw** (unlike graphql/slonik/mailer on an equivalent missing-config path). +4. When Stripe configuration is present, the plugin logs `"Registering Stripe plugin"` at `info` level. +5. The webhook controller is registered only when the resolved config's `enablePaymentWebhook` is truthy, with `{ stripeConfig }` passed into the controller so signature verification closes over the same object (not only `fastify.config.stripe`). ## Configuration & Type Exports -5. Module augmentation of `@prefabs.tech/fastify-config` adds `stripe?: StripeConfig` to `ApiConfig` (optional, matching the plugin's runtime tolerance for missing config). -6. Module augmentation of `fastify` adds `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` to `FastifyRequest`. -7. `StripeConfig` type is exported (curated subset; not a direct passthrough of any Stripe SDK type). -8. `StripeEvent` type is exported as an alias for `Stripe.Event`. -9. `CreateSessionInput` type describes the checkout helper input shape. -10. `ROUTE_STRIPE_WEBHOOK` constant (`"/payment/webhook"`) is exported as the default webhook route path. +6. Module augmentation of `@prefabs.tech/fastify-config` adds `stripe?: StripeConfig` to `ApiConfig` (optional, matching the plugin's runtime tolerance for missing config). +7. Module augmentation of `fastify` adds `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` to `FastifyRequest`. +8. `StripeConfig` type is exported (curated subset; not a direct passthrough of any Stripe SDK type). +9. `StripeEvent` type is exported as an alias for `Stripe.Event`. +10. `CreateSessionInput` type describes the checkout helper input shape. +11. `ROUTE_STRIPE_WEBHOOK` constant (`"/payment/webhook"`) is exported as the default webhook route path. ## Webhook Endpoint -11. A `POST` route is registered at `config.stripe.webhookPath`, falling back to `ROUTE_STRIPE_WEBHOOK` (`/payment/webhook`) when unset. -12. The webhook controller logs `"Registering Stripe webhook route"` at `info` level on registration. -13. When the controller is registered with `enablePaymentWebhook: true` but `config.stripe.handlers.webhook` is unset, the controller logs a warning at registration time (`"config.stripe.handlers.webhook is not set; received webhooks will be acknowledged but not processed. Provide a handler to fulfill events."`). -14. The verified Stripe event is dispatched to `config.stripe.handlers.webhook` when defined. -15. When `config.stripe.handlers.webhook` is not defined, the request falls through to the default handler, which logs an error with `{ eventId, eventType }` and resolves so the route responds `200` (instead of throwing 500 and triggering Stripe's retry backoff). -16. The route responds with `500 { error: "Stripe event not found on request" }` and logs an error when the preHandler did not attach `request.stripeEvent` (defensive guard — should be unreachable). -17. If the webhook controller is registered directly (not via the parent plugin) on a Fastify instance whose `config.stripe` is unset, it logs an error (`"Stripe webhook controller registered without config.stripe; skipping route registration."`) and registers no route. +12. A `POST` route is registered at the resolved config's `webhookPath`, falling back to `ROUTE_STRIPE_WEBHOOK` (`/payment/webhook`) when unset. +13. The webhook controller logs `"Registering Stripe webhook route"` at `info` level on registration. +14. When the controller is registered with `enablePaymentWebhook: true` but `handlers.webhook` is unset, the controller logs a warning at registration time (`"config.stripe.handlers.webhook is not set; received webhooks will be acknowledged but not processed. Provide a handler to fulfill events."`). +15. The verified Stripe event is dispatched to `handlers.webhook` on the resolved config when defined. +16. When `handlers.webhook` is not defined, the request falls through to the default handler, which logs an error with `{ eventId, eventType }` and resolves so the route responds `200` (instead of throwing 500 and triggering Stripe's retry backoff). +17. The route responds with `500 { error: "Stripe event not found on request" }` and logs an error when the preHandler did not attach `request.stripeEvent` (defensive guard — should be unreachable). +18. If the webhook controller is registered directly (not via the parent plugin) with no `{ stripeConfig }` and `fastify.config.stripe` is unset, it logs an error (`"Stripe webhook controller registered without stripe configuration; skipping route registration."`) and registers no route. ## Webhook Signature Verification -The `verifyStripeSignature` preHandler runs on the webhook route and performs the following checks in order: +The preHandler from `createVerifyStripeSignature(stripeConfig)` runs on the webhook route and performs the following checks in order: -18. Responds with 400 `{ error: "Webhook secret not configured" }` and logs an error (`"Stripe webhook secret is not configured; rejecting webhook request."`) when `config.stripe.webhookSecret` is unset. -19. Responds with 400 `{ error: "Missing stripe-signature header" }` and logs an error when the `stripe-signature` request header is absent. -20. Responds with 400 `{ error: "Raw body is not available for signature verification" }` and logs an error when `request.rawBody` is unset. -21. Responds with 400 `{ error: "Webhook signature verification failed" }` and logs the underlying error when `stripe.webhooks.constructEvent` throws. -22. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (available via module augmentation). +19. Responds with 400 `{ error: "Webhook secret not configured" }` and logs an error (`"Stripe webhook secret is not configured; rejecting webhook request."`) when `stripeConfig.webhookSecret` is unset. +20. Responds with 400 `{ error: "Missing stripe-signature header" }` and logs an error when the `stripe-signature` request header is absent. +21. Responds with 400 `{ error: "Raw body is not available for signature verification" }` and logs an error when `request.rawBody` is unset. +22. Responds with 400 `{ error: "Webhook signature verification failed" }` and logs the underlying error when `stripe.webhooks.constructEvent` throws. +23. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (available via module augmentation). ## Raw Body Parser -23. `registerRawBodyParser(fastify)` registers a Fastify content-type parser for `application/json` that captures the request buffer to `request.rawBody` and parses JSON for downstream handlers. -24. JSON parse errors are tagged with `statusCode: 400` and forwarded through `done(error)`, so Fastify's default error handler produces a 400 response. -25. When the webhook controller installs the raw body parser, the parser is scoped to the webhook controller's plugin encapsulation. It applies to the webhook route but does **not** bleed into other `application/json` routes registered on the parent Fastify instance. Calling `registerRawBodyParser(fastify)` directly on the parent installs it on that instance instead. +24. `registerRawBodyParser(fastify)` registers a Fastify content-type parser for `application/json` that captures the request buffer to `request.rawBody` and parses JSON for downstream handlers. +25. JSON parse errors are tagged with `statusCode: 400` and forwarded through `done(error)`, so Fastify's default error handler produces a 400 response. +26. When the webhook controller installs the raw body parser, the parser is scoped to the webhook controller's plugin encapsulation. It applies to the webhook route but does **not** bleed into other `application/json` routes registered on the parent Fastify instance. Calling `registerRawBodyParser(fastify)` directly on the parent installs it on that instance instead. ## `StripeClient` Helper -26. `new StripeClient(config)` throws `"StripeClient requires config.stripe to be set on the provided ApiConfig."` when `config.stripe` is unset. -27. `new StripeClient(config)` constructs a `Stripe` SDK client using `config.stripe.apiKey` and forwards `config.stripe.clientConfig` unmodified. -28. The raw `Stripe` SDK instance is exposed as `client.stripe` for direct SDK calls. -29. `createCheckoutSession(input, metadata?)` synthesizes a Checkout session containing exactly one `line_items` entry built from `productName`, `unitAmount`, `quantity`, and `currency`. -30. `createCheckoutSession` defaults `quantity` to `1` when `input.quantity` is unset. -31. `createCheckoutSession` defaults `mode` to `"payment"` when `input.mode` is unset. -32. `createCheckoutSession` defaults `currency` to `config.stripe.defaultCurrency` when `input.currency` is unset. -33. `createCheckoutSession` defaults `success_url` to `config.stripe.urls.success` when `input.successUrl` is unset. -34. `createCheckoutSession` defaults `cancel_url` to `config.stripe.urls.cancel` when `input.cancelUrl` is unset. -35. `createCheckoutSession` forwards `config.stripe.allowPromotionCodes` as the `allow_promotion_codes` parameter (passed as-is — `undefined` is allowed). -36. `createCheckoutSession` writes the `metadata` argument onto `session.metadata` only when `metadata` is provided. When provided, it additionally writes it onto the mode-specific data block: +27. `new StripeClient(config)` throws `"StripeClient requires config.stripe to be set on the provided ApiConfig."` when `config.stripe` is unset. +28. `new StripeClient(config)` constructs a `Stripe` SDK client using `config.stripe.apiKey` and forwards `config.stripe.clientConfig` unmodified. +29. The raw `Stripe` SDK instance is exposed as `client.stripe` for direct SDK calls. +30. `createCheckoutSession(input, metadata?)` synthesizes a Checkout session containing exactly one `line_items` entry built from `productName`, `unitAmount`, `quantity`, and `currency`. +31. `createCheckoutSession` defaults `quantity` to `1` when `input.quantity` is unset. +32. `createCheckoutSession` defaults `mode` to `"payment"` when `input.mode` is unset. +33. `createCheckoutSession` defaults `currency` to `config.stripe.defaultCurrency` when `input.currency` is unset. +34. `createCheckoutSession` defaults `success_url` to `config.stripe.urls.success` when `input.successUrl` is unset. +35. `createCheckoutSession` defaults `cancel_url` to `config.stripe.urls.cancel` when `input.cancelUrl` is unset. +36. `createCheckoutSession` forwards `config.stripe.allowPromotionCodes` as the `allow_promotion_codes` parameter (passed as-is — `undefined` is allowed). +37. `createCheckoutSession` writes the `metadata` argument onto `session.metadata` only when `metadata` is provided. When provided, it additionally writes it onto the mode-specific data block: - `mode: "payment"` → `payment_intent_data.metadata` - `mode: "subscription"` → `subscription_data.metadata` - `mode: "setup"` → `setup_intent_data.metadata` Only the block matching the selected mode is set; the others are left unset so Stripe does not reject the call. When `metadata` is not provided, no mode-specific `*_data` block is set. -37. `getActivePromotionCode(code)` calls `promotionCodes.list({ active: true, code })` and returns only the first matching `Stripe.PromotionCode` (or `undefined` when there is no match). +38. `getActivePromotionCode(code)` calls `promotionCodes.list({ active: true, code })` and returns only the first matching `Stripe.PromotionCode` (or `undefined` when there is no match). diff --git a/packages/stripe/GUIDE.md b/packages/stripe/GUIDE.md index 4b38265d7..3193c6d27 100644 --- a/packages/stripe/GUIDE.md +++ b/packages/stripe/GUIDE.md @@ -30,7 +30,7 @@ pnpm --filter @prefabs.tech/fastify-stripe lint ## Setup -Register `@prefabs.tech/fastify-config` first, then the Stripe plugin. The plugin reads everything from `fastify.config.stripe` — there are no register-time options. +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"; @@ -61,11 +61,13 @@ const config: ApiConfig = { const fastify = Fastify({ logger: true }); await fastify.register(configPlugin, { config }); -await fastify.register(stripePlugin); +await fastify.register(stripePlugin, config.stripe); await fastify.listen({ port: 3000, host: "0.0.0.0" }); ``` +**Legacy path:** `await fastify.register(stripePlugin)` with no second argument (or `{}`) logs a recommendation to pass options explicitly, then falls back to `fastify.config.stripe` if the config plugin was registered first. Unlike graphql/slonik/mailer, Stripe **does not throw** when no Stripe config is resolved; it logs `"Stripe configuration is missing. Stripe plugin will not be registered."` and returns. + 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. @@ -83,7 +85,7 @@ The official [Stripe Node SDK](https://github.com/stripe/stripe-node) for talkin 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.** Our `verifyStripeSignature` preHandler is a thin wrapper that calls `stripe.webhooks.constructEvent(rawBody, signature, secret)` and attaches the result to the request. +- **`webhooks.constructEvent` is passed through unchanged.** The webhook route uses `createVerifyStripeSignature(stripeConfig)`, which calls `stripe.webhooks.constructEvent(rawBody, signature, secret)` with the resolved `StripeConfig` and attaches the result to the request. - **`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. @@ -101,7 +103,9 @@ This package exposes the SDK partially: ### Plugin registration and missing-config guard -`stripePlugin` is `fastify-plugin`-wrapped, so its decorations attach to the top-level Fastify instance. If `config.stripe` is missing entirely, the plugin logs a warning and returns — registering the plugin against a config that doesn't have Stripe is safe and never throws. +`stripePlugin` is `fastify-plugin`-wrapped, so its decorations attach to the top-level Fastify instance. + +After resolving register-time options (see **Setup**), if no Stripe configuration is available, the plugin logs a warning and returns — it **does not throw** (unlike `@prefabs.tech/fastify-graphql` / `fastify-slonik` / `fastify-mailer` on the same code path). ```typescript const config: ApiConfig = { @@ -112,9 +116,10 @@ await fastify.register(configPlugin, { config }); await fastify.register(stripePlugin); ``` -Server log: +Server log (legacy empty options path; two warnings when `stripe` is absent from `config`): ``` +WARN: The stripe plugin now recommends passing stripe options directly to the plugin. WARN: Stripe configuration is missing. Stripe plugin will not be registered. ``` @@ -186,13 +191,13 @@ const handleWebhook = async ( } ``` -### Signature verification (`verifyStripeSignature`) +### Signature verification (`createVerifyStripeSignature`) -The webhook route runs the `verifyStripeSignature` preHandler before invoking your handler. It validates the `stripe-signature` header against `config.stripe.webhookSecret` using `stripe.webhooks.constructEvent`. All failures return HTTP 400 with a `{ error }` body and log an error line: +The webhook route runs the preHandler returned by `createVerifyStripeSignature(resolvedStripeConfig)` before invoking your handler. It validates the `stripe-signature` header against `resolvedStripeConfig.webhookSecret` using `stripe.webhooks.constructEvent`. All failures return HTTP 400 with a `{ error }` body and log an error line: | Condition | Status | Response body | | ------------------------------------------------------ | ------ | ------------------------------------------------------------------- | -| `config.stripe.webhookSecret` unset | 400 | `{ error: "Webhook secret not configured" }` | +| `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" }` | diff --git a/packages/stripe/README.md b/packages/stripe/README.md index 3e4bdd131..fc825e897 100644 --- a/packages/stripe/README.md +++ b/packages/stripe/README.md @@ -35,8 +35,9 @@ const start = async () => { // Register fastify-config plugin await fastify.register(configPlugin, { config }); - // Register stripe plugin - await fastify.register(stripePlugin); + // Register stripe plugin (pass the same object as `config.stripe` — same idea as + // `register(mailerPlugin, config.mailer)` / `register(graphqlPlugin, config.graphql)`) + await fastify.register(stripePlugin, config.stripe); await fastify.listen({ port: config.port, @@ -47,6 +48,20 @@ const start = async () => { start(); ``` +### Legacy registration (empty register options) + +If you call `await fastify.register(stripePlugin)` with no second argument (or with an empty object), the plugin logs that you should pass Stripe options at register time, then reads from `fastify.config.stripe` when `fastify-config` has been registered first. + +### Optional Stripe + +When `fastify.config.stripe` is also missing after that fallback, the plugin logs a warning and skips registration (it does not throw). Omit `config.stripe` entirely on services that do not use Stripe. + +```typescript +// After configPlugin, if `config` has no `stripe` key: +await fastify.register(stripePlugin); +// WARN: recommends passing options directly; then WARN: Stripe configuration is missing… +``` + ## Configuration Add Stripe configuration to your config: diff --git a/packages/stripe/src/__test__/plugin.test.ts b/packages/stripe/src/__test__/plugin.test.ts index ab83c9196..79653c42b 100644 --- a/packages/stripe/src/__test__/plugin.test.ts +++ b/packages/stripe/src/__test__/plugin.test.ts @@ -34,10 +34,13 @@ describe("stripePlugin — missing configuration", async () => { await expect(fastify.register(plugin)).resolves.not.toThrow(); }); - it("warns when config.stripe is missing", async () => { + it("warns when stripe configuration is missing (legacy path, then no config)", async () => { const warnSpy = vi.spyOn(fastify.log, "warn"); await fastify.register(plugin); await fastify.ready(); + expect(warnSpy).toHaveBeenCalledWith( + "The stripe plugin now recommends passing stripe options directly to the plugin.", + ); expect(warnSpy).toHaveBeenCalledWith( "Stripe configuration is missing. Stripe plugin will not be registered.", ); @@ -131,3 +134,76 @@ describe("stripePlugin — fastify-plugin wrapping", async () => { ); }); }); + +describe("stripePlugin — register-time options", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: { level: "silent" } }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("registers the webhook route when options are passed and enablePaymentWebhook is true (without fastify.config.stripe)", async () => { + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + true, + ); + }); + + it("does not log legacy deprecation when stripe options are passed explicitly", async () => { + const warnSpy = vi.spyOn(fastify.log, "warn"); + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: false }), + ); + await fastify.ready(); + + expect(warnSpy).not.toHaveBeenCalledWith( + "The stripe plugin now recommends passing stripe options directly to the plugin.", + ); + }); +}); + +describe("stripePlugin — legacy fastify.config.stripe fallback", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: { level: "silent" } }); + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("reads stripe config from fastify.config when register is called without options", async () => { + const warnSpy = vi.spyOn(fastify.log, "warn"); + + await fastify.register(plugin); + await fastify.ready(); + + expect(warnSpy).toHaveBeenCalledWith( + "The stripe plugin now recommends passing stripe options directly to the plugin.", + ); + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + true, + ); + }); +}); diff --git a/packages/stripe/src/__test__/verifyStripeSignature.test.ts b/packages/stripe/src/__test__/verifyStripeSignature.test.ts index 656ae9f3b..1b16a9fcd 100644 --- a/packages/stripe/src/__test__/verifyStripeSignature.test.ts +++ b/packages/stripe/src/__test__/verifyStripeSignature.test.ts @@ -284,14 +284,17 @@ const buildBareReply = () => ({ status: vi.fn().mockReturnThis(), }); -describe("verifyStripeSignature — raw body missing", async () => { +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 { default: verifyStripeSignature } = + const { createVerifyStripeSignature } = await import("../middlewares/verifyStripeSignature"); + const verifyStripeSignature = + createVerifyStripeSignature(createStripeConfig()); + type VerifyArguments = Parameters; beforeEach(() => { diff --git a/packages/stripe/src/__test__/webhookController.test.ts b/packages/stripe/src/__test__/webhookController.test.ts index c528f9f31..18a81986a 100644 --- a/packages/stripe/src/__test__/webhookController.test.ts +++ b/packages/stripe/src/__test__/webhookController.test.ts @@ -221,7 +221,7 @@ describe("webhookController — defensive guards", async () => { expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining( - "Stripe webhook controller registered without config.stripe", + "Stripe webhook controller registered without stripe configuration", ), ); expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( diff --git a/packages/stripe/src/middlewares/verifyStripeSignature.ts b/packages/stripe/src/middlewares/verifyStripeSignature.ts index 101b661c7..6e2c69385 100644 --- a/packages/stripe/src/middlewares/verifyStripeSignature.ts +++ b/packages/stripe/src/middlewares/verifyStripeSignature.ts @@ -1,53 +1,54 @@ import { FastifyReply, FastifyRequest } from "fastify"; import Stripe from "stripe"; -const verifyStripeSignature = async ( - request: FastifyRequest, - reply: FastifyReply, -): Promise => { - const { config, log } = request.server; - const webhookSecret = config.stripe?.webhookSecret; +import type { StripeConfig } from "../types"; - if (!webhookSecret) { - log.error( - "Stripe webhook secret is not configured; rejecting webhook request.", - ); +export const createVerifyStripeSignature = + (stripeConfig: StripeConfig) => + async (request: FastifyRequest, reply: FastifyReply): Promise => { + const { log } = request.server; + const webhookSecret = stripeConfig.webhookSecret; - return reply.status(400).send({ error: "Webhook secret not configured" }); - } + if (!webhookSecret) { + log.error( + "Stripe webhook secret is not configured; rejecting webhook request.", + ); - const signature = request.headers["stripe-signature"]; - - if (!signature) { - log.error("Missing stripe-signature header"); - - return reply.status(400).send({ error: "Missing stripe-signature header" }); - } + return reply.status(400).send({ error: "Webhook secret not configured" }); + } - try { - const rawBody = request.rawBody; + const signature = request.headers["stripe-signature"]; - if (!rawBody) { - log.error("Raw body is not available for signature verification"); + if (!signature) { + log.error("Missing stripe-signature header"); - return reply.status(400).send({ - error: "Raw body is not available for signature verification", - }); + return reply + .status(400) + .send({ error: "Missing stripe-signature header" }); } - 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" }); - } -}; - -export default verifyStripeSignature; + 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 index c678a1aad..6a94f9109 100644 --- a/packages/stripe/src/plugin.ts +++ b/packages/stripe/src/plugin.ts @@ -2,12 +2,28 @@ import type { FastifyInstance } from "fastify"; import fastifyPlugin from "fastify-plugin"; +import type { StripeConfig } from "./types"; + import webhookController from "./webhook/controller"; -const plugin = async (fastify: FastifyInstance) => { - const { config, log } = fastify; +const plugin = async (fastify: FastifyInstance, options?: StripeConfig) => { + const rawOptions = options ?? {}; + + let stripeConfig: StripeConfig | undefined; + + if (Object.keys(rawOptions).length === 0) { + fastify.log.warn( + "The stripe plugin now recommends passing stripe options directly to the plugin.", + ); + + stripeConfig = fastify.config?.stripe; + } else { + stripeConfig = rawOptions as StripeConfig; + } + + const { log } = fastify; - if (!config.stripe) { + if (!stripeConfig) { log.warn( "Stripe configuration is missing. Stripe plugin will not be registered.", ); @@ -17,8 +33,8 @@ const plugin = async (fastify: FastifyInstance) => { fastify.log.info("Registering Stripe plugin"); - if (config.stripe.enablePaymentWebhook) { - await fastify.register(webhookController); + if (stripeConfig.enablePaymentWebhook) { + await fastify.register(webhookController, { stripeConfig }); } }; diff --git a/packages/stripe/src/webhook/controller.ts b/packages/stripe/src/webhook/controller.ts index 089d9cc31..33e3dac76 100644 --- a/packages/stripe/src/webhook/controller.ts +++ b/packages/stripe/src/webhook/controller.ts @@ -1,21 +1,27 @@ import { FastifyInstance, FastifyRequest } from "fastify"; +import type { StripeConfig } from "../types"; + import { ROUTE_STRIPE_WEBHOOK } from "../constants"; -import verifyStripeSignature from "../middlewares/verifyStripeSignature"; +import { createVerifyStripeSignature } from "../middlewares/verifyStripeSignature"; import stripeRawBodyParser from "../utils/stripeRawBodyParser"; import webhookHandler from "./handler"; -const plugin = async (fastify: FastifyInstance) => { +export type WebhookControllerOptions = { + stripeConfig?: StripeConfig; +}; + +const plugin = async ( + fastify: FastifyInstance, + options?: WebhookControllerOptions, +) => { fastify.log.info("Registering Stripe webhook route"); - // `stripe` is guaranteed by the parent plugin (which only registers this - // controller when `config.stripe` is set), but we narrow it once locally - // so the rest of the function stays free of `!` non-null assertions. - const stripeConfig = fastify.config.stripe; + const stripeConfig = options?.stripeConfig ?? fastify.config?.stripe; if (!stripeConfig) { fastify.log.error( - "Stripe webhook controller registered without config.stripe; skipping route registration.", + "Stripe webhook controller registered without stripe configuration; skipping route registration.", ); return; @@ -31,12 +37,12 @@ const plugin = async (fastify: FastifyInstance) => { fastify.post( stripeConfig.webhookPath || ROUTE_STRIPE_WEBHOOK, - { preHandler: [verifyStripeSignature] }, + { preHandler: [createVerifyStripeSignature(stripeConfig)] }, async (request: FastifyRequest, reply) => { const event = request.stripeEvent; if (!event) { - // Should be unreachable: verifyStripeSignature either sets the 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.", From 2b0da8594caea78b9132d84e5c3949a04ad94180 Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Thu, 14 May 2026 13:43:40 +0545 Subject: [PATCH 11/16] feat(stripe)!: require register options and peer Stripe SDK Move stripe to peerDependencies; keep it in devDependencies for package tests. Throw when plugin options or webhook { stripeConfig } is missing. Tighten Fastify plugin typings; bundle externals from peers only in Vite. Update stripe docs, tests, and lockfile. --- packages/stripe/ANALYSIS.md | 86 ++-- packages/stripe/FEATURES.md | 73 ++- packages/stripe/GUIDE.md | 24 +- packages/stripe/README.md | 20 +- packages/stripe/package.json | 7 +- packages/stripe/src/__test__/plugin.test.ts | 127 +---- .../src/__test__/stripeRawBodyParser.test.ts | 9 +- .../__test__/verifyStripeSignature.test.ts | 68 ++- .../src/__test__/webhookController.test.ts | 98 ++-- packages/stripe/src/plugin.ts | 45 +- packages/stripe/src/types/index.ts | 8 +- packages/stripe/src/webhook/controller.ts | 28 +- packages/stripe/vite.config.ts | 7 +- pnpm-lock.yaml | 463 +----------------- 14 files changed, 234 insertions(+), 829 deletions(-) diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md index 3015480a1..4da14e7e8 100644 --- a/packages/stripe/ANALYSIS.md +++ b/packages/stripe/ANALYSIS.md @@ -1,4 +1,4 @@ - + # `@prefabs.tech/fastify-stripe` — Package Analysis @@ -17,8 +17,7 @@ - Raw-body content-type parser scoped to webhook routes - Config-aware `StripeClient` helper with defaults for currency, URLs, and promotion codes - Module augmentations for `fastify.config.stripe`, `request.rawBody`, and `request.stripeEvent` - - Missing-config guard (plugin warns and skips registration when Stripe config does not resolve) - - Register-time options with legacy `fastify.config.stripe` fallback (same ergonomics as graphql/slonik/mailer; Stripe does not throw when config is still missing after fallback) + - Required register-time `StripeConfig` (throws if omitted or `{}`, same class of error as empty-options registration in mailer/slonik) - Webhook handler warning system (logs at registration when no custom handler is provided) - Default fallback webhook handler (returns 200 to prevent Stripe retries) - Mode-specific metadata routing for checkout sessions @@ -30,59 +29,57 @@ ### OURS — Custom Logic **Plugin Registration (`src/plugin.ts`)** -1. Resolves Stripe config: register options when non-empty; otherwise warns and reads `fastify.config?.stripe` (legacy path) -2. Missing-config guard: logs warning and returns early if no config resolves (does not throw) -3. Conditional webhook controller registration when resolved `enablePaymentWebhook` is truthy, passing `{ stripeConfig }` into the controller -4. `fastify-plugin` wrapping for non-encapsulated registration +1. Requires resolved `StripeConfig` from register options: throws if options are omitted or `{}` +2. Conditional webhook controller registration when resolved `enablePaymentWebhook` is truthy, passing `{ stripeConfig }` into the controller +3. `fastify-plugin` wrapping for non-encapsulated registration **Type System (`src/types/index.ts`, `src/index.ts`)** -5. Module augmentation of `@prefabs.tech/fastify-config` to add `stripe?: StripeConfig` -6. Module augmentation of `fastify` to add `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` -7. Curated `StripeConfig` type (not a direct passthrough of Stripe SDK types) -8. `CreateSessionInput` type for simplified checkout session creation +4. Module augmentation of `@prefabs.tech/fastify-config` to add `stripe?: StripeConfig` +5. Module augmentation of `fastify` to add `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` +6. Curated `StripeConfig` type (not a direct passthrough of Stripe SDK types) +7. `CreateSessionInput` type for simplified checkout session creation **Webhook Controller (`src/webhook/controller.ts`)** -9. Accepts optional `{ stripeConfig }` at register time; falls back to `fastify.config?.stripe` when the controller is registered directly -10. Registration-time warning when `handlers.webhook` is unset but `enablePaymentWebhook` is true -11. Defensive guard logging error when neither register options nor `fastify.config.stripe` provides config -12. Route path defaults to `ROUTE_STRIPE_WEBHOOK` when resolved `webhookPath` is unset -13. Conditional dispatch: calls custom handler if provided, otherwise falls back to default handler -14. Error response when `request.stripeEvent` is missing after verification (defensive, should be unreachable) +8. Requires `{ stripeConfig }` at register time; throws if `stripeConfig` is missing +9. Registration-time warning when `handlers.webhook` is unset on the resolved config (webhook route is still registered; default handler applies) +10. Route path defaults to `ROUTE_STRIPE_WEBHOOK` when resolved `webhookPath` is unset +11. Conditional dispatch: calls custom handler if provided, otherwise falls back to default handler +12. Error response when `request.stripeEvent` is missing after verification (defensive, should be unreachable) **Signature Verification (`src/middlewares/verifyStripeSignature.ts`)** -15. `createVerifyStripeSignature(stripeConfig)` returns a preHandler that closes over `webhookSecret` (does not read `request.server.config.stripe`) -16. Structured 400 responses with specific error messages for each failure mode -17. Error logging for all verification failures -18. Guards: missing secret, missing signature header, missing raw body -19. Attaches verified event to `request.stripeEvent` on success +13. `createVerifyStripeSignature(stripeConfig)` returns a preHandler that closes over `webhookSecret` (does not read `request.server.config.stripe`) +14. Structured 400 responses with specific error messages for each failure mode +15. Error logging for all verification failures +16. Guards: missing secret, missing signature header, missing raw body +17. Attaches verified event to `request.stripeEvent` on success **Raw Body Parser (`src/utils/stripeRawBodyParser.ts`)** -20. Custom `application/json` parser that captures `request.rawBody` while still parsing JSON -21. JSON parse errors tagged with `statusCode: 400` for proper HTTP response -22. Scoped to plugin encapsulation (doesn't affect parent instance routes) +18. Custom `application/json` parser that captures `request.rawBody` while still parsing JSON +19. JSON parse errors tagged with `statusCode: 400` for proper HTTP response +20. Scoped to plugin encapsulation (doesn't affect parent instance routes) **Default Webhook Handler (`src/webhook/handler.ts`)** -23. Intentionally returns without throwing to avoid 500 response and Stripe retry loop -24. Logs error with `eventId` and `eventType` for visibility -25. Acknowledges event with 200 to suppress Stripe retries +21. Intentionally returns without throwing to avoid 500 response and Stripe retry loop +22. Logs error with `eventId` and `eventType` for visibility +23. Acknowledges event with 200 to suppress Stripe retries **StripeClient Wrapper (`src/utils/stripeClient.ts`)** -26. Constructor throws when `config.stripe` is unset -27. `createCheckoutSession`: defaults `quantity` to `1` -28. `createCheckoutSession`: defaults `mode` to `"payment"` -29. `createCheckoutSession`: defaults `currency` to `config.stripe.defaultCurrency` -30. `createCheckoutSession`: defaults `successUrl` to `config.stripe.urls.success` -31. `createCheckoutSession`: defaults `cancelUrl` to `config.stripe.urls.cancel` -32. `createCheckoutSession`: forwards `config.stripe.allowPromotionCodes` as `allow_promotion_codes` -33. `createCheckoutSession`: synthesizes single `line_items` entry from flat input -34. `createCheckoutSession`: mode-specific metadata routing (payment → `payment_intent_data.metadata`, subscription → `subscription_data.metadata`, setup → `setup_intent_data.metadata`) -35. `createCheckoutSession`: omits all `*_data` blocks when metadata is not provided -36. `getActivePromotionCode`: hardcodes `active: true` filter -37. `getActivePromotionCode`: returns only first match (no pagination) +24. Constructor throws when `config.stripe` is unset +25. `createCheckoutSession`: defaults `quantity` to `1` +26. `createCheckoutSession`: defaults `mode` to `"payment"` +27. `createCheckoutSession`: defaults `currency` to `config.stripe.defaultCurrency` +28. `createCheckoutSession`: defaults `successUrl` to `config.stripe.urls.success` +29. `createCheckoutSession`: defaults `cancelUrl` to `config.stripe.urls.cancel` +30. `createCheckoutSession`: forwards `config.stripe.allowPromotionCodes` as `allow_promotion_codes` +31. `createCheckoutSession`: synthesizes single `line_items` entry from flat input +32. `createCheckoutSession`: mode-specific metadata routing (payment → `payment_intent_data.metadata`, subscription → `subscription_data.metadata`, setup → `setup_intent_data.metadata`) +33. `createCheckoutSession`: omits all `*_data` blocks when metadata is not provided +34. `getActivePromotionCode`: hardcodes `active: true` filter +35. `getActivePromotionCode`: returns only first match (no pagination) **Constants & Exports (`src/constants.ts`, `src/index.ts`)** -38. `ROUTE_STRIPE_WEBHOOK` constant exported for consumer reference -39. Re-exports: `StripeConfig`, `StripeEvent` (aliased from `Stripe.Event`), `CreateSessionInput` +36. `ROUTE_STRIPE_WEBHOOK` constant exported for consumer reference +37. Re-exports: `StripeConfig`, `StripeEvent` (aliased from `Stripe.Event`), `CreateSessionInput` ### THEIRS — Direct Passthrough @@ -126,13 +123,12 @@ ### Conditional Branches 1. **Plugin Registration:** - - If register options are empty → warn, then read `fastify.config?.stripe` - - If Stripe config still missing → log warning, skip registration (no throw) + - If register options are omitted or `{}` → throw `"Missing stripe configuration. Did you forget to pass it to the stripe plugin?"` - If resolved `enablePaymentWebhook` is truthy → register webhook controller with `{ stripeConfig }` - If resolved `enablePaymentWebhook` is falsy → skip webhook controller 2. **Webhook Controller:** - - If neither `{ stripeConfig }` nor `fastify.config.stripe` is set → log error, skip route registration + - If `{ stripeConfig }` is not provided → throw `"Missing stripe configuration. Did you forget to pass { stripeConfig } to the Stripe webhook controller?"` - If `handlers.webhook` is missing → log warning at registration time - If `handlers.webhook` is set → dispatch to custom handler - If `handlers.webhook` is unset → dispatch to default handler (logs error, returns 200) diff --git a/packages/stripe/FEATURES.md b/packages/stripe/FEATURES.md index 6dad1f7ad..1424f7414 100644 --- a/packages/stripe/FEATURES.md +++ b/packages/stripe/FEATURES.md @@ -5,62 +5,61 @@ ## Plugin Registration 1. The plugin is exported via `fastify-plugin`, so all registrations attach to the top-level (non-encapsulated) Fastify instance. -2. Register-time options are recommended: `fastify.register(stripePlugin, config.stripe)` (same convention as graphql/slonik/mailer). If register options are empty, the plugin logs `"The stripe plugin now recommends passing stripe options directly to the plugin."` then reads from `fastify.config.stripe`. -3. When no Stripe configuration resolves after that step, the plugin logs (`"Stripe configuration is missing. Stripe plugin will not be registered."`) and returns without registering anything — **it does not throw** (unlike graphql/slonik/mailer on an equivalent missing-config path). -4. When Stripe configuration is present, the plugin logs `"Registering Stripe plugin"` at `info` level. -5. The webhook controller is registered only when the resolved config's `enablePaymentWebhook` is truthy, with `{ stripeConfig }` passed into the controller so signature verification closes over the same object (not only `fastify.config.stripe`). +2. Registration requires explicit options: `fastify.register(stripePlugin, config.stripe)` (same convention as `@prefabs.tech/fastify-mailer` / `@prefabs.tech/fastify-slonik`). If options are omitted or `{}`, the plugin throws `"Missing stripe configuration. Did you forget to pass it to the stripe plugin?"` (same class of failure as those plugins on an empty-options path). +3. When registration succeeds, the plugin logs `"Registering Stripe plugin"` at `info` level. +4. The webhook controller is registered only when the resolved config's `enablePaymentWebhook` is truthy, with `{ stripeConfig }` passed into the controller so signature verification closes over the same resolved object. ## Configuration & Type Exports -6. Module augmentation of `@prefabs.tech/fastify-config` adds `stripe?: StripeConfig` to `ApiConfig` (optional, matching the plugin's runtime tolerance for missing config). -7. Module augmentation of `fastify` adds `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` to `FastifyRequest`. -8. `StripeConfig` type is exported (curated subset; not a direct passthrough of any Stripe SDK type). -9. `StripeEvent` type is exported as an alias for `Stripe.Event`. -10. `CreateSessionInput` type describes the checkout helper input shape. -11. `ROUTE_STRIPE_WEBHOOK` constant (`"/payment/webhook"`) is exported as the default webhook route path. +5. Module augmentation of `@prefabs.tech/fastify-config` adds `stripe?: StripeConfig` to `ApiConfig` (optional — omit when a service does not use Stripe; the Stripe plugin is only registered when you pass options explicitly). +6. Module augmentation of `fastify` adds `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` to `FastifyRequest`. +7. `StripeConfig` type is exported (curated subset; not a direct passthrough of any Stripe SDK type). +8. `StripeEvent` type is exported as an alias for `Stripe.Event`. +9. `CreateSessionInput` type describes the checkout helper input shape. +10. `ROUTE_STRIPE_WEBHOOK` constant (`"/payment/webhook"`) is exported as the default webhook route path. ## Webhook Endpoint -12. A `POST` route is registered at the resolved config's `webhookPath`, falling back to `ROUTE_STRIPE_WEBHOOK` (`/payment/webhook`) when unset. -13. The webhook controller logs `"Registering Stripe webhook route"` at `info` level on registration. -14. When the controller is registered with `enablePaymentWebhook: true` but `handlers.webhook` is unset, the controller logs a warning at registration time (`"config.stripe.handlers.webhook is not set; received webhooks will be acknowledged but not processed. Provide a handler to fulfill events."`). -15. The verified Stripe event is dispatched to `handlers.webhook` on the resolved config when defined. -16. When `handlers.webhook` is not defined, the request falls through to the default handler, which logs an error with `{ eventId, eventType }` and resolves so the route responds `200` (instead of throwing 500 and triggering Stripe's retry backoff). -17. The route responds with `500 { error: "Stripe event not found on request" }` and logs an error when the preHandler did not attach `request.stripeEvent` (defensive guard — should be unreachable). -18. If the webhook controller is registered directly (not via the parent plugin) with no `{ stripeConfig }` and `fastify.config.stripe` is unset, it logs an error (`"Stripe webhook controller registered without stripe configuration; skipping route registration."`) and registers no route. +11. A `POST` route is registered at the resolved config's `webhookPath`, falling back to `ROUTE_STRIPE_WEBHOOK` (`/payment/webhook`) when unset. +12. The webhook controller logs `"Registering Stripe webhook route"` at `info` level on registration. +13. When the controller is registered with `enablePaymentWebhook: true` but `handlers.webhook` is unset, the controller logs a warning at registration time (`"config.stripe.handlers.webhook is not set; received webhooks will be acknowledged but not processed. Provide a handler to fulfill events."`). +14. The verified Stripe event is dispatched to `handlers.webhook` on the resolved config when defined. +15. When `handlers.webhook` is not defined, the request falls through to the default handler, which logs an error with `{ eventId, eventType }` and resolves so the route responds `200` (instead of throwing 500 and triggering Stripe's retry backoff). +16. The route responds with `500 { error: "Stripe event not found on request" }` and logs an error when the preHandler did not attach `request.stripeEvent` (defensive guard — should be unreachable). +17. If the webhook controller is registered directly (not via the parent plugin) without `{ stripeConfig }`, it throws `"Missing stripe configuration. Did you forget to pass { stripeConfig } to the Stripe webhook controller?"` and registers no route. ## Webhook Signature Verification The preHandler from `createVerifyStripeSignature(stripeConfig)` runs on the webhook route and performs the following checks in order: -19. Responds with 400 `{ error: "Webhook secret not configured" }` and logs an error (`"Stripe webhook secret is not configured; rejecting webhook request."`) when `stripeConfig.webhookSecret` is unset. -20. Responds with 400 `{ error: "Missing stripe-signature header" }` and logs an error when the `stripe-signature` request header is absent. -21. Responds with 400 `{ error: "Raw body is not available for signature verification" }` and logs an error when `request.rawBody` is unset. -22. Responds with 400 `{ error: "Webhook signature verification failed" }` and logs the underlying error when `stripe.webhooks.constructEvent` throws. -23. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (available via module augmentation). +18. Responds with 400 `{ error: "Webhook secret not configured" }` and logs an error (`"Stripe webhook secret is not configured; rejecting webhook request."`) when `stripeConfig.webhookSecret` is unset. +19. Responds with 400 `{ error: "Missing stripe-signature header" }` and logs an error when the `stripe-signature` request header is absent. +20. Responds with 400 `{ error: "Raw body is not available for signature verification" }` and logs an error when `request.rawBody` is unset. +21. Responds with 400 `{ error: "Webhook signature verification failed" }` and logs the underlying error when `stripe.webhooks.constructEvent` throws. +22. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (available via module augmentation). ## Raw Body Parser -24. `registerRawBodyParser(fastify)` registers a Fastify content-type parser for `application/json` that captures the request buffer to `request.rawBody` and parses JSON for downstream handlers. -25. JSON parse errors are tagged with `statusCode: 400` and forwarded through `done(error)`, so Fastify's default error handler produces a 400 response. -26. When the webhook controller installs the raw body parser, the parser is scoped to the webhook controller's plugin encapsulation. It applies to the webhook route but does **not** bleed into other `application/json` routes registered on the parent Fastify instance. Calling `registerRawBodyParser(fastify)` directly on the parent installs it on that instance instead. +23. `registerRawBodyParser(fastify)` registers a Fastify content-type parser for `application/json` that captures the request buffer to `request.rawBody` and parses JSON for downstream handlers. +24. JSON parse errors are tagged with `statusCode: 400` and forwarded through `done(error)`, so Fastify's default error handler produces a 400 response. +25. When the webhook controller installs the raw body parser, the parser is scoped to the webhook controller's plugin encapsulation. It applies to the webhook route but does **not** bleed into other `application/json` routes registered on the parent Fastify instance. Calling `registerRawBodyParser(fastify)` directly on the parent installs it on that instance instead. ## `StripeClient` Helper -27. `new StripeClient(config)` throws `"StripeClient requires config.stripe to be set on the provided ApiConfig."` when `config.stripe` is unset. -28. `new StripeClient(config)` constructs a `Stripe` SDK client using `config.stripe.apiKey` and forwards `config.stripe.clientConfig` unmodified. -29. The raw `Stripe` SDK instance is exposed as `client.stripe` for direct SDK calls. -30. `createCheckoutSession(input, metadata?)` synthesizes a Checkout session containing exactly one `line_items` entry built from `productName`, `unitAmount`, `quantity`, and `currency`. -31. `createCheckoutSession` defaults `quantity` to `1` when `input.quantity` is unset. -32. `createCheckoutSession` defaults `mode` to `"payment"` when `input.mode` is unset. -33. `createCheckoutSession` defaults `currency` to `config.stripe.defaultCurrency` when `input.currency` is unset. -34. `createCheckoutSession` defaults `success_url` to `config.stripe.urls.success` when `input.successUrl` is unset. -35. `createCheckoutSession` defaults `cancel_url` to `config.stripe.urls.cancel` when `input.cancelUrl` is unset. -36. `createCheckoutSession` forwards `config.stripe.allowPromotionCodes` as the `allow_promotion_codes` parameter (passed as-is — `undefined` is allowed). -37. `createCheckoutSession` writes the `metadata` argument onto `session.metadata` only when `metadata` is provided. When provided, it additionally writes it onto the mode-specific data block: +26. `new StripeClient(config)` throws `"StripeClient requires config.stripe to be set on the provided ApiConfig."` when `config.stripe` is unset. +27. `new StripeClient(config)` constructs a `Stripe` SDK client using `config.stripe.apiKey` and forwards `config.stripe.clientConfig` unmodified. +28. The raw `Stripe` SDK instance is exposed as `client.stripe` for direct SDK calls. +29. `createCheckoutSession(input, metadata?)` synthesizes a Checkout session containing exactly one `line_items` entry built from `productName`, `unitAmount`, `quantity`, and `currency`. +30. `createCheckoutSession` defaults `quantity` to `1` when `input.quantity` is unset. +31. `createCheckoutSession` defaults `mode` to `"payment"` when `input.mode` is unset. +32. `createCheckoutSession` defaults `currency` to `config.stripe.defaultCurrency` when `input.currency` is unset. +33. `createCheckoutSession` defaults `success_url` to `config.stripe.urls.success` when `input.successUrl` is unset. +34. `createCheckoutSession` defaults `cancel_url` to `config.stripe.urls.cancel` when `input.cancelUrl` is unset. +35. `createCheckoutSession` forwards `config.stripe.allowPromotionCodes` as the `allow_promotion_codes` parameter (passed as-is — `undefined` is allowed). +36. `createCheckoutSession` writes the `metadata` argument onto `session.metadata` only when `metadata` is provided. When provided, it additionally writes it onto the mode-specific data block: - `mode: "payment"` → `payment_intent_data.metadata` - `mode: "subscription"` → `subscription_data.metadata` - `mode: "setup"` → `setup_intent_data.metadata` Only the block matching the selected mode is set; the others are left unset so Stripe does not reject the call. When `metadata` is not provided, no mode-specific `*_data` block is set. -38. `getActivePromotionCode(code)` calls `promotionCodes.list({ active: true, code })` and returns only the first matching `Stripe.PromotionCode` (or `undefined` when there is no match). +37. `getActivePromotionCode(code)` calls `promotionCodes.list({ active: true, code })` and returns only the first matching `Stripe.PromotionCode` (or `undefined` when there is no match). diff --git a/packages/stripe/GUIDE.md b/packages/stripe/GUIDE.md index 3193c6d27..38a65d1bd 100644 --- a/packages/stripe/GUIDE.md +++ b/packages/stripe/GUIDE.md @@ -16,7 +16,7 @@ npm install @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config stripe fas pnpm add @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config stripe fastify fastify-plugin ``` -Peer dependencies enforced by `package.json`: `fastify >= 5.2.2`, `fastify-plugin >= 5.0.1`, `@prefabs.tech/fastify-config 0.94.0`. +Peer dependencies enforced by `package.json`: `fastify >= 5.2.2`, `fastify-plugin >= 5.0.1`, `@prefabs.tech/fastify-config 0.94.0`, `stripe >= 20.3.0`. ### For monorepo development @@ -66,7 +66,7 @@ await fastify.register(stripePlugin, config.stripe); await fastify.listen({ port: 3000, host: "0.0.0.0" }); ``` -**Legacy path:** `await fastify.register(stripePlugin)` with no second argument (or `{}`) logs a recommendation to pass options explicitly, then falls back to `fastify.config.stripe` if the config plugin was registered first. Unlike graphql/slonik/mailer, Stripe **does not throw** when no Stripe config is resolved; it logs `"Stripe configuration is missing. Stripe plugin will not be registered."` and returns. +You must pass `config.stripe` as the second argument to `register`, like `@prefabs.tech/fastify-mailer` / `@prefabs.tech/fastify-slonik`. Omitting options or passing `{}` throws with `"Missing stripe configuration. Did you forget to pass it to the stripe plugin?"` — the same idea as an empty-options registration to those plugins. 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. @@ -101,27 +101,13 @@ This package exposes the SDK partially: ## Features -### Plugin registration and missing-config guard +### Plugin registration and required options `stripePlugin` is `fastify-plugin`-wrapped, so its decorations attach to the top-level Fastify instance. -After resolving register-time options (see **Setup**), if no Stripe configuration is available, the plugin logs a warning and returns — it **does not throw** (unlike `@prefabs.tech/fastify-graphql` / `fastify-slonik` / `fastify-mailer` on the same code path). +This plugin expects `register(stripePlugin, config.stripe)`. Do not call `register(stripePlugin)` with no second argument or with `{}` — registration will reject with the same style of `"Missing stripe…"` error as other prefabs plugins on an invalid empty-options registration. -```typescript -const config: ApiConfig = { - // ...no `stripe` block -}; - -await fastify.register(configPlugin, { config }); -await fastify.register(stripePlugin); -``` - -Server log (legacy empty options path; two warnings when `stripe` is absent from `config`): - -``` -WARN: The stripe plugin now recommends passing stripe options directly to the plugin. -WARN: Stripe configuration is missing. Stripe plugin will not be registered. -``` +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`) diff --git a/packages/stripe/README.md b/packages/stripe/README.md index fc825e897..752b42eeb 100644 --- a/packages/stripe/README.md +++ b/packages/stripe/README.md @@ -1,6 +1,6 @@ # @prefabs.tech/fastify-stripe -A [Fastify](https://github.com/fastify/fastify) plugin for that provides easy integration of Stripe for payment processing. +A [Fastify](https://github.com/fastify/fastify) plugin that integrates Stripe for payment processing. ## Features @@ -11,7 +11,7 @@ A [Fastify](https://github.com/fastify/fastify) plugin for that provides easy in ## Requirements -- [@prefabs.tech/fastify-config](https://www.npmjs.com/package/@prefabs.tech/fastify-config) +Peer dependencies (install explicitly in your app): `fastify`, `fastify-plugin`, `@prefabs.tech/fastify-config`, and `stripe`. See `package.json` for supported version ranges. ## Usage @@ -48,19 +48,9 @@ const start = async () => { start(); ``` -### Legacy registration (empty register options) +You must pass the Stripe options object as the second argument to `register` (typically `config.stripe`), matching `@prefabs.tech/fastify-mailer`, `@prefabs.tech/fastify-slonik`, and other prefabs plugins. Omitting options or passing `{}` throws at registration time. -If you call `await fastify.register(stripePlugin)` with no second argument (or with an empty object), the plugin logs that you should pass Stripe options at register time, then reads from `fastify.config.stripe` when `fastify-config` has been registered first. - -### Optional Stripe - -When `fastify.config.stripe` is also missing after that fallback, the plugin logs a warning and skips registration (it does not throw). Omit `config.stripe` entirely on services that do not use Stripe. - -```typescript -// After configPlugin, if `config` has no `stripe` key: -await fastify.register(stripePlugin); -// WARN: recommends passing options directly; then WARN: Stripe configuration is missing… -``` +If a service does not use Stripe, do not register this plugin (you may still omit `stripe` from `ApiConfig` for `StripeClient` usage elsewhere). ## Configuration @@ -186,7 +176,7 @@ const config: ApiConfig = { apiKey: process.env.STRIPE_API_KEY!, // ... clientConfig: { - apiVersion: "2026-01-28.clover", + apiVersion: "2024-11-20.acacia", }, }, }; diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 3b6b883ed..2c1548368 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -30,9 +30,6 @@ "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", @@ -43,6 +40,7 @@ "fastify": "5.8.5", "fastify-plugin": "5.1.0", "prettier": "3.8.3", + "stripe": "20.3.1", "typescript": "5.9.3", "vite": "6.4.2", "vitest": "3.2.4" @@ -50,7 +48,8 @@ "peerDependencies": { "@prefabs.tech/fastify-config": "0.94.0", "fastify": ">=5.2.2", - "fastify-plugin": ">=5.0.1" + "fastify-plugin": ">=5.0.1", + "stripe": ">=20.3.0" }, "engines": { "node": ">=20" diff --git a/packages/stripe/src/__test__/plugin.test.ts b/packages/stripe/src/__test__/plugin.test.ts index 79653c42b..fd4d1bc79 100644 --- a/packages/stripe/src/__test__/plugin.test.ts +++ b/packages/stripe/src/__test__/plugin.test.ts @@ -1,6 +1,8 @@ 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"; @@ -30,27 +32,15 @@ describe("stripePlugin — missing configuration", async () => { await fastify.close(); }); - it("does not throw when config.stripe is missing", async () => { - await expect(fastify.register(plugin)).resolves.not.toThrow(); - }); - - it("warns when stripe configuration is missing (legacy path, then no config)", async () => { - const warnSpy = vi.spyOn(fastify.log, "warn"); - await fastify.register(plugin); - await fastify.ready(); - expect(warnSpy).toHaveBeenCalledWith( - "The stripe plugin now recommends passing stripe options directly to the plugin.", - ); - expect(warnSpy).toHaveBeenCalledWith( - "Stripe configuration is missing. Stripe plugin will not be registered.", + it("throws when register is called without options", async () => { + await expect(fastify.register(plugin)).rejects.toThrow( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", ); }); - it("does not register the webhook route when config.stripe is missing", async () => { - await fastify.register(plugin); - await fastify.ready(); - expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( - false, + it("throws when register is called with an empty options object", async () => { + await expect(fastify.register(plugin, {} as StripeConfig)).rejects.toThrow( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", ); }); }); @@ -69,24 +59,23 @@ describe("stripePlugin — configuration present", async () => { await fastify.close(); }); - it("logs 'Registering Stripe plugin' at info level when config is present", async () => { - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: false }), - }); + it("logs 'Registering Stripe plugin' at info level when config is passed", async () => { const infoSpy = vi.spyOn(fastify.log, "info"); - await fastify.register(plugin); + 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 () => { - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: false }), - }); - - await fastify.register(plugin); + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: false }), + ); await fastify.ready(); expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( @@ -95,11 +84,10 @@ describe("stripePlugin — configuration present", async () => { }); it("registers the webhook route when enablePaymentWebhook is true", async () => { - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: true }), - }); - - await fastify.register(plugin); + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); await fastify.ready(); expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( @@ -116,9 +104,6 @@ describe("stripePlugin — fastify-plugin wrapping", async () => { beforeEach(() => { vi.clearAllMocks(); fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: true }), - }); }); afterEach(async () => { @@ -126,30 +111,6 @@ describe("stripePlugin — fastify-plugin wrapping", async () => { }); it("registers without encapsulation so the route is reachable on the top-level instance", async () => { - await fastify.register(plugin); - await fastify.ready(); - - expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( - true, - ); - }); -}); - -describe("stripePlugin — register-time options", async () => { - const { default: plugin } = await import("../plugin"); - - let fastify: FastifyInstance; - - beforeEach(() => { - vi.clearAllMocks(); - fastify = Fastify({ logger: { level: "silent" } }); - }); - - afterEach(async () => { - await fastify.close(); - }); - - it("registers the webhook route when options are passed and enablePaymentWebhook is true (without fastify.config.stripe)", async () => { await fastify.register( plugin, createStripeConfig({ enablePaymentWebhook: true }), @@ -160,50 +121,4 @@ describe("stripePlugin — register-time options", async () => { true, ); }); - - it("does not log legacy deprecation when stripe options are passed explicitly", async () => { - const warnSpy = vi.spyOn(fastify.log, "warn"); - - await fastify.register( - plugin, - createStripeConfig({ enablePaymentWebhook: false }), - ); - await fastify.ready(); - - expect(warnSpy).not.toHaveBeenCalledWith( - "The stripe plugin now recommends passing stripe options directly to the plugin.", - ); - }); -}); - -describe("stripePlugin — legacy fastify.config.stripe fallback", async () => { - const { default: plugin } = await import("../plugin"); - - let fastify: FastifyInstance; - - beforeEach(() => { - vi.clearAllMocks(); - fastify = Fastify({ logger: { level: "silent" } }); - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: true }), - }); - }); - - afterEach(async () => { - await fastify.close(); - }); - - it("reads stripe config from fastify.config when register is called without options", async () => { - const warnSpy = vi.spyOn(fastify.log, "warn"); - - await fastify.register(plugin); - await fastify.ready(); - - expect(warnSpy).toHaveBeenCalledWith( - "The stripe plugin now recommends passing stripe options directly to the plugin.", - ); - expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( - true, - ); - }); }); diff --git a/packages/stripe/src/__test__/stripeRawBodyParser.test.ts b/packages/stripe/src/__test__/stripeRawBodyParser.test.ts index f263bb374..f7ac1a634 100644 --- a/packages/stripe/src/__test__/stripeRawBodyParser.test.ts +++ b/packages/stripe/src/__test__/stripeRawBodyParser.test.ts @@ -97,11 +97,10 @@ describe("stripeRawBodyParser — scoping when installed by the webhook controll // content-type parser is encapsulated to the controller's plugin scope // and does NOT bleed into the parent instance. fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: true }), - }); - - await fastify.register(plugin); + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); let unrelatedRawBody: unknown; fastify.post("/some/other/route", async (request) => { diff --git a/packages/stripe/src/__test__/verifyStripeSignature.test.ts b/packages/stripe/src/__test__/verifyStripeSignature.test.ts index 1b16a9fcd..840289661 100644 --- a/packages/stripe/src/__test__/verifyStripeSignature.test.ts +++ b/packages/stripe/src/__test__/verifyStripeSignature.test.ts @@ -1,6 +1,8 @@ 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"; @@ -20,12 +22,16 @@ const SAMPLE_EVENT = { type: "checkout.session.completed", }; -const buildFastify = (stripeOverrides: Record = {}) => { - const fastify = Fastify({ logger: { level: "silent" } }); - fastify.decorate("config", { - stripe: createStripeConfig(stripeOverrides), - }); - return fastify; +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 () => { @@ -43,14 +49,12 @@ describe("verifyStripeSignature — webhookSecret missing", async () => { }); it("responds with 400 and 'Webhook secret not configured' when webhookSecret is unset", async () => { - fastify = buildFastify({ - enablePaymentWebhook: true, + fastify = Fastify({ logger: { level: "silent" } }); + + await registerWithStripe(fastify, plugin, { webhookSecret: undefined, }); - await fastify.register(plugin); - await fastify.ready(); - const res = await fastify.inject({ headers: { "content-type": "application/json", @@ -66,14 +70,12 @@ describe("verifyStripeSignature — webhookSecret missing", async () => { }); it("logs an error when webhookSecret is unset", async () => { - fastify = buildFastify({ - enablePaymentWebhook: true, - webhookSecret: undefined, - }); + fastify = Fastify({ logger: { level: "silent" } }); const errorSpy = vi.spyOn(fastify.log, "error"); - await fastify.register(plugin); - await fastify.ready(); + await registerWithStripe(fastify, plugin, { + webhookSecret: undefined, + }); await fastify.inject({ headers: { @@ -99,7 +101,7 @@ describe("verifyStripeSignature — signature header missing", async () => { beforeEach(() => { vi.clearAllMocks(); constructEventMock.mockReturnValue(SAMPLE_EVENT); - fastify = buildFastify({ enablePaymentWebhook: true }); + fastify = Fastify({ logger: { level: "silent" } }); }); afterEach(async () => { @@ -107,8 +109,7 @@ describe("verifyStripeSignature — signature header missing", async () => { }); it("responds with 400 and 'Missing stripe-signature header' when the header is absent", async () => { - await fastify.register(plugin); - await fastify.ready(); + await registerWithStripe(fastify, plugin); const res = await fastify.inject({ headers: { "content-type": "application/json" }, @@ -122,8 +123,7 @@ describe("verifyStripeSignature — signature header missing", async () => { }); it("does not invoke stripe.webhooks.constructEvent when the signature header is missing", async () => { - await fastify.register(plugin); - await fastify.ready(); + await registerWithStripe(fastify, plugin); await fastify.inject({ headers: { "content-type": "application/json" }, @@ -143,7 +143,7 @@ describe("verifyStripeSignature — signature verification failure", async () => beforeEach(() => { vi.clearAllMocks(); - fastify = buildFastify({ enablePaymentWebhook: true }); + fastify = Fastify({ logger: { level: "silent" } }); }); afterEach(async () => { @@ -155,8 +155,7 @@ describe("verifyStripeSignature — signature verification failure", async () => throw new Error("invalid signature"); }); - await fastify.register(plugin); - await fastify.ready(); + await registerWithStripe(fastify, plugin); const res = await fastify.inject({ headers: { @@ -181,8 +180,7 @@ describe("verifyStripeSignature — signature verification failure", async () => }); const errorSpy = vi.spyOn(fastify.log, "error"); - await fastify.register(plugin); - await fastify.ready(); + await registerWithStripe(fastify, plugin); await fastify.inject({ headers: { @@ -216,12 +214,6 @@ describe("verifyStripeSignature — success", async () => { constructEventMock.mockReturnValue(SAMPLE_EVENT); fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ - enablePaymentWebhook: true, - handlers: { webhook: webhookHandlerMock }, - }), - }); }); afterEach(async () => { @@ -229,8 +221,9 @@ describe("verifyStripeSignature — success", async () => { }); it("attaches the verified Stripe.Event to the request before the route handler runs", async () => { - await fastify.register(plugin); - await fastify.ready(); + await registerWithStripe(fastify, plugin, { + handlers: { webhook: webhookHandlerMock }, + }); const res = await fastify.inject({ headers: { @@ -247,8 +240,9 @@ describe("verifyStripeSignature — success", async () => { }); it("calls stripe.webhooks.constructEvent with the raw body, signature, and configured secret", async () => { - await fastify.register(plugin); - await fastify.ready(); + await registerWithStripe(fastify, plugin, { + handlers: { webhook: webhookHandlerMock }, + }); const payload = JSON.stringify({ id: "evt_test" }); await fastify.inject({ diff --git a/packages/stripe/src/__test__/webhookController.test.ts b/packages/stripe/src/__test__/webhookController.test.ts index 18a81986a..59ea30d3a 100644 --- a/packages/stripe/src/__test__/webhookController.test.ts +++ b/packages/stripe/src/__test__/webhookController.test.ts @@ -52,11 +52,11 @@ describe("webhookController — route registration", async () => { it("registers POST at /payment/webhook by default when webhookPath is unset", async () => { fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: true }), - }); - await fastify.register(plugin); + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); await fastify.ready(); expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( @@ -66,14 +66,14 @@ describe("webhookController — route registration", async () => { it("registers POST at the configured webhookPath when set", async () => { fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true, webhookPath: "/custom/webhook", }), - }); - - await fastify.register(plugin); + ); await fastify.ready(); expect(fastify.hasRoute({ method: "POST", url: "/custom/webhook" })).toBe( @@ -83,12 +83,12 @@ describe("webhookController — route registration", async () => { it("logs 'Registering Stripe webhook route' at info level", async () => { fastify = Fastify({ logger: { level: "silent" } }); - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: true }), - }); const infoSpy = vi.spyOn(fastify.log, "info"); - await fastify.register(plugin); + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); await fastify.ready(); expect(infoSpy).toHaveBeenCalledWith("Registering Stripe webhook route"); @@ -112,14 +112,14 @@ describe("webhookController — dispatch", async () => { it("invokes config.stripe.handlers.webhook with request and verified event", async () => { const webhookHandlerMock = vi.fn().mockResolvedValue(); fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true, handlers: { webhook: webhookHandlerMock }, }), - }); - - await fastify.register(plugin); + ); await fastify.ready(); const res = await injectWebhook(fastify, "/payment/webhook"); @@ -131,11 +131,11 @@ describe("webhookController — dispatch", async () => { it("responds 200 with the default fallback handler when no custom handler is configured (to suppress Stripe retries)", async () => { fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: true }), - }); - await fastify.register(plugin); + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); await fastify.ready(); const res = await injectWebhook(fastify, "/payment/webhook"); @@ -145,12 +145,12 @@ describe("webhookController — dispatch", async () => { it("warns at registration time when enablePaymentWebhook is true but handlers.webhook is unset", async () => { fastify = Fastify({ logger: { level: "silent" } }); - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: true }), - }); const warnSpy = vi.spyOn(fastify.log, "warn"); - await fastify.register(plugin); + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); await fastify.ready(); expect(warnSpy).toHaveBeenCalledWith( @@ -161,15 +161,15 @@ describe("webhookController — dispatch", async () => { it("does NOT warn at registration time when handlers.webhook is configured", async () => { const webhookHandlerMock = vi.fn().mockResolvedValue(); fastify = Fastify({ logger: { level: "silent" } }); - fastify.decorate("config", { - stripe: createStripeConfig({ + const warnSpy = vi.spyOn(fastify.log, "warn"); + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true, handlers: { webhook: webhookHandlerMock }, }), - }); - const warnSpy = vi.spyOn(fastify.log, "warn"); - - await fastify.register(plugin); + ); await fastify.ready(); expect(warnSpy).not.toHaveBeenCalledWith( @@ -180,14 +180,14 @@ describe("webhookController — dispatch", async () => { it("does not call the default handler when handlers.webhook is configured", async () => { const webhookHandlerMock = vi.fn().mockResolvedValue(); fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ + + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true, handlers: { webhook: webhookHandlerMock }, }), - }); - - await fastify.register(plugin); + ); await fastify.ready(); const res = await injectWebhook(fastify, "/payment/webhook"); @@ -211,21 +211,11 @@ describe("webhookController — defensive guards", async () => { await fastify.close(); }); - it("logs an error and does NOT register the route when registered directly without config.stripe", async () => { + it("throws when the webhook controller is registered without stripeConfig", async () => { fastify = Fastify({ logger: { level: "silent" } }); - fastify.decorate("config", {} as unknown as FastifyInstance["config"]); - const errorSpy = vi.spyOn(fastify.log, "error"); - - await fastify.register(webhookController); - await fastify.ready(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - "Stripe webhook controller registered without stripe configuration", - ), - ); - expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( - false, + await expect(fastify.register(webhookController)).rejects.toThrow( + "Missing stripe configuration. Did you forget to pass { stripeConfig } to the Stripe webhook controller?", ); }); @@ -238,11 +228,11 @@ describe("webhookController — defensive guards", async () => { ); fastify = Fastify({ logger: false }); - fastify.decorate("config", { - stripe: createStripeConfig({ enablePaymentWebhook: true }), - }); - await fastify.register(plugin); + await fastify.register( + plugin, + createStripeConfig({ enablePaymentWebhook: true }), + ); await fastify.ready(); const res = await injectWebhook(fastify, "/payment/webhook"); diff --git a/packages/stripe/src/plugin.ts b/packages/stripe/src/plugin.ts index 6a94f9109..47ec7610d 100644 --- a/packages/stripe/src/plugin.ts +++ b/packages/stripe/src/plugin.ts @@ -1,45 +1,26 @@ -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyPluginAsync } from "fastify"; -import fastifyPlugin from "fastify-plugin"; +import FastifyPlugin from "fastify-plugin"; import type { StripeConfig } from "./types"; import webhookController from "./webhook/controller"; -const plugin = async (fastify: FastifyInstance, options?: StripeConfig) => { - const rawOptions = options ?? {}; - - let stripeConfig: StripeConfig | undefined; - - if (Object.keys(rawOptions).length === 0) { - fastify.log.warn( - "The stripe plugin now recommends passing stripe options directly to the plugin.", - ); - - stripeConfig = fastify.config?.stripe; - } else { - stripeConfig = rawOptions as StripeConfig; - } - - const { log } = fastify; +const plugin: FastifyPluginAsync = async ( + fastify: FastifyInstance, + options, +) => { + fastify.log.info("Registering Stripe plugin"); - if (!stripeConfig) { - log.warn( - "Stripe configuration is missing. Stripe plugin will not be registered.", + if (!options || Object.keys(options).length === 0) { + throw new Error( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", ); - - return; } - fastify.log.info("Registering Stripe plugin"); - - if (stripeConfig.enablePaymentWebhook) { - await fastify.register(webhookController, { stripeConfig }); + if (options.enablePaymentWebhook) { + await fastify.register(webhookController, { stripeConfig: options }); } }; -const stripePlugin: ReturnType = fastifyPlugin( - plugin as unknown as Parameters[0], -); - -export default stripePlugin; +export default FastifyPlugin(plugin); diff --git a/packages/stripe/src/types/index.ts b/packages/stripe/src/types/index.ts index 3d7575403..c0f9d8f24 100644 --- a/packages/stripe/src/types/index.ts +++ b/packages/stripe/src/types/index.ts @@ -1,3 +1,5 @@ +import type { FastifyPluginOptions } from "fastify"; + import Stripe from "stripe"; import webhookHandler from "../webhook/handler"; @@ -18,7 +20,7 @@ export type CreateSessionInput = { unitAmount: number; }; -export type StripeConfig = { +export type StripeConfig = FastifyPluginOptions & { allowPromotionCodes?: boolean; apiKey: string; clientConfig?: Stripe.StripeConfig; @@ -34,3 +36,7 @@ export type StripeConfig = { webhookPath?: string; webhookSecret?: string; }; + +export type WebhookControllerOptions = FastifyPluginOptions & { + stripeConfig: StripeConfig; +}; diff --git a/packages/stripe/src/webhook/controller.ts b/packages/stripe/src/webhook/controller.ts index 33e3dac76..0ff9f0c76 100644 --- a/packages/stripe/src/webhook/controller.ts +++ b/packages/stripe/src/webhook/controller.ts @@ -1,32 +1,30 @@ -import { FastifyInstance, FastifyRequest } from "fastify"; +import { + FastifyInstance, + type FastifyPluginAsync, + FastifyRequest, +} from "fastify"; -import type { StripeConfig } from "../types"; +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"; -export type WebhookControllerOptions = { - stripeConfig?: StripeConfig; -}; - -const plugin = async ( +const plugin: FastifyPluginAsync = async ( fastify: FastifyInstance, - options?: WebhookControllerOptions, + options, ) => { fastify.log.info("Registering Stripe webhook route"); - const stripeConfig = options?.stripeConfig ?? fastify.config?.stripe; - - if (!stripeConfig) { - fastify.log.error( - "Stripe webhook controller registered without stripe configuration; skipping route registration.", + if (!options?.stripeConfig) { + throw new Error( + "Missing stripe configuration. Did you forget to pass { stripeConfig } to the Stripe webhook controller?", ); - - return; } + 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.", diff --git a/packages/stripe/vite.config.ts b/packages/stripe/vite.config.ts index 750cf9dae..37bb260dc 100644 --- a/packages/stripe/vite.config.ts +++ b/packages/stripe/vite.config.ts @@ -2,7 +2,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig, loadEnv } from "vite"; -import { dependencies, peerDependencies } from "./package.json"; +import { peerDependencies } from "./package.json"; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { @@ -17,10 +17,7 @@ export default defineConfig(({ mode }) => { name: "PrefabsTechFastifyStripe", }, rollupOptions: { - external: [ - ...Object.keys(dependencies), - ...Object.keys(peerDependencies), - ], + external: Object.keys(peerDependencies), output: { exports: "named", globals: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c00914a7a..2f958f9d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -482,14 +482,10 @@ importers: 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)(typescript@5.9.3))(eslint@9.39.4)(prettier@3.8.3)(typescript@5.9.3) + 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: link:../config @@ -501,10 +497,10 @@ importers: version: 24.10.15 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)) + 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 + version: 9.39.4(jiti@2.6.1) fastify: specifier: 5.8.5 version: 5.8.5 @@ -514,15 +510,18 @@ importers: prettier: specifier: 3.8.3 version: 3.8.3 + stripe: + specifier: 20.3.1 + version: 20.3.1(@types/node@24.10.15) typescript: specifier: 5.9.3 version: 5.9.3 vite: specifier: 6.4.2 - version: 6.4.2(@types/node@24.10.15) + 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) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) packages/swagger: dependencies: @@ -6667,11 +6666,6 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': - dependencies: - eslint: 9.39.4 - eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.12.2': {} '@eslint/config-array@0.21.2': @@ -7245,36 +7239,6 @@ snapshots: - eslint-plugin-import-x - supports-color - '@prefabs.tech/eslint-config@0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(prettier@3.8.3)(typescript@5.9.3)': - dependencies: - '@eslint/js': 9.39.4 - eslint: 9.39.4 - eslint-config-prettier: 10.1.8(eslint@9.39.4) - 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) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4) - eslint-plugin-n: 17.20.0(eslint@9.39.4)(typescript@5.9.3) - eslint-plugin-perfectionist: 5.9.0(eslint@9.39.4)(typescript@5.9.3) - eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.3) - eslint-plugin-promise: 7.2.1(eslint@9.39.4) - eslint-plugin-react: 7.37.5(eslint@9.39.4) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4) - eslint-plugin-unicorn: 62.0.0(eslint@9.39.4) - eslint-plugin-vue: 10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.2.0(eslint@9.39.4)) - globals: 17.3.0 - prettier: 3.8.3 - typescript: 5.9.3 - typescript-eslint: 8.58.0(eslint@9.39.4)(typescript@5.9.3) - vue-eslint-parser: 10.2.0(eslint@9.39.4) - transitivePeerDependencies: - - '@stylistic/eslint-plugin' - - '@types/eslint' - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - '@prefabs.tech/postgres-migrations@5.4.3': dependencies: pg: 8.20.0 @@ -7913,22 +7877,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - eslint: 9.39.4 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.58.0 @@ -7941,18 +7889,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - debug: 4.4.3 - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) @@ -7983,18 +7919,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/types@8.58.0': {} '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': @@ -8023,17 +7947,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/visitor-keys@8.58.0': dependencies: '@typescript-eslint/types': 8.58.0 @@ -8114,22 +8027,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-istanbul@3.2.4(vitest@3.2.4(@types/node@24.10.15))': - dependencies: - '@istanbuljs/schema': 0.1.3 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magicast: 0.3.5 - test-exclude: 7.0.1 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.10.15) - transitivePeerDependencies: - - supports-color - '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -8146,14 +8043,6 @@ snapshots: optionalDependencies: vite: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) - '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.10.15))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 6.4.2(@types/node@24.10.15) - '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -9008,19 +8897,10 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) semver: 7.7.4 - eslint-compat-utils@0.5.1(eslint@9.39.4): - dependencies: - eslint: 9.39.4 - semver: 7.7.4 - eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) - eslint-config-prettier@10.1.8(eslint@9.39.4): - dependencies: - eslint: 9.39.4 - eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: get-tsconfig: 4.13.1 @@ -9030,7 +8910,7 @@ snapshots: eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0): dependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + 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-import-resolver-node@0.3.9: dependencies: @@ -9055,21 +8935,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4): - dependencies: - debug: 4.4.3 - eslint: 9.39.4 - eslint-import-context: 0.1.9(unrs-resolver@1.11.1) - get-tsconfig: 4.13.1 - is-bun-module: 2.0.0 - stable-hash-x: 0.2.0 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 @@ -9081,17 +8946,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) - transitivePeerDependencies: - - supports-color - eslint-plugin-es-x@7.8.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9099,13 +8953,6 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-es-x@7.8.0(eslint@9.39.4): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@eslint-community/regexpp': 4.12.2 - eslint: 9.39.4 - eslint-compat-utils: 0.5.1(eslint@9.39.4) - 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)): dependencies: '@rtsao/scc': 1.1.0 @@ -9135,35 +8982,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) - hasown: 2.0.3 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): dependencies: aria-query: 5.3.2 @@ -9183,25 +9001,6 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4): - dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.11.1 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 9.39.4 - hasown: 2.0.3 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - eslint-plugin-n@17.20.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9219,23 +9018,6 @@ snapshots: - supports-color - typescript - eslint-plugin-n@17.20.0(eslint@9.39.4)(typescript@5.9.3): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - enhanced-resolve: 5.19.0 - eslint: 9.39.4 - eslint-plugin-es-x: 7.8.0(eslint@9.39.4) - get-tsconfig: 4.13.1 - globals: 15.15.0 - ignore: 5.3.2 - minimatch: 9.0.5 - semver: 7.7.4 - ts-declaration-location: 1.0.7(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - - typescript - eslint-plugin-perfectionist@5.9.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) @@ -9245,15 +9027,6 @@ snapshots: - supports-color - typescript - eslint-plugin-perfectionist@5.9.0(eslint@9.39.4)(typescript@5.9.3): - dependencies: - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - natural-orderby: 5.0.0 - transitivePeerDependencies: - - supports-color - - typescript - 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): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -9263,25 +9036,11 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.3): - dependencies: - eslint: 9.39.4 - prettier: 3.8.3 - prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 - optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.4) - eslint-plugin-promise@7.2.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-promise@7.2.1(eslint@9.39.4): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - eslint: 9.39.4 - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/core': 7.28.5 @@ -9293,17 +9052,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4): - dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 - eslint: 9.39.4 - hermes-parser: 0.25.1 - zod: 3.25.76 - zod-validation-error: 4.0.2(zod@3.25.76) - transitivePeerDependencies: - - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -9326,28 +9074,6 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-react@7.37.5(eslint@9.39.4): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.2 - eslint: 9.39.4 - estraverse: 5.3.0 - hasown: 2.0.3 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.5 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.5 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - eslint-plugin-unicorn@62.0.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -9370,28 +9096,6 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-unicorn@62.0.0(eslint@9.39.4): - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@eslint/plugin-kit': 0.4.1 - change-case: 5.4.4 - ci-info: 4.4.0 - clean-regexp: 1.0.0 - core-js-compat: 3.48.0 - eslint: 9.39.4 - esquery: 1.6.0 - find-up-simple: 1.0.1 - globals: 16.5.0 - indent-string: 5.0.0 - is-builtin-module: 5.0.0 - jsesc: 3.1.0 - pluralize: 8.0.0 - regexp-tree: 0.1.27 - regjsparser: 0.13.0 - semver: 7.7.4 - strip-indent: 4.1.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))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9405,19 +9109,6 @@ snapshots: optionalDependencies: '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-vue@10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.2.0(eslint@9.39.4)): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - eslint: 9.39.4 - natural-compare: 1.4.0 - nth-check: 2.1.1 - postcss-selector-parser: 7.1.1 - semver: 7.7.4 - vue-eslint-parser: 10.2.0(eslint@9.39.4) - xml-name-validator: 4.0.0 - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -9429,45 +9120,6 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.15.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - eslint@9.39.4(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -12221,17 +11873,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript-eslint@8.58.0(eslint@9.39.4)(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - typescript@5.9.3: {} uglify-js@3.19.3: {} @@ -12318,27 +11959,6 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@24.10.15): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 6.4.2(@types/node@24.10.15) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite-node@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: cac: 6.7.14 @@ -12360,18 +11980,6 @@ snapshots: - tsx - yaml - vite@6.4.2(@types/node@24.10.15): - dependencies: - esbuild: 0.25.11 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.52.5 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.15 - fsevents: 2.3.3 - vite@6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: esbuild: 0.25.11 @@ -12386,47 +11994,6 @@ snapshots: jiti: 2.6.1 yaml: 2.8.1 - vitest@3.2.4(@types/node@24.10.15): - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.10.15)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.2.2 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@24.10.15) - vite-node: 3.2.4(@types/node@24.10.15) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.10.15 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 @@ -12480,18 +12047,6 @@ snapshots: transitivePeerDependencies: - supports-color - vue-eslint-parser@10.2.0(eslint@9.39.4): - dependencies: - debug: 4.4.3 - eslint: 9.39.4 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - walk-up-path@3.0.1: {} web-resource-inliner@6.0.1: From 57c0ec1c8c643cf60703e87dc8f62a74e010e2bc Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Thu, 14 May 2026 15:02:24 +0545 Subject: [PATCH 12/16] feat(stripe): move stripe to runtime dependency and refresh docs - Add stripe as a direct dependency; remove from peerDependencies - Externalize dependencies in stripe Vite build alongside peers - Remove README; update ANALYSIS, FEATURES, and GUIDE --- packages/stripe/ANALYSIS.md | 210 +++++++++++---------------------- packages/stripe/FEATURES.md | 87 ++++++-------- packages/stripe/GUIDE.md | 24 ++-- packages/stripe/README.md | 183 ---------------------------- packages/stripe/package.json | 7 +- packages/stripe/vite.config.ts | 7 +- pnpm-lock.yaml | 7 +- 7 files changed, 131 insertions(+), 394 deletions(-) delete mode 100644 packages/stripe/README.md diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md index 4da14e7e8..f60a57647 100644 --- a/packages/stripe/ANALYSIS.md +++ b/packages/stripe/ANALYSIS.md @@ -1,173 +1,97 @@ - - -# `@prefabs.tech/fastify-stripe` — Package Analysis + ## Base Library Passthrough Analysis ### `stripe` (Stripe Node SDK) — PARTIAL PASSTHROUGH / MODIFIED -- **Options type:** Partial import — `clientConfig` is imported from `Stripe.StripeConfig` and passed through; other SDK features are accessed via the exposed `client.stripe` instance. -- **Options passed:** `clientConfig` is forwarded unmodified to `new Stripe(apiKey, clientConfig)`. All other SDK configuration happens through the curated `StripeConfig` type defined by this package. -- **Features restricted:** - - `checkout.sessions.create` is wrapped with a simplified surface (`CreateSessionInput`) that supports only single-product sessions. Multi-product sessions, tax rates, shipping options, and other advanced features require direct SDK access via `client.stripe`. - - `promotionCodes.list` is wrapped to hardcode `active: true` and return only the first result. -- **Features added:** - - Fastify plugin with automatic webhook endpoint registration - - Signature verification preHandler with structured 400 responses - - Raw-body content-type parser scoped to webhook routes - - Config-aware `StripeClient` helper with defaults for currency, URLs, and promotion codes - - Module augmentations for `fastify.config.stripe`, `request.rawBody`, and `request.stripeEvent` - - Required register-time `StripeConfig` (throws if omitted or `{}`, same class of error as empty-options registration in mailer/slonik) - - Webhook handler warning system (logs at registration when no custom handler is provided) - - Default fallback webhook handler (returns 200 to prevent Stripe retries) - - Mode-specific metadata routing for checkout sessions +- 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) -## Code Classification: "Ours" vs "Theirs" - -### OURS — Custom Logic - -**Plugin Registration (`src/plugin.ts`)** -1. Requires resolved `StripeConfig` from register options: throws if options are omitted or `{}` -2. Conditional webhook controller registration when resolved `enablePaymentWebhook` is truthy, passing `{ stripeConfig }` into the controller -3. `fastify-plugin` wrapping for non-encapsulated registration - -**Type System (`src/types/index.ts`, `src/index.ts`)** -4. Module augmentation of `@prefabs.tech/fastify-config` to add `stripe?: StripeConfig` -5. Module augmentation of `fastify` to add `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` -6. Curated `StripeConfig` type (not a direct passthrough of Stripe SDK types) -7. `CreateSessionInput` type for simplified checkout session creation - -**Webhook Controller (`src/webhook/controller.ts`)** -8. Requires `{ stripeConfig }` at register time; throws if `stripeConfig` is missing -9. Registration-time warning when `handlers.webhook` is unset on the resolved config (webhook route is still registered; default handler applies) -10. Route path defaults to `ROUTE_STRIPE_WEBHOOK` when resolved `webhookPath` is unset -11. Conditional dispatch: calls custom handler if provided, otherwise falls back to default handler -12. Error response when `request.stripeEvent` is missing after verification (defensive, should be unreachable) - -**Signature Verification (`src/middlewares/verifyStripeSignature.ts`)** -13. `createVerifyStripeSignature(stripeConfig)` returns a preHandler that closes over `webhookSecret` (does not read `request.server.config.stripe`) -14. Structured 400 responses with specific error messages for each failure mode -15. Error logging for all verification failures -16. Guards: missing secret, missing signature header, missing raw body -17. Attaches verified event to `request.stripeEvent` on success - -**Raw Body Parser (`src/utils/stripeRawBodyParser.ts`)** -18. Custom `application/json` parser that captures `request.rawBody` while still parsing JSON -19. JSON parse errors tagged with `statusCode: 400` for proper HTTP response -20. Scoped to plugin encapsulation (doesn't affect parent instance routes) - -**Default Webhook Handler (`src/webhook/handler.ts`)** -21. Intentionally returns without throwing to avoid 500 response and Stripe retry loop -22. Logs error with `eventId` and `eventType` for visibility -23. Acknowledges event with 200 to suppress Stripe retries - -**StripeClient Wrapper (`src/utils/stripeClient.ts`)** -24. Constructor throws when `config.stripe` is unset -25. `createCheckoutSession`: defaults `quantity` to `1` -26. `createCheckoutSession`: defaults `mode` to `"payment"` -27. `createCheckoutSession`: defaults `currency` to `config.stripe.defaultCurrency` -28. `createCheckoutSession`: defaults `successUrl` to `config.stripe.urls.success` -29. `createCheckoutSession`: defaults `cancelUrl` to `config.stripe.urls.cancel` -30. `createCheckoutSession`: forwards `config.stripe.allowPromotionCodes` as `allow_promotion_codes` -31. `createCheckoutSession`: synthesizes single `line_items` entry from flat input -32. `createCheckoutSession`: mode-specific metadata routing (payment → `payment_intent_data.metadata`, subscription → `subscription_data.metadata`, setup → `setup_intent_data.metadata`) -33. `createCheckoutSession`: omits all `*_data` blocks when metadata is not provided -34. `getActivePromotionCode`: hardcodes `active: true` filter -35. `getActivePromotionCode`: returns only first match (no pagination) - -**Constants & Exports (`src/constants.ts`, `src/index.ts`)** -36. `ROUTE_STRIPE_WEBHOOK` constant exported for consumer reference -37. Re-exports: `StripeConfig`, `StripeEvent` (aliased from `Stripe.Event`), `CreateSessionInput` - -### THEIRS — Direct Passthrough - -**Stripe SDK Usage** -1. `new Stripe(apiKey, clientConfig)` — `clientConfig` passed unmodified -2. `stripe.webhooks.constructEvent(rawBody, signature, secret)` — called directly with no transformation -3. `stripe.checkout.sessions.create(params)` — called directly after parameter synthesis -4. `stripe.promotionCodes.list({ active, code })` — called directly with hardcoded filter -5. Raw `Stripe` SDK instance exposed as `client.stripe` for direct access to all SDK features +- 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) -## Summary +- 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. -### Public Exports +--- -**Default Export:** `stripePlugin` — Fastify plugin wrapped with `fastify-plugin` for non-encapsulated registration +## Summary -**Named Exports:** -- `ROUTE_STRIPE_WEBHOOK` — constant string `"/payment/webhook"` -- `StripeClient` — class for config-aware Stripe operations -- `registerRawBodyParser` — function to install raw-body parser on any Fastify instance -- `StripeConfig` — type for `config.stripe` shape -- `StripeEvent` — re-exported `Stripe.Event` type -- `CreateSessionInput` — type for `createCheckoutSession` input +### Package metadata -### Framework Constructs Added +- **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`. -1. **Module Augmentations:** - - `@prefabs.tech/fastify-config` gains `ApiConfig.stripe?: StripeConfig` - - `fastify` gains `FastifyRequest.rawBody?: Buffer` and `FastifyRequest.stripeEvent?: Stripe.Event` +### Public exports (from `src/index.ts` and `src/utils/index.ts`) -2. **Fastify Plugin:** Non-encapsulated plugin (via `fastify-plugin`) that registers webhook routes on the parent instance +| 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`. | -3. **Content-Type Parser:** `application/json` parser registered inside webhook controller scope (does not affect parent routes) +`CreateSessionInput` exists in `src/types/index.ts` but is **not** re-exported from the package entry. -4. **PreHandler:** `createVerifyStripeSignature(resolvedConfig)` runs before webhook route handler +### Internal (not in public API) -5. **Route:** `POST` route at resolved `webhookPath` (defaults to `/payment/webhook`) when `enablePaymentWebhook` is true +| 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. | -### Conditional Branches +### Source modules (one line each) -1. **Plugin Registration:** - - If register options are omitted or `{}` → throw `"Missing stripe configuration. Did you forget to pass it to the stripe plugin?"` - - If resolved `enablePaymentWebhook` is truthy → register webhook controller with `{ stripeConfig }` - - If resolved `enablePaymentWebhook` is falsy → skip webhook controller +| 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`. | -2. **Webhook Controller:** - - If `{ stripeConfig }` is not provided → throw `"Missing stripe configuration. Did you forget to pass { stripeConfig } to the Stripe webhook controller?"` - - If `handlers.webhook` is missing → log warning at registration time - - If `handlers.webhook` is set → dispatch to custom handler - - If `handlers.webhook` is unset → dispatch to default handler (logs error, returns 200) +### `StripeClient` methods (ours vs theirs) -3. **Signature Verification:** - - If resolved `webhookSecret` is missing → return 400 - - If `stripe-signature` header is missing → return 400 - - If `request.rawBody` is missing → return 400 - - If `Stripe.webhooks.constructEvent` throws → return 400 +| 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`. | -4. **StripeClient Constructor:** - - If `config.stripe` is missing → throw error +### Framework / lifecycle -5. **createCheckoutSession Metadata:** - - If `metadata` is provided → write to `session.metadata` and mode-specific `*_data` block - - If `metadata` is not provided → omit all metadata fields +- 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. -6. **createCheckoutSession Mode Routing:** - - `mode: "payment"` → metadata to `payment_intent_data.metadata` - - `mode: "subscription"` → metadata to `subscription_data.metadata` - - `mode: "setup"` → metadata to `setup_intent_data.metadata` +### Conditional branches & defaults -### Default Values +- 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`. -| Field | Default Value | Applied When | -| --------------------------------- | --------------------------------------- | ------------------------------- | -| `webhookPath` | `/payment/webhook` | `config.stripe.webhookPath` unset | -| `CreateSessionInput.quantity` | `1` | `input.quantity` unset | -| `CreateSessionInput.mode` | `"payment"` | `input.mode` unset | -| `CreateSessionInput.currency` | `config.stripe.defaultCurrency` | `input.currency` unset | -| `CreateSessionInput.successUrl` | `config.stripe.urls.success` | `input.successUrl` unset | -| `CreateSessionInput.cancelUrl` | `config.stripe.urls.cancel` | `input.cancelUrl` unset | +### 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. -## Completeness Checklist +### “Ours” vs “Theirs” highlights -- ✅ Classified every public export as "ours" or "theirs" -- ✅ Listed every framework construct added (module augmentations, plugin, parser, preHandler, route) -- ✅ Identified every conditional branch (plugin registration, webhook dispatch, signature verification, metadata handling, mode routing) -- ✅ Documented default values for all options we define -- ✅ Produced passthrough classification for wrapped dependency (`stripe`) +- **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 index 1424f7414..139440231 100644 --- a/packages/stripe/FEATURES.md +++ b/packages/stripe/FEATURES.md @@ -1,65 +1,52 @@ -# `@prefabs.tech/fastify-stripe` — Features +## Plugin registration -## Plugin Registration +1. The default export is wrapped with `fastify-plugin` so the plugin applies to the parent Fastify scope. +2. Registration throws with a clear message if options are missing or an empty object (`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. -1. The plugin is exported via `fastify-plugin`, so all registrations attach to the top-level (non-encapsulated) Fastify instance. -2. Registration requires explicit options: `fastify.register(stripePlugin, config.stripe)` (same convention as `@prefabs.tech/fastify-mailer` / `@prefabs.tech/fastify-slonik`). If options are omitted or `{}`, the plugin throws `"Missing stripe configuration. Did you forget to pass it to the stripe plugin?"` (same class of failure as those plugins on an empty-options path). -3. When registration succeeds, the plugin logs `"Registering Stripe plugin"` at `info` level. -4. The webhook controller is registered only when the resolved config's `enablePaymentWebhook` is truthy, with `{ stripeConfig }` passed into the controller so signature verification closes over the same resolved object. +## Webhook route -## Configuration & Type Exports +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. -5. Module augmentation of `@prefabs.tech/fastify-config` adds `stripe?: StripeConfig` to `ApiConfig` (optional — omit when a service does not use Stripe; the Stripe plugin is only registered when you pass options explicitly). -6. Module augmentation of `fastify` adds `rawBody?: Buffer` and `stripeEvent?: Stripe.Event` to `FastifyRequest`. -7. `StripeConfig` type is exported (curated subset; not a direct passthrough of any Stripe SDK type). -8. `StripeEvent` type is exported as an alias for `Stripe.Event`. -9. `CreateSessionInput` type describes the checkout helper input shape. -10. `ROUTE_STRIPE_WEBHOOK` constant (`"/payment/webhook"`) is exported as the default webhook route path. +## Webhook signature verification -## Webhook Endpoint +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`. -11. A `POST` route is registered at the resolved config's `webhookPath`, falling back to `ROUTE_STRIPE_WEBHOOK` (`/payment/webhook`) when unset. -12. The webhook controller logs `"Registering Stripe webhook route"` at `info` level on registration. -13. When the controller is registered with `enablePaymentWebhook: true` but `handlers.webhook` is unset, the controller logs a warning at registration time (`"config.stripe.handlers.webhook is not set; received webhooks will be acknowledged but not processed. Provide a handler to fulfill events."`). -14. The verified Stripe event is dispatched to `handlers.webhook` on the resolved config when defined. -15. When `handlers.webhook` is not defined, the request falls through to the default handler, which logs an error with `{ eventId, eventType }` and resolves so the route responds `200` (instead of throwing 500 and triggering Stripe's retry backoff). -16. The route responds with `500 { error: "Stripe event not found on request" }` and logs an error when the preHandler did not attach `request.stripeEvent` (defensive guard — should be unreachable). -17. If the webhook controller is registered directly (not via the parent plugin) without `{ stripeConfig }`, it throws `"Missing stripe configuration. Did you forget to pass { stripeConfig } to the Stripe webhook controller?"` and registers no route. +## Raw body parser -## Webhook Signature Verification +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. -The preHandler from `createVerifyStripeSignature(stripeConfig)` runs on the webhook route and performs the following checks in order: +## Types and module augmentations -18. Responds with 400 `{ error: "Webhook secret not configured" }` and logs an error (`"Stripe webhook secret is not configured; rejecting webhook request."`) when `stripeConfig.webhookSecret` is unset. -19. Responds with 400 `{ error: "Missing stripe-signature header" }` and logs an error when the `stripe-signature` request header is absent. -20. Responds with 400 `{ error: "Raw body is not available for signature verification" }` and logs an error when `request.rawBody` is unset. -21. Responds with 400 `{ error: "Webhook signature verification failed" }` and logs the underlying error when `stripe.webhooks.constructEvent` throws. -22. On success, attaches the verified `Stripe.Event` to `request.stripeEvent` (available via module augmentation). +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`. -## Raw Body Parser +## StripeClient -23. `registerRawBodyParser(fastify)` registers a Fastify content-type parser for `application/json` that captures the request buffer to `request.rawBody` and parses JSON for downstream handlers. -24. JSON parse errors are tagged with `statusCode: 400` and forwarded through `done(error)`, so Fastify's default error handler produces a 400 response. -25. When the webhook controller installs the raw body parser, the parser is scoped to the webhook controller's plugin encapsulation. It applies to the webhook route but does **not** bleed into other `application/json` routes registered on the parent Fastify instance. Calling `registerRawBodyParser(fastify)` directly on the parent installs it on that instance instead. +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`. -## `StripeClient` Helper +## Constants -26. `new StripeClient(config)` throws `"StripeClient requires config.stripe to be set on the provided ApiConfig."` when `config.stripe` is unset. -27. `new StripeClient(config)` constructs a `Stripe` SDK client using `config.stripe.apiKey` and forwards `config.stripe.clientConfig` unmodified. -28. The raw `Stripe` SDK instance is exposed as `client.stripe` for direct SDK calls. -29. `createCheckoutSession(input, metadata?)` synthesizes a Checkout session containing exactly one `line_items` entry built from `productName`, `unitAmount`, `quantity`, and `currency`. -30. `createCheckoutSession` defaults `quantity` to `1` when `input.quantity` is unset. -31. `createCheckoutSession` defaults `mode` to `"payment"` when `input.mode` is unset. -32. `createCheckoutSession` defaults `currency` to `config.stripe.defaultCurrency` when `input.currency` is unset. -33. `createCheckoutSession` defaults `success_url` to `config.stripe.urls.success` when `input.successUrl` is unset. -34. `createCheckoutSession` defaults `cancel_url` to `config.stripe.urls.cancel` when `input.cancelUrl` is unset. -35. `createCheckoutSession` forwards `config.stripe.allowPromotionCodes` as the `allow_promotion_codes` parameter (passed as-is — `undefined` is allowed). -36. `createCheckoutSession` writes the `metadata` argument onto `session.metadata` only when `metadata` is provided. When provided, it additionally writes it onto the mode-specific data block: - - `mode: "payment"` → `payment_intent_data.metadata` - - `mode: "subscription"` → `subscription_data.metadata` - - `mode: "setup"` → `setup_intent_data.metadata` - - Only the block matching the selected mode is set; the others are left unset so Stripe does not reject the call. When `metadata` is not provided, no mode-specific `*_data` block is set. -37. `getActivePromotionCode(code)` calls `promotionCodes.list({ active: true, code })` and returns only the first matching `Stripe.PromotionCode` (or `undefined` when there is no match). +30. `ROUTE_STRIPE_WEBHOOK` is exported as the string `/payment/webhook`. diff --git a/packages/stripe/GUIDE.md b/packages/stripe/GUIDE.md index 38a65d1bd..60544da49 100644 --- a/packages/stripe/GUIDE.md +++ b/packages/stripe/GUIDE.md @@ -9,14 +9,16 @@ This guide assumes familiarity with Fastify and the [Stripe Node SDK](https://gi ### For package consumers ```bash -npm install @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config stripe fastify fastify-plugin +npm install @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config fastify fastify-plugin ``` ```bash -pnpm add @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config stripe fastify fastify-plugin +pnpm add @prefabs.tech/fastify-stripe @prefabs.tech/fastify-config fastify fastify-plugin ``` -Peer dependencies enforced by `package.json`: `fastify >= 5.2.2`, `fastify-plugin >= 5.0.1`, `@prefabs.tech/fastify-config 0.94.0`, `stripe >= 20.3.0`. +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 @@ -85,7 +87,7 @@ The official [Stripe Node SDK](https://github.com/stripe/stripe-node) for talkin 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 uses `createVerifyStripeSignature(stripeConfig)`, which calls `stripe.webhooks.constructEvent(rawBody, signature, secret)` with the resolved `StripeConfig` and attaches the result to the request. +- **`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. @@ -95,7 +97,7 @@ This package exposes the SDK partially: - 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 `fastify.config.stripe` and `request.rawBody` are typed without manual declaration. +- Module augmentations so `ApiConfig.stripe`, `request.rawBody`, and `request.stripeEvent` are typed without manual declaration. --- @@ -177,16 +179,16 @@ const handleWebhook = async ( } ``` -### Signature verification (`createVerifyStripeSignature`) +### Signature verification (webhook `preHandler`) -The webhook route runs the preHandler returned by `createVerifyStripeSignature(resolvedStripeConfig)` before invoking your handler. It validates the `stripe-signature` header against `resolvedStripeConfig.webhookSecret` using `stripe.webhooks.constructEvent`. All failures return HTTP 400 with a `{ error }` body and log an error line: +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" }` | +| `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`: @@ -199,6 +201,8 @@ function readEvent(request: FastifyRequest) { } ``` +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. @@ -338,12 +342,12 @@ if (promo) { ### Module augmentations -Importing this package's default export brings two ambient TypeScript augmentations into scope: +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 either — just import the plugin once in your entry file. +You do not need to redeclare these fields — import the package in your entry file so the augmentations apply. --- diff --git a/packages/stripe/README.md b/packages/stripe/README.md deleted file mode 100644 index 752b42eeb..000000000 --- a/packages/stripe/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# @prefabs.tech/fastify-stripe - -A [Fastify](https://github.com/fastify/fastify) plugin that integrates Stripe for payment processing. - -## Features - -- **Stripe Checkout Sessions**: Create Stripe checkout sessions for one-time payments -- **Webhook Handling**: Built-in webhook endpoint with signature verification -- **Custom Webhook Handlers**: Support for custom webhook event handlers -- **Configurable Routes**: Customizable webhook endpoint paths - -## Requirements - -Peer dependencies (install explicitly in your app): `fastify`, `fastify-plugin`, `@prefabs.tech/fastify-config`, and `stripe`. See `package.json` for supported version ranges. - -## Usage - -### Register Plugin - -Register the stripe plugin with your Fastify instance: - -```typescript -import stripePlugin from "@prefabs.tech/fastify-stripe"; -import configPlugin from "@prefabs.tech/fastify-config"; -import Fastify from "fastify"; - -import config from "./config"; - -const start = async () => { - // Create fastify instance - const fastify = Fastify({ - logger: config.logger, - }); - - // Register fastify-config plugin - await fastify.register(configPlugin, { config }); - - // Register stripe plugin (pass the same object as `config.stripe` — same idea as - // `register(mailerPlugin, config.mailer)` / `register(graphqlPlugin, config.graphql)`) - await fastify.register(stripePlugin, config.stripe); - - await fastify.listen({ - port: config.port, - host: "0.0.0.0", - }); -}; - -start(); -``` - -You must pass the Stripe options object as the second argument to `register` (typically `config.stripe`), matching `@prefabs.tech/fastify-mailer`, `@prefabs.tech/fastify-slonik`, and other prefabs plugins. Omitting options or passing `{}` throws at registration time. - -If a service does not use Stripe, do not register this plugin (you may still omit `stripe` from `ApiConfig` for `StripeClient` usage elsewhere). - -## Configuration - -Add Stripe configuration to your config: - -```typescript -import type { ApiConfig } from "@prefabs.tech/fastify-config"; - -const config: ApiConfig = { - // ...other config - stripe: { - apiKey: "sk_test_...", - defaultCurrency: "usd", - enablePaymentWebhook: true, - webhookPath: "/payment/webhook", // Optional, defaults to "/payment/webhook" - webhookSecret: "whsec_...", - allowPromotionCodes: true, // Optional, enables promotion code support - clientConfig: {}, // Optional, custom Stripe client configuration - urls: { - cancel: "https://your-domain.com/cancel", - success: "https://your-domain.com/success", - }, - handlers: { - webhook: async (request, event) => { - // Handle Stripe events - switch (event.type) { - case "checkout.session.completed": - // Handle successful checkout - break; - case "payment_intent.succeeded": - // Handle successful payment - break; - // ... handle other events - } - }, - }, - }, -}; -``` - - -## Using the Stripe Client - -The package exports a `StripeClient` class for creating checkout sessions: - -```typescript -import { StripeClient } from "@prefabs.tech/fastify-stripe"; - -// Initialize the client with your config -const stripeClient = new StripeClient(config); - -// Create a checkout session -const session = await stripeClient.createCheckoutSession({ - productName: "Premium Subscription", - unitAmount: 2999, // Amount in cents ($29.99) - quantity: 1, - currency: "usd", // Optional, uses defaultCurrency if not provided - mode: "payment", // Optional: "payment" | "subscription" | "setup" - successUrl: "https://your-domain.com/success", // Optional, uses config if not provided - cancelUrl: "https://your-domain.com/cancel", // Optional, uses config if not provided -}, { - // Optional metadata - userId: "user_123", - orderId: "order_456", -}); -``` - -### Custom Webhook Handler - -When `enablePaymentWebhook` is `true` you should provide a `handlers.webhook` function to process Stripe events. If you don't, 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 — so Stripe does not retry the delivery indefinitely, but the misconfiguration is loud in the logs. - -```typescript -import type { FastifyRequest } from "fastify"; -import type { StripeEvent } from "@prefabs.tech/fastify-stripe"; - -const customWebhookHandler = async ( - request: FastifyRequest, - event: StripeEvent, -): Promise => { - switch (event.type) { - case "checkout.session.completed": { - const session = event.data.object; - // Fulfill the purchase - break; - } - case "payment_intent.succeeded": { - const paymentIntent = event.data.object; - // Handle successful payment - break; - } - case "payment_intent.payment_failed": { - const paymentIntent = event.data.object; - // Handle failed payment - break; - } - default: - console.log(`Unhandled event type: ${event.type}`); - } -}; - -// Add to config -const config: ApiConfig = { - stripe: { - // ...other config - urls: { - cancel: "https://your-domain.com/cancel", - success: "https://your-domain.com/success", - }, - handlers: { - webhook: customWebhookHandler, - }, - }, -}; -``` - -## API Version - -This package does not pin a Stripe API version — it forwards `config.stripe.clientConfig` (including `apiVersion`) unmodified to the [`Stripe`](https://github.com/stripe/stripe-node) constructor. When `apiVersion` is omitted, the default pinned by the installed `stripe` SDK is used. For production deployments, pin `apiVersion` explicitly via `clientConfig.apiVersion` so SDK upgrades don't silently change the API version: - -```typescript -const config: ApiConfig = { - stripe: { - apiKey: process.env.STRIPE_API_KEY!, - // ... - clientConfig: { - apiVersion: "2024-11-20.acacia", - }, - }, -}; -``` diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 2c1548368..3b6b883ed 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -30,6 +30,9 @@ "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", @@ -40,7 +43,6 @@ "fastify": "5.8.5", "fastify-plugin": "5.1.0", "prettier": "3.8.3", - "stripe": "20.3.1", "typescript": "5.9.3", "vite": "6.4.2", "vitest": "3.2.4" @@ -48,8 +50,7 @@ "peerDependencies": { "@prefabs.tech/fastify-config": "0.94.0", "fastify": ">=5.2.2", - "fastify-plugin": ">=5.0.1", - "stripe": ">=20.3.0" + "fastify-plugin": ">=5.0.1" }, "engines": { "node": ">=20" diff --git a/packages/stripe/vite.config.ts b/packages/stripe/vite.config.ts index 37bb260dc..750cf9dae 100644 --- a/packages/stripe/vite.config.ts +++ b/packages/stripe/vite.config.ts @@ -2,7 +2,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig, loadEnv } from "vite"; -import { peerDependencies } from "./package.json"; +import { dependencies, peerDependencies } from "./package.json"; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { @@ -17,7 +17,10 @@ export default defineConfig(({ mode }) => { name: "PrefabsTechFastifyStripe", }, rollupOptions: { - external: Object.keys(peerDependencies), + external: [ + ...Object.keys(dependencies), + ...Object.keys(peerDependencies), + ], output: { exports: "named", globals: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f958f9d5..1b829f338 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -482,6 +482,10 @@ importers: 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 @@ -510,9 +514,6 @@ importers: prettier: specifier: 3.8.3 version: 3.8.3 - stripe: - specifier: 20.3.1 - version: 20.3.1(@types/node@24.10.15) typescript: specifier: 5.9.3 version: 5.9.3 From d6c9a6e0a2c61e0efdf85eacddf07dc3ebb7d093 Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Thu, 14 May 2026 15:25:00 +0545 Subject: [PATCH 13/16] feat(stripe): fall back to fastify.config.stripe for empty options Warn when register omits options or passes {}, use fastify.config.stripe when present, update ANALYSIS/FEATURES/GUIDE, and extend plugin tests. --- packages/stripe/ANALYSIS.md | 2 +- packages/stripe/FEATURES.md | 2 +- packages/stripe/GUIDE.md | 6 +-- packages/stripe/src/__test__/plugin.test.ts | 50 ++++++++++++++++++++- packages/stripe/src/plugin.ts | 12 ++++- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md index f60a57647..92a548207 100644 --- a/packages/stripe/ANALYSIS.md +++ b/packages/stripe/ANALYSIS.md @@ -80,7 +80,7 @@ ### Conditional branches & defaults -- Empty/missing plugin options → throw `"Missing stripe configuration..."`. +- Empty/missing plugin options → warn; resolve from `fastify.config.stripe` when set, otherwise 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`. diff --git a/packages/stripe/FEATURES.md b/packages/stripe/FEATURES.md index 139440231..d71a4c0c7 100644 --- a/packages/stripe/FEATURES.md +++ b/packages/stripe/FEATURES.md @@ -3,7 +3,7 @@ ## Plugin registration 1. The default export is wrapped with `fastify-plugin` so the plugin applies to the parent Fastify scope. -2. Registration throws with a clear message if options are missing or an empty object (`Missing stripe configuration. Did you forget to pass it to the stripe plugin?`). +2. If plugin options are missing or an empty object, a warn recommends passing options explicitly; registration then uses `fastify.config.stripe` when present. If `fastify.config.stripe` is also absent, 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. diff --git a/packages/stripe/GUIDE.md b/packages/stripe/GUIDE.md index 60544da49..d10ce0468 100644 --- a/packages/stripe/GUIDE.md +++ b/packages/stripe/GUIDE.md @@ -68,7 +68,7 @@ await fastify.register(stripePlugin, config.stripe); await fastify.listen({ port: 3000, host: "0.0.0.0" }); ``` -You must pass `config.stripe` as the second argument to `register`, like `@prefabs.tech/fastify-mailer` / `@prefabs.tech/fastify-slonik`. Omitting options or passing `{}` throws with `"Missing stripe configuration. Did you forget to pass it to the stripe plugin?"` — the same idea as an empty-options registration to those plugins. +Recommended: pass `config.stripe` as the second argument to `register`, like `@prefabs.tech/fastify-mailer` / `@prefabs.tech/fastify-graphql`. If you omit options or pass `{}`, the plugin logs a warning and reads `fastify.config.stripe` (after `@prefabs.tech/fastify-config` has run). If `config.stripe` is missing in that case, registration throws 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. @@ -103,11 +103,11 @@ This package exposes the SDK partially: ## Features -### Plugin registration and required options +### Plugin registration and options `stripePlugin` is `fastify-plugin`-wrapped, so its decorations attach to the top-level Fastify instance. -This plugin expects `register(stripePlugin, config.stripe)`. Do not call `register(stripePlugin)` with no second argument or with `{}` — registration will reject with the same style of `"Missing stripe…"` error as other prefabs plugins on an invalid empty-options registration. +Prefer `register(stripePlugin, config.stripe)`. You may call `register(stripePlugin)` or `register(stripePlugin, {})` after the config plugin has decorated `fastify.config`; the Stripe plugin will then use `fastify.config.stripe` (with a warning). Empty-options registration without `config.stripe` fails with the same `"Missing stripe…"` message as `@prefabs.tech/fastify-mailer` when mailer config is missing. Services that do not use Stripe should **not** register this plugin (you may omit `stripe` from `ApiConfig` entirely on those services). diff --git a/packages/stripe/src/__test__/plugin.test.ts b/packages/stripe/src/__test__/plugin.test.ts index fd4d1bc79..f143b7079 100644 --- a/packages/stripe/src/__test__/plugin.test.ts +++ b/packages/stripe/src/__test__/plugin.test.ts @@ -32,19 +32,65 @@ describe("stripePlugin — missing configuration", async () => { await fastify.close(); }); - it("throws when register is called without options", async () => { + 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", async () => { + 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?", ); }); }); +describe("stripePlugin — fastify.config.stripe fallback", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: { level: "silent" } }); + fastify.decorate("config", { + stripe: createStripeConfig({ enablePaymentWebhook: true }), + } as unknown as FastifyInstance["config"]); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("warns and uses fastify.config.stripe when register is called without options", async () => { + const warnSpy = vi.spyOn(fastify.log, "warn"); + + await fastify.register(plugin); + await fastify.ready(); + + expect(warnSpy).toHaveBeenCalledWith( + "The stripe plugin now recommends passing stripe options directly to the plugin.", + ); + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + true, + ); + }); + + it("warns and uses fastify.config.stripe when register is called with {}", async () => { + const warnSpy = vi.spyOn(fastify.log, "warn"); + + await fastify.register(plugin, {} as StripeConfig); + await fastify.ready(); + + expect(warnSpy).toHaveBeenCalledWith( + "The stripe plugin now recommends passing stripe options directly to the plugin.", + ); + expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( + true, + ); + }); +}); + describe("stripePlugin — configuration present", async () => { const { default: plugin } = await import("../plugin"); diff --git a/packages/stripe/src/plugin.ts b/packages/stripe/src/plugin.ts index 47ec7610d..33eb91e26 100644 --- a/packages/stripe/src/plugin.ts +++ b/packages/stripe/src/plugin.ts @@ -13,9 +13,17 @@ const plugin: FastifyPluginAsync = async ( 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?", + fastify.log.warn( + "The stripe plugin now recommends passing stripe options directly to the plugin.", ); + + if (!fastify.config?.stripe) { + throw new Error( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", + ); + } + + options = fastify.config.stripe; } if (options.enablePaymentWebhook) { From 39278f50dc8bc472c1f59adcf2b81aa857de03b8 Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Thu, 14 May 2026 20:59:45 +0545 Subject: [PATCH 14/16] refactor(stripe): require explicit register options Remove fastify.config.stripe fallback when options are omitted or empty. Update ANALYSIS, FEATURES, GUIDE, and plugin tests accordingly. --- packages/stripe/ANALYSIS.md | 2 +- packages/stripe/FEATURES.md | 2 +- packages/stripe/GUIDE.md | 4 +- packages/stripe/src/__test__/plugin.test.ts | 46 +++------------------ packages/stripe/src/plugin.ts | 12 +----- 5 files changed, 12 insertions(+), 54 deletions(-) diff --git a/packages/stripe/ANALYSIS.md b/packages/stripe/ANALYSIS.md index 92a548207..f60a57647 100644 --- a/packages/stripe/ANALYSIS.md +++ b/packages/stripe/ANALYSIS.md @@ -80,7 +80,7 @@ ### Conditional branches & defaults -- Empty/missing plugin options → warn; resolve from `fastify.config.stripe` when set, otherwise throw `"Missing stripe configuration..."`. +- 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`. diff --git a/packages/stripe/FEATURES.md b/packages/stripe/FEATURES.md index d71a4c0c7..0a91fb0ba 100644 --- a/packages/stripe/FEATURES.md +++ b/packages/stripe/FEATURES.md @@ -3,7 +3,7 @@ ## 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, a warn recommends passing options explicitly; registration then uses `fastify.config.stripe` when present. If `fastify.config.stripe` is also absent, registration throws with a clear message (`Missing stripe configuration. Did you forget to pass it to the stripe plugin?`). +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. diff --git a/packages/stripe/GUIDE.md b/packages/stripe/GUIDE.md index d10ce0468..753496da8 100644 --- a/packages/stripe/GUIDE.md +++ b/packages/stripe/GUIDE.md @@ -68,7 +68,7 @@ await fastify.register(stripePlugin, config.stripe); await fastify.listen({ port: 3000, host: "0.0.0.0" }); ``` -Recommended: pass `config.stripe` as the second argument to `register`, like `@prefabs.tech/fastify-mailer` / `@prefabs.tech/fastify-graphql`. If you omit options or pass `{}`, the plugin logs a warning and reads `fastify.config.stripe` (after `@prefabs.tech/fastify-config` has run). If `config.stripe` is missing in that case, registration throws with `"Missing stripe configuration. Did you forget to pass it to the stripe plugin?"`. +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. @@ -107,7 +107,7 @@ This package exposes the SDK partially: `stripePlugin` is `fastify-plugin`-wrapped, so its decorations attach to the top-level Fastify instance. -Prefer `register(stripePlugin, config.stripe)`. You may call `register(stripePlugin)` or `register(stripePlugin, {})` after the config plugin has decorated `fastify.config`; the Stripe plugin will then use `fastify.config.stripe` (with a warning). Empty-options registration without `config.stripe` fails with the same `"Missing stripe…"` message as `@prefabs.tech/fastify-mailer` when mailer config is missing. +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). diff --git a/packages/stripe/src/__test__/plugin.test.ts b/packages/stripe/src/__test__/plugin.test.ts index f143b7079..5149be3fb 100644 --- a/packages/stripe/src/__test__/plugin.test.ts +++ b/packages/stripe/src/__test__/plugin.test.ts @@ -43,51 +43,17 @@ describe("stripePlugin — missing configuration", async () => { "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", ); }); -}); - -describe("stripePlugin — fastify.config.stripe fallback", async () => { - const { default: plugin } = await import("../plugin"); - - let fastify: FastifyInstance; - beforeEach(() => { - vi.clearAllMocks(); - fastify = Fastify({ logger: { level: "silent" } }); - fastify.decorate("config", { + 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"]); - }); - afterEach(async () => { - await fastify.close(); - }); - - it("warns and uses fastify.config.stripe when register is called without options", async () => { - const warnSpy = vi.spyOn(fastify.log, "warn"); - - await fastify.register(plugin); - await fastify.ready(); - - expect(warnSpy).toHaveBeenCalledWith( - "The stripe plugin now recommends passing stripe options directly to the plugin.", - ); - expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( - true, - ); - }); - - it("warns and uses fastify.config.stripe when register is called with {}", async () => { - const warnSpy = vi.spyOn(fastify.log, "warn"); - - await fastify.register(plugin, {} as StripeConfig); - await fastify.ready(); - - expect(warnSpy).toHaveBeenCalledWith( - "The stripe plugin now recommends passing stripe options directly to the plugin.", - ); - expect(fastify.hasRoute({ method: "POST", url: "/payment/webhook" })).toBe( - true, + await expect(app.register(plugin)).rejects.toThrow( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", ); + await app.close(); }); }); diff --git a/packages/stripe/src/plugin.ts b/packages/stripe/src/plugin.ts index 33eb91e26..47ec7610d 100644 --- a/packages/stripe/src/plugin.ts +++ b/packages/stripe/src/plugin.ts @@ -13,17 +13,9 @@ const plugin: FastifyPluginAsync = async ( fastify.log.info("Registering Stripe plugin"); if (!options || Object.keys(options).length === 0) { - fastify.log.warn( - "The stripe plugin now recommends passing stripe options directly to the plugin.", + throw new Error( + "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", ); - - if (!fastify.config?.stripe) { - throw new Error( - "Missing stripe configuration. Did you forget to pass it to the stripe plugin?", - ); - } - - options = fastify.config.stripe; } if (options.enablePaymentWebhook) { From 33a3571572a441d9de9ceda5f9ce8d920b41041c Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Thu, 14 May 2026 21:01:17 +0545 Subject: [PATCH 15/16] docs(stripe): add package developer README --- packages/stripe/README.md | 96 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/stripe/README.md 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. From 4e8adac2b278c39fca1965cefb1c57387cd11d36 Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Mon, 8 Jun 2026 10:41:23 +0545 Subject: [PATCH 16/16] chore: refresh pnpm-lock after fastify-config registry resolution --- pnpm-lock.yaml | 75 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b1cc8037..f07d2a40e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -492,7 +492,7 @@ importers: 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: link:../config + version: 0.94.0(fastify-plugin@5.1.0)(fastify@5.8.5) '@prefabs.tech/tsconfig': specifier: 0.7.0 version: 0.7.0 @@ -1615,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: @@ -1622,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==} @@ -5138,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'} @@ -6554,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 @@ -7210,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 @@ -7240,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 @@ -7247,6 +7294,8 @@ snapshots: transitivePeerDependencies: - pg-native + '@prefabs.tech/tsconfig@0.7.0': {} + '@prefabs.tech/tsconfig@0.8.0': {} '@protobufjs/aspromise@1.1.2': @@ -8502,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: {} @@ -8714,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: @@ -8840,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: @@ -9406,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: @@ -10764,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: {} @@ -11368,8 +11417,6 @@ snapshots: semver@7.7.1: {} - semver@7.7.3: {} - semver@7.7.4: {} serialize-error@8.1.0: