From 3f816bae031da421934fb69719ecf58996dd0b9f Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Wed, 3 Dec 2025 17:24:03 -0300 Subject: [PATCH 1/9] fix: support stripe 20 --- packages/stripe/package.json | 6 +++--- packages/stripe/src/stripe.decorators.ts | 7 +++++-- packages/stripe/src/stripe.module.ts | 2 +- pnpm-lock.yaml | 14 +++++++------- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 6618829cc..7f465c361 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -39,10 +39,10 @@ "@golevelup/nestjs-discovery": "workspace:^" }, "devDependencies": { - "stripe": "^18.2.1" + "stripe": "^20.0.0" }, "peerDependencies": { - "stripe": "^18.2.1" + "stripe": "^20.0.0" }, "jest": { "moduleFileExtensions": [ @@ -59,4 +59,4 @@ "testEnvironment": "node" }, "gitHead": "6f97aab8ce9d65dc074750a3ee467ec5ff3b9908" -} +} \ No newline at end of file diff --git a/packages/stripe/src/stripe.decorators.ts b/packages/stripe/src/stripe.decorators.ts index 4a25fb105..0bdc20a21 100644 --- a/packages/stripe/src/stripe.decorators.ts +++ b/packages/stripe/src/stripe.decorators.ts @@ -19,8 +19,11 @@ export const InjectStripeClient = () => Inject(STRIPE_CLIENT_TOKEN); /** * Binds the decorated service method as a handler for incoming Stripe Webhook events. * Events will be automatically routed here based on their event type property - * @param config The configuration for this handler + * + * @param eventType The Stripe event type to bind the handler to (either normal or thin events) */ export const StripeWebhookHandler = ( - eventType: Stripe.WebhookEndpointCreateParams.EnabledEvent, + eventType: + | Stripe.WebhookEndpointCreateParams.EnabledEvent + | Stripe.V2.Core.Event['type'], ) => SetMetadata(STRIPE_WEBHOOK_HANDLER, eventType); diff --git a/packages/stripe/src/stripe.module.ts b/packages/stripe/src/stripe.module.ts index 5106ffe78..a8feb9d97 100644 --- a/packages/stripe/src/stripe.module.ts +++ b/packages/stripe/src/stripe.module.ts @@ -47,7 +47,7 @@ import { StripeWebhookService } from './stripe.webhook.service'; useFactory: ({ apiKey, typescript = true, - apiVersion = '2025-05-28.basil', + apiVersion = '2025-11-17.clover', ...options }: StripeModuleConfig): Stripe => { return new Stripe(apiKey, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecbe4b21f..93239639b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,8 +279,8 @@ importers: version: link:../discovery devDependencies: stripe: - specifier: ^18.2.1 - version: 18.2.1(@types/node@22.16.5) + specifier: ^20.0.0 + version: 20.0.0(@types/node@22.16.5) packages/testing/ts-jest: devDependencies: @@ -5255,11 +5255,11 @@ packages: strip-literal@2.1.0: resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==} - stripe@18.2.1: - resolution: {integrity: sha512-GwB1B7WSwEBzW4dilgyJruUYhbGMscrwuyHsPUmSRKrGHZ5poSh2oU9XKdii5BFVJzXHn35geRvGJ6R8bYcp8w==} - engines: {node: '>=12.*'} + stripe@20.0.0: + resolution: {integrity: sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==} + engines: {node: '>=16'} peerDependencies: - '@types/node': '>=12.x.x' + '@types/node': '>=16' peerDependenciesMeta: '@types/node': optional: true @@ -11797,7 +11797,7 @@ snapshots: js-tokens: 9.0.0 optional: true - stripe@18.2.1(@types/node@22.16.5): + stripe@20.0.0(@types/node@22.16.5): dependencies: qs: 6.14.0 optionalDependencies: From 828efd86dc9fd94ad9d635f65b7fb3d2f7ca54df Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Wed, 3 Dec 2025 17:30:36 -0300 Subject: [PATCH 2/9] docs --- docs/modules/stripe.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/modules/stripe.md b/docs/modules/stripe.md index 39dbad0da..9f5067623 100644 --- a/docs/modules/stripe.md +++ b/docs/modules/stripe.md @@ -136,6 +136,14 @@ class PaymentCreatedService { handlePaymentIntentCreated(evt: Stripe.PaymentIntentPaymentCreatedEvent) { // execute your custom business logic } + + @StripeWebhookHandler('v1.billing.meter.no_meter_found') + handleBillingMeterNoMeterFound( + nft: Stripe.Events.V1BillingMeterNoMeterFoundEventNotification, + ) { + const event = await nft.fetchEvent(); + // execute your custom business logic + } } ``` From 829621abd5ff2e66cead490e03a982642faa4ca1 Mon Sep 17 00:00:00 2001 From: Arthur Fiorette <47537704+arthurfiorette@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:45:25 -0300 Subject: [PATCH 3/9] Change handleBillingMeterNoMeterFound to async --- docs/modules/stripe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/stripe.md b/docs/modules/stripe.md index 9f5067623..202a600d4 100644 --- a/docs/modules/stripe.md +++ b/docs/modules/stripe.md @@ -138,7 +138,7 @@ class PaymentCreatedService { } @StripeWebhookHandler('v1.billing.meter.no_meter_found') - handleBillingMeterNoMeterFound( + async handleBillingMeterNoMeterFound( nft: Stripe.Events.V1BillingMeterNoMeterFoundEventNotification, ) { const event = await nft.fetchEvent(); From f6af5ebdbb6b660c04fbf067f1332e70bffdd0e9 Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Mon, 15 Dec 2025 16:53:58 -0300 Subject: [PATCH 4/9] code --- docs/modules/stripe.md | 75 ++++- packages/stripe/src/stripe.constants.ts | 4 + packages/stripe/src/stripe.decorators.ts | 17 +- packages/stripe/src/stripe.interfaces.ts | 18 +- packages/stripe/src/stripe.module.ts | 187 +++++++++---- packages/stripe/src/stripe.payload.service.ts | 93 ++++--- .../stripe/src/stripe.webhook.controller.ts | 22 +- packages/stripe/src/stripe.webhook.service.ts | 8 +- .../src/tests/stripe.webhook.e2e.spec.ts | 259 +++++++++++++++++- 9 files changed, 575 insertions(+), 108 deletions(-) diff --git a/docs/modules/stripe.md b/docs/modules/stripe.md index 202a600d4..c662e8faa 100644 --- a/docs/modules/stripe.md +++ b/docs/modules/stripe.md @@ -34,19 +34,25 @@ pnpm add @golevelup/nestjs-stripe stripe - 🔒 Automatically validates that the event payload was actually sent from Stripe using the configured webhook signing secret -- 🕵️ Discovers providers from your application decorated with `StripeWebhookHandler` and routes incoming events to them +- 🕵️ Discovers providers from your application decorated with `StripeWebhookHandler` and `StripeThinWebhookHandler` and routes incoming events to them - 🧭 Route events to logical services easily simply by providing the Stripe webhook event type +- ⚡ Support for both traditional snapshot events and Stripe's new v2 thin events + ## Import -Import and add `StripeModule` to the `imports` section of the consuming module (most likely `AppModule`). Your Stripe API key is required, and you can optionally include a webhook configuration if you plan on consuming Stripe webhook events inside your app. -Stripe secrets you can get from your Dashboard’s [Webhooks settings](https://dashboard.stripe.com/webhooks). Select an endpoint that you want to obtain the secret for, then click the Reveal link below "Signing secret". +Import and add `StripeModule` to the `imports` section of the consuming module (most likely `AppModule`). Your Stripe API key is required, and you can optionally include a webhook configuration if you plan on consuming Stripe webhook events inside your app. +Stripe secrets you can get from your Dashboard's [Webhooks settings](https://dashboard.stripe.com/webhooks). Select an endpoint that you want to obtain the secret for, then click the Reveal link below "Signing secret". -`account` - The webhook secret registered in the Stripe Dashboard for events on your accounts -`account_test` - The webhook secret registered in the Stripe Dashboard for events on your accounts in test mode +**Snapshot Events (Traditional Webhooks):** +`account` - The webhook secret registered in the Stripe Dashboard for events on your accounts +`accountTest` - The webhook secret registered in the Stripe Dashboard for events on your accounts in test mode `connect` - The webhook secret registered in the Stripe Dashboard for events on Connected accounts -`connect_test` - The webhook secret registered in the Stripe Dashboard for events on Connected accounts in test mode +`connectTest` - The webhook secret registered in the Stripe Dashboard for events on Connected accounts in test mode + +**Thin Events (V2 Webhooks - Optional):** +`stripeThinSecrets` - Separate secrets for Stripe's v2 thin events (only required if using `@StripeThinWebhookHandler`) ```typescript import { StripeModule } from '@golevelup/nestjs-stripe'; @@ -56,12 +62,20 @@ import { StripeModule } from '@golevelup/nestjs-stripe'; StripeModule.forRoot({ apiKey: 'sk_***', webhookConfig: { + // Snapshot event secrets stripeSecrets: { account: 'whsec_***', accountTest: 'whsec_***', connect: 'whsec_***', connectTest: 'whsec_***', }, + // Thin event secrets (optional) + stripeThinSecrets: { + account: 'whsec_thin_***', + accountTest: 'whsec_thin_***', + connect: 'whsec_thin_***', + connectTest: 'whsec_thin_***', + }, }, }), ], @@ -129,6 +143,10 @@ Exposing provider/service methods to be used for processing Stripe events is eas [Review the Stripe documentation](https://stripe.com/docs/api/events/types) for more information about the types of events available. +#### Snapshot Events (Traditional) + +Use `@StripeWebhookHandler` for traditional Stripe webhook events. These include the full event object in the payload. + ```typescript @Injectable() class PaymentCreatedService { @@ -136,17 +154,52 @@ class PaymentCreatedService { handlePaymentIntentCreated(evt: Stripe.PaymentIntentPaymentCreatedEvent) { // execute your custom business logic } +} +``` + +**Webhook URL:** `https://your-domain.com/stripe/webhook` + +#### Thin Events (V2) - @StripeWebhookHandler('v1.billing.meter.no_meter_found') - async handleBillingMeterNoMeterFound( - nft: Stripe.Events.V1BillingMeterNoMeterFoundEventNotification, - ) { - const event = await nft.fetchEvent(); +Use `@StripeThinWebhookHandler` for Stripe's v2 thin events. These are lightweight events that only include metadata - you'll need to fetch the full object separately. + +```typescript +@Injectable() +class BillingService { + constructor(@InjectStripeClient() private stripe: Stripe) {} + + @StripeThinWebhookHandler('v1.billing.meter.error_report_triggered') + async handleBillingMeterError(evt: Stripe.V2.Core.Event) { + // Thin events require fetching the full object + const meter = await this.stripe.v2.billing.meters.retrieve( + evt.related_object.id + ); // execute your custom business logic } } ``` +**Webhook URL:** `https://your-domain.com/stripe/webhook?mode=thin` + +#### Wildcard Handlers + +You can use `'*'` to handle all events of a specific type: + +```typescript +@Injectable() +class WebhookLogger { + @StripeWebhookHandler('*') + logAllSnapshotEvents(evt: Stripe.Event) { + console.log('Received snapshot event:', evt.type); + } + + @StripeThinWebhookHandler('*') + logAllThinEvents(evt: Stripe.V2.Core.Event) { + console.log('Received thin event:', evt.type); + } +} +``` + ### Webhook Controller Decorators You can also pass any class decorator to the `decorators` property of the `webhookConfig` object as a part of the module configuration. This could be used in situations like when using the `@nestjs/throttler` package and needing to apply the `@SkipThrottle()` decorator, or when you have a global guard but need to skip routes with certain metadata. diff --git a/packages/stripe/src/stripe.constants.ts b/packages/stripe/src/stripe.constants.ts index 58a520379..2fcfa6a8b 100644 --- a/packages/stripe/src/stripe.constants.ts +++ b/packages/stripe/src/stripe.constants.ts @@ -2,6 +2,10 @@ export const STRIPE_CLIENT_TOKEN = Symbol('STRIPE_CLIENT_TOKEN'); export const STRIPE_WEBHOOK_HANDLER = Symbol('STRIPE_WEBHOOK_HANDLER'); +export const STRIPE_THIN_WEBHOOK_HANDLER = Symbol( + 'STRIPE_THIN_WEBHOOK_HANDLER', +); + export const STRIPE_WEBHOOK_SERVICE = Symbol('STRIPE_WEBHOOK_SERVICE'); export const STRIPE_WEBHOOK_CONTEXT_TYPE = 'stripe_webhook'; diff --git a/packages/stripe/src/stripe.decorators.ts b/packages/stripe/src/stripe.decorators.ts index 0bdc20a21..431d5556c 100644 --- a/packages/stripe/src/stripe.decorators.ts +++ b/packages/stripe/src/stripe.decorators.ts @@ -4,6 +4,7 @@ import { MODULE_OPTIONS_TOKEN } from './stripe-module-definition'; import { STRIPE_CLIENT_TOKEN, STRIPE_WEBHOOK_HANDLER, + STRIPE_THIN_WEBHOOK_HANDLER, } from './stripe.constants'; /** @@ -20,10 +21,18 @@ export const InjectStripeClient = () => Inject(STRIPE_CLIENT_TOKEN); * Binds the decorated service method as a handler for incoming Stripe Webhook events. * Events will be automatically routed here based on their event type property * - * @param eventType The Stripe event type to bind the handler to (either normal or thin events) + * @param eventType The Stripe event type to bind the handler to */ export const StripeWebhookHandler = ( - eventType: - | Stripe.WebhookEndpointCreateParams.EnabledEvent - | Stripe.V2.Core.Event['type'], + eventType: Stripe.WebhookEndpointCreateParams.EnabledEvent, ) => SetMetadata(STRIPE_WEBHOOK_HANDLER, eventType); + +/** + * Binds the decorated service method as a handler for incoming Stripe Thin Webhook events. + * Events will be automatically routed here based on their event type property + * + * @param eventType The Stripe thin event type to bind the handler to, or '*' for all events + */ +export const StripeThinWebhookHandler = ( + eventType: Stripe.V2.Core.Event['type'] | '*', +) => SetMetadata(STRIPE_THIN_WEBHOOK_HANDLER, eventType); diff --git a/packages/stripe/src/stripe.interfaces.ts b/packages/stripe/src/stripe.interfaces.ts index edec5bd36..8b36faedd 100644 --- a/packages/stripe/src/stripe.interfaces.ts +++ b/packages/stripe/src/stripe.interfaces.ts @@ -8,7 +8,15 @@ type RequireAtLeastOne = Pick< [K in Keys]-?: Required> & Partial>>; }[Keys]; -interface StripeSecrets { +/** + * The mode of Stripe webhook being used + */ +export enum StripeWebhookMode { + SNAPSHOT = 'snapshot', + THIN = 'thin', +} + +export interface StripeSecrets { /** * The webhook secret registered in the Stripe Dashboard for events on your accounts */ @@ -33,8 +41,16 @@ export interface StripeModuleConfig extends Partial { * Configuration for processing Stripe Webhooks */ webhookConfig?: { + /** + * Secrets for validating incoming webhook **snapshot** signatures. At least one secret must be provided. + */ stripeSecrets: RequireAtLeastOne; + /** + * Secrets for validating incoming webhook **thin** signatures. At least one secret must be provided if using thin webhooks. + */ + stripeThinSecrets?: RequireAtLeastOne; + /** * The property on the request that contains the raw message body so that it * can be validated. Defaults to 'body' diff --git a/packages/stripe/src/stripe.module.ts b/packages/stripe/src/stripe.module.ts index a8feb9d97..22f1a5b04 100644 --- a/packages/stripe/src/stripe.module.ts +++ b/packages/stripe/src/stripe.module.ts @@ -1,4 +1,8 @@ -import { DiscoveryModule, DiscoveryService } from '@golevelup/nestjs-discovery'; +import { + DiscoveryModule, + DiscoveryService, + MetaKey, +} from '@golevelup/nestjs-discovery'; import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { PATH_METADATA } from '@nestjs/common/constants'; import { ExternalContextCreator } from '@nestjs/core/helpers/external-context-creator'; @@ -13,9 +17,10 @@ import { STRIPE_WEBHOOK_CONTEXT_TYPE, STRIPE_WEBHOOK_HANDLER, STRIPE_WEBHOOK_SERVICE, + STRIPE_THIN_WEBHOOK_HANDLER, } from './stripe.constants'; import { InjectStripeModuleConfig } from './stripe.decorators'; -import { StripeModuleConfig } from './stripe.interfaces'; +import { StripeModuleConfig, StripeWebhookMode } from './stripe.interfaces'; import { StripePayloadService } from './stripe.payload.service'; import { StripeWebhookController } from './stripe.webhook.controller'; import { StripeWebhookService } from './stripe.webhook.service'; @@ -77,29 +82,45 @@ export class StripeModule super(); } - public async onModuleInit() { - // If they didn't provide a webhook config secret there's no reason - // to even attempt discovery - if (!this.stripeModuleConfig.webhookConfig) { - return; - } + private async discoverAndCreateWebhookHandlers( + metadataKey: MetaKey, + mode: StripeWebhookMode, + ) { + const eventHandlerMeta = + await this.discover.providerMethodsWithMetaAtKey(metadataKey); - const noOneSecretProvided = - this.stripeModuleConfig.webhookConfig && - !this.stripeModuleConfig.webhookConfig?.stripeSecrets.account && - !this.stripeModuleConfig.webhookConfig?.stripeSecrets.accountTest && - !this.stripeModuleConfig.webhookConfig?.stripeSecrets.connect && - !this.stripeModuleConfig.webhookConfig?.stripeSecrets.connectTest; + const grouped = groupBy( + eventHandlerMeta, + (x) => x.discoveredMethod.parentClass.name, + ); - if (noOneSecretProvided) { - const errorMessage = - 'missing stripe webhook secret. module is improperly configured and will be unable to process incoming webhooks from Stripe'; - this.logger.error(errorMessage); - throw new Error(errorMessage); - } + const webhookHandlers = flatten( + Object.keys(grouped).map((x) => { + this.logger.log( + `Registering Stripe ${mode} webhook handlers from ${x}`, + ); - this.logger.log('Initializing Stripe Module for webhooks'); + return grouped[x].map(({ discoveredMethod, meta: eventType }) => ({ + key: eventType, + handler: this.externalContextCreator.create( + discoveredMethod.parentClass.instance, + discoveredMethod.handler, + discoveredMethod.methodName, + undefined, + undefined, + undefined, + undefined, + undefined, + STRIPE_WEBHOOK_CONTEXT_TYPE, + ), + })); + }), + ); + + return { handlers: webhookHandlers, handlerMeta: eventHandlerMeta }; + } + private async getStripeWebhookService() { const [stripeWebhookService] = ( (await this.discover.providersWithMetaAtKey( STRIPE_WEBHOOK_SERVICE, @@ -113,42 +134,108 @@ export class StripeModule throw new Error('Could not find instance of Stripe Webhook Service'); } - const eventHandlerMeta = - await this.discover.providerMethodsWithMetaAtKey( + return stripeWebhookService; + } + + private validateWebhookSecrets( + hasSnapshotHandlers: boolean, + hasThinHandlers: boolean, + ) { + const noSnapshotSecretProvided = + !this.stripeModuleConfig.webhookConfig?.stripeSecrets || + Object.values(this.stripeModuleConfig.webhookConfig.stripeSecrets).filter( + Boolean, + ).length === 0; + + const noThinSecretProvided = + !this.stripeModuleConfig.webhookConfig?.stripeThinSecrets || + Object.values( + this.stripeModuleConfig.webhookConfig.stripeThinSecrets, + ).filter(Boolean).length === 0; + + // Validate snapshot secrets if snapshot handlers exist + if (hasSnapshotHandlers && noSnapshotSecretProvided) { + const errorMessage = + 'Snapshot webhook handlers found but no snapshot secrets provided. ' + + 'Please provide at least one of: stripeSecrets.account, stripeSecrets.accountTest, ' + + 'stripeSecrets.connect, or stripeSecrets.connectTest'; + + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + // Validate thin secrets if thin handlers exist + if (hasThinHandlers && noThinSecretProvided) { + const errorMessage = + 'Thin webhook handlers found but no thin secrets provided. ' + + 'Please provide at least one of: stripeThinSecrets.account, stripeThinSecrets.accountTest, ' + + 'stripeThinSecrets.connect, or stripeThinSecrets.connectTest'; + + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + // At least one type of handler must exist + if (!hasSnapshotHandlers && !hasThinHandlers) { + this.logger.warn( + 'No webhook handlers found. Stripe module initialized but will not handle any events.', + ); + + return false; + } + + return true; + } + + public async onModuleInit() { + // If they didn't provide a webhook config secret there's no reason + // to even attempt discovery + if (!this.stripeModuleConfig.webhookConfig) { + return; + } + + this.logger.log('Initializing Stripe Module for webhooks'); + + const stripeWebhookService = await this.getStripeWebhookService(); + + // Discover and create snapshot webhook handlers + const { handlers: webhookHandlers, handlerMeta: eventHandlerMeta } = + await this.discoverAndCreateWebhookHandlers( STRIPE_WEBHOOK_HANDLER, + StripeWebhookMode.SNAPSHOT, ); - const grouped = groupBy( - eventHandlerMeta, - (x) => x.discoveredMethod.parentClass.name, - ); + // Discover and create thin webhook handlers + const { handlers: thinWebhookHandlers, handlerMeta: thinEventHandlerMeta } = + await this.discoverAndCreateWebhookHandlers( + STRIPE_THIN_WEBHOOK_HANDLER, + StripeWebhookMode.THIN, + ); - const webhookHandlers = flatten( - Object.keys(grouped).map((x) => { - this.logger.log(`Registering Stripe webhook handlers from ${x}`); + // Check if handlers exist for each mode + const hasThinHandlers = thinEventHandlerMeta.length > 0; + const hasSnapshotHandlers = eventHandlerMeta.length > 0; - return grouped[x].map(({ discoveredMethod, meta: eventType }) => ({ - key: eventType, - handler: this.externalContextCreator.create( - discoveredMethod.parentClass.instance, - discoveredMethod.handler, - discoveredMethod.methodName, - undefined, // metadataKey - undefined, // paramsFactory - undefined, // contextId - undefined, // inquirerId - undefined, // options - STRIPE_WEBHOOK_CONTEXT_TYPE, // contextType - ), - })); - }), + // Validate webhook secrets + const hasHandlers = this.validateWebhookSecrets( + hasSnapshotHandlers, + hasThinHandlers, ); - const handleWebhook = async (webhookEvent: { type: string }) => { + if (!hasHandlers) { + return; + } + + const handleWebhook = async ( + webhookEvent: { type: string }, + mode: StripeWebhookMode, + ) => { const { type } = webhookEvent; - const handlers = webhookHandlers.filter( - (x) => x.key === type || x.key === '*', - ); + + // Select the correct handler list based on mode + const handlers = ( + mode === StripeWebhookMode.THIN ? thinWebhookHandlers : webhookHandlers + ).filter((x) => x.key === type || x.key === '*'); if (handlers.length) { if ( @@ -156,7 +243,7 @@ export class StripeModule ?.logMatchingEventHandlers ) { this.logger.log( - `Received webhook event for ${type}. Forwarding to ${handlers.length} event handlers`, + `Received ${mode} webhook event for ${type}. Forwarding to ${handlers.length} event handlers`, ); } await Promise.all(handlers.map((x) => x.handler(webhookEvent))); diff --git a/packages/stripe/src/stripe.payload.service.ts b/packages/stripe/src/stripe.payload.service.ts index d187c0dc0..a63da8525 100644 --- a/packages/stripe/src/stripe.payload.service.ts +++ b/packages/stripe/src/stripe.payload.service.ts @@ -5,54 +5,83 @@ import { InjectStripeClient, InjectStripeModuleConfig, } from './stripe.decorators'; -import { StripeModuleConfig } from './stripe.interfaces'; +import { + StripeModuleConfig, + StripeSecrets, + StripeWebhookMode, +} from './stripe.interfaces'; @Injectable() export class StripePayloadService { - private readonly stripeWebhookSecret: string; - private readonly stripeWebhookTestSecret: string; - private readonly stripeConnectWebhookSecret: string; - private readonly stripeConnectWebhookTestSecret: string; + private readonly stripeSecrets: StripeSecrets; + private readonly stripeThinSecrets: StripeSecrets; constructor( @InjectStripeModuleConfig() - private readonly config: StripeModuleConfig, + readonly config: StripeModuleConfig, @InjectStripeClient() - private readonly stripeClient: Stripe + private readonly stripeClient: Stripe, ) { - this.stripeWebhookSecret = - this.config.webhookConfig?.stripeSecrets.account || ''; - this.stripeWebhookTestSecret = - this.config.webhookConfig?.stripeSecrets.accountTest || ''; - this.stripeConnectWebhookSecret = - this.config.webhookConfig?.stripeSecrets.connect || ''; - this.stripeConnectWebhookTestSecret = - this.config.webhookConfig?.stripeSecrets.connectTest || ''; + this.stripeSecrets = config.webhookConfig?.stripeSecrets || {}; + this.stripeThinSecrets = config.webhookConfig?.stripeThinSecrets || {}; } - tryHydratePayload(signature: string, payload: Buffer): { type: string } { + + tryHydratePayload( + signature: string, + payload: Buffer, + mode: StripeWebhookMode, + ): { type: string } { const decodedPayload = JSON.parse( - Buffer.isBuffer(payload) ? payload.toString('utf8') : payload + Buffer.isBuffer(payload) ? payload.toString('utf8') : payload, ); - let secretToUse: string; - if (!decodedPayload.account && decodedPayload.livemode) { - secretToUse = this.stripeWebhookSecret; - } else if (!decodedPayload.account && !decodedPayload.livemode) { - secretToUse = this.stripeWebhookTestSecret; - } else if (decodedPayload.account && decodedPayload.livemode) { - secretToUse = this.stripeConnectWebhookSecret; - } else if (decodedPayload.account && !decodedPayload.livemode) { - secretToUse = this.stripeConnectWebhookTestSecret; + const secrets = this.getWebhookSecrets(mode); + const secretToUse = this.getWebhookSecret(decodedPayload, secrets); + + switch (mode) { + case StripeWebhookMode.SNAPSHOT: + return this.stripeClient.webhooks.constructEvent( + payload, + signature, + secretToUse, + ); + + case StripeWebhookMode.THIN: + return this.stripeClient.parseEventNotification( + payload, + signature, + secretToUse, + ); + } + } + + private getWebhookSecrets(mode: StripeWebhookMode) { + switch (mode) { + case StripeWebhookMode.SNAPSHOT: + return this.stripeSecrets; + case StripeWebhookMode.THIN: + return this.stripeThinSecrets; + } + } + + private getWebhookSecret( + evt: Stripe.EventBase, + secrets: StripeSecrets, + ): string { + let secret: string | undefined; + + if (!evt.account) { + secret = evt.livemode ? secrets.account : secrets.accountTest; } else { + secret = evt.livemode ? secrets.connect : secrets.connectTest; + } + + if (!secret) { throw new Error( - 'Could not determine which secret to use for this webhook call!' + 'Could not determine which secret to use for this webhook call!', ); } - return this.stripeClient.webhooks.constructEvent( - payload, - signature, - secretToUse - ); + return secret; } } diff --git a/packages/stripe/src/stripe.webhook.controller.ts b/packages/stripe/src/stripe.webhook.controller.ts index d33774bd6..196cacbd3 100644 --- a/packages/stripe/src/stripe.webhook.controller.ts +++ b/packages/stripe/src/stripe.webhook.controller.ts @@ -1,6 +1,6 @@ -import { Controller, Headers, Post, Request } from '@nestjs/common'; +import { Controller, Headers, Post, Query, Request } from '@nestjs/common'; import { InjectStripeModuleConfig } from './stripe.decorators'; -import { StripeModuleConfig } from './stripe.interfaces'; +import { StripeModuleConfig, StripeWebhookMode } from './stripe.interfaces'; import { StripePayloadService } from './stripe.payload.service'; import { StripeWebhookService } from './stripe.webhook.service'; @@ -12,7 +12,7 @@ export class StripeWebhookController { @InjectStripeModuleConfig() config: StripeModuleConfig, private readonly stripePayloadService: StripePayloadService, - private readonly stripeWebhookService: StripeWebhookService + private readonly stripeWebhookService: StripeWebhookService, ) { this.requestBodyProperty = config.webhookConfig?.requestBodyProperty || 'body'; @@ -21,15 +21,25 @@ export class StripeWebhookController { @Post('/webhook') async handleWebhook( @Headers('stripe-signature') sig: string, - @Request() request + @Request() request, + @Query('mode') mode: StripeWebhookMode = StripeWebhookMode.SNAPSHOT, ) { if (!sig) { throw new Error('Missing stripe-signature header'); } + + if (mode !== 'thin' && mode !== 'snapshot') { + throw new Error('Invalid mode query parameter'); + } + const rawBody = request[this.requestBodyProperty]; - const event = this.stripePayloadService.tryHydratePayload(sig, rawBody); + const event = this.stripePayloadService.tryHydratePayload( + sig, + rawBody, + mode, + ); - await this.stripeWebhookService.handleWebhook(event); + await this.stripeWebhookService.handleWebhook(event, mode); } } diff --git a/packages/stripe/src/stripe.webhook.service.ts b/packages/stripe/src/stripe.webhook.service.ts index 868c74ed8..f2be09b67 100644 --- a/packages/stripe/src/stripe.webhook.service.ts +++ b/packages/stripe/src/stripe.webhook.service.ts @@ -1,11 +1,15 @@ import { Injectable, SetMetadata } from '@nestjs/common'; import { STRIPE_WEBHOOK_SERVICE } from './stripe.constants'; +import { StripeWebhookMode } from './stripe.interfaces'; @Injectable() @SetMetadata(STRIPE_WEBHOOK_SERVICE, true) export class StripeWebhookService { - public handleWebhook(evt: any): any { + public handleWebhook( + _event: any, + _mode: StripeWebhookMode, + ): void | Promise { // The implementation for this method is overridden by the containing module - console.log(evt); + throw new Error('Not implemented'); } } diff --git a/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts b/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts index 3119397ae..1dab95227 100644 --- a/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts +++ b/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts @@ -1,15 +1,23 @@ import { ConsoleLogger, INestApplication, Injectable } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import * as request from 'supertest'; -import { StripeWebhookHandler } from '../stripe.decorators'; -import { StripeModuleConfig } from '../stripe.interfaces'; +import { + StripeWebhookHandler, + StripeThinWebhookHandler, +} from '../stripe.decorators'; +import { StripeModuleConfig, StripeWebhookMode } from '../stripe.interfaces'; import { StripePayloadService } from '../stripe.payload.service'; import { StripeModule } from '../stripe.module'; const testReceiveStripeFn = jest.fn(); +const testReceiveThinStripeFn = jest.fn(); +const testReceiveWildcardFn = jest.fn(); +const testReceiveThinWildcardFn = jest.fn(); const defaultStripeWebhookEndpoint = '/stripe/webhook'; const eventType = 'payment_intent.created'; +const thinEventType = 'v1.billing.meter.error_report_triggered'; const expectedEvent = { type: eventType }; +const expectedThinEvent = { type: thinEventType }; const stripeSig = 'stripeSignatureValue'; @Injectable() @@ -30,6 +38,30 @@ class PaymentCreatedService { } } +@Injectable() +class BillingMeterService { + @StripeThinWebhookHandler(thinEventType) + handleBillingMeterError(evt: any) { + testReceiveThinStripeFn(evt); + } +} + +@Injectable() +class WildcardSnapshotService { + @StripeWebhookHandler('*') + handleAllSnapshotEvents(evt: any) { + testReceiveWildcardFn(evt); + } +} + +@Injectable() +class WildcardThinService { + @StripeThinWebhookHandler('*') + handleAllThinEvents(evt: any) { + testReceiveThinWildcardFn(evt); + } +} + type ModuleType = 'forRoot' | 'forRootAsync'; const cases: [ModuleType, string | undefined][] = [ ['forRoot', undefined], @@ -107,6 +139,7 @@ describe.each(cases)( expect(hydratePayloadFn).toHaveBeenCalledWith( stripeSig, expectedEvent, + StripeWebhookMode.SNAPSHOT, ); expect(testReceiveStripeFn).toHaveBeenCalledWith(expectedEvent); }); @@ -115,3 +148,225 @@ describe.each(cases)( afterEach(() => jest.resetAllMocks()); }, ); + +// Tests for thin webhook functionality +describe('Stripe Thin Webhooks (e2e)', () => { + let app: INestApplication; + let hydratePayloadFn: jest.SpyInstance; + + const moduleConfig: StripeModuleConfig = { + apiKey: '123', + webhookConfig: { + stripeSecrets: { + account: '123', + }, + stripeThinSecrets: { + account: 'thin-secret-123', + }, + loggingConfiguration: { + logMatchingEventHandlers: true, + }, + }, + }; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [StripeModule.forRoot(moduleConfig)], + providers: [BillingMeterService], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useLogger(new SilentLogger()); + await app.init(); + + const stripePayloadService = + app.get(StripePayloadService); + + hydratePayloadFn = jest + .spyOn(stripePayloadService, 'tryHydratePayload') + .mockImplementation((sig, buff, mode) => buff as any); + }); + + it('routes thin events to their handlers when mode=thin', () => { + return request(app.getHttpServer()) + .post(`${defaultStripeWebhookEndpoint}?mode=${StripeWebhookMode.THIN}`) + .send(expectedThinEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveThinStripeFn).toHaveBeenCalledTimes(1); + expect(hydratePayloadFn).toHaveBeenCalledTimes(1); + expect(hydratePayloadFn).toHaveBeenCalledWith( + stripeSig, + expectedThinEvent, + StripeWebhookMode.THIN, + ); + expect(testReceiveThinStripeFn).toHaveBeenCalledWith(expectedThinEvent); + }); + }); + + it('returns error for invalid mode parameter', () => { + return request(app.getHttpServer()) + .post(`${defaultStripeWebhookEndpoint}?mode=invalid`) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(500); + }); + + it('defaults to snapshot mode when mode parameter is missing', () => { + return request(app.getHttpServer()) + .post(defaultStripeWebhookEndpoint) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(hydratePayloadFn).toHaveBeenCalledWith( + stripeSig, + expectedEvent, + StripeWebhookMode.SNAPSHOT, + ); + }); + }); + + afterEach(() => jest.resetAllMocks()); +}); + +// Tests for mixed snapshot and thin handlers +describe('Stripe Mixed Snapshot and Thin Webhooks (e2e)', () => { + let app: INestApplication; + let hydratePayloadFn: jest.SpyInstance; + + const moduleConfig: StripeModuleConfig = { + apiKey: '123', + webhookConfig: { + stripeSecrets: { + account: '123', + }, + stripeThinSecrets: { + account: 'thin-secret-123', + }, + loggingConfiguration: { + logMatchingEventHandlers: true, + }, + }, + }; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [StripeModule.forRoot(moduleConfig)], + providers: [PaymentCreatedService, BillingMeterService], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useLogger(new SilentLogger()); + await app.init(); + + const stripePayloadService = + app.get(StripePayloadService); + + hydratePayloadFn = jest + .spyOn(stripePayloadService, 'tryHydratePayload') + .mockImplementation((sig, buff, mode) => buff as any); + }); + + it('routes snapshot events to snapshot handlers', () => { + return request(app.getHttpServer()) + .post(defaultStripeWebhookEndpoint) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveStripeFn).toHaveBeenCalledTimes(1); + expect(testReceiveThinStripeFn).toHaveBeenCalledTimes(0); + }); + }); + + it('routes thin events to thin handlers', () => { + return request(app.getHttpServer()) + .post(`${defaultStripeWebhookEndpoint}?mode=${StripeWebhookMode.THIN}`) + .send(expectedThinEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveThinStripeFn).toHaveBeenCalledTimes(1); + expect(testReceiveStripeFn).toHaveBeenCalledTimes(0); + }); + }); + + afterEach(() => jest.resetAllMocks()); +}); + +// Tests for wildcard handlers +describe('Stripe Wildcard Handlers (e2e)', () => { + let app: INestApplication; + + const moduleConfig: StripeModuleConfig = { + apiKey: '123', + webhookConfig: { + stripeSecrets: { + account: '123', + }, + stripeThinSecrets: { + account: 'thin-secret-123', + }, + }, + }; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [StripeModule.forRoot(moduleConfig)], + providers: [WildcardSnapshotService, WildcardThinService], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useLogger(new SilentLogger()); + await app.init(); + + const stripePayloadService = + app.get(StripePayloadService); + + jest + .spyOn(stripePayloadService, 'tryHydratePayload') + .mockImplementation((sig, buff, mode) => buff as any); + }); + + it('wildcard snapshot handler receives all snapshot events', () => { + return request(app.getHttpServer()) + .post(defaultStripeWebhookEndpoint) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveWildcardFn).toHaveBeenCalledTimes(1); + expect(testReceiveWildcardFn).toHaveBeenCalledWith(expectedEvent); + }); + }); + + it('wildcard thin handler receives all thin events', () => { + return request(app.getHttpServer()) + .post(`${defaultStripeWebhookEndpoint}?mode=${StripeWebhookMode.THIN}`) + .send(expectedThinEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveThinWildcardFn).toHaveBeenCalledTimes(1); + expect(testReceiveThinWildcardFn).toHaveBeenCalledWith( + expectedThinEvent, + ); + }); + }); + + it('wildcard handlers do not cross between modes', () => { + return request(app.getHttpServer()) + .post(defaultStripeWebhookEndpoint) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveWildcardFn).toHaveBeenCalledTimes(1); + expect(testReceiveThinWildcardFn).toHaveBeenCalledTimes(0); + }); + }); + + afterEach(() => jest.resetAllMocks()); +}); From ba07d798fbfb4867fa97c49865f9fb426b73988a Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Mon, 15 Dec 2025 16:59:43 -0300 Subject: [PATCH 5/9] chore: fix --- docs/modules/stripe.md | 18 +++++------------- packages/stripe/src/stripe.webhook.service.ts | 6 +++--- .../src/tests/stripe.webhook.e2e.spec.ts | 9 ++++----- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/docs/modules/stripe.md b/docs/modules/stripe.md index c98151ecf..6304af181 100644 --- a/docs/modules/stripe.md +++ b/docs/modules/stripe.md @@ -71,10 +71,10 @@ import { StripeModule } from '@golevelup/nestjs-stripe'; }, // Thin event secrets (optional) stripeThinSecrets: { - account: 'whsec_thin_***', - accountTest: 'whsec_thin_***', - connect: 'whsec_thin_***', - connectTest: 'whsec_thin_***', + account: 'whsec_***', + accountTest: 'whsec_***', + connect: 'whsec_***', + connectTest: 'whsec_***', }, }, }), @@ -154,14 +154,6 @@ class PaymentCreatedService { handlePaymentIntentCreated(evt: Stripe.PaymentIntentPaymentCreatedEvent) { // execute your custom business logic } - - @StripeWebhookHandler('v1.billing.meter.no_meter_found') - async handleBillingMeterNoMeterFound( - nft: Stripe.Events.V1BillingMeterNoMeterFoundEventNotification, - ) { - const event = await nft.fetchEvent(); - // execute your custom business logic - } } ``` @@ -180,7 +172,7 @@ class BillingService { async handleBillingMeterError(evt: Stripe.V2.Core.Event) { // Thin events require fetching the full object const meter = await this.stripe.v2.billing.meters.retrieve( - evt.related_object.id + evt.related_object.id, ); // execute your custom business logic } diff --git a/packages/stripe/src/stripe.webhook.service.ts b/packages/stripe/src/stripe.webhook.service.ts index f2be09b67..f895451ef 100644 --- a/packages/stripe/src/stripe.webhook.service.ts +++ b/packages/stripe/src/stripe.webhook.service.ts @@ -6,10 +6,10 @@ import { StripeWebhookMode } from './stripe.interfaces'; @SetMetadata(STRIPE_WEBHOOK_SERVICE, true) export class StripeWebhookService { public handleWebhook( - _event: any, - _mode: StripeWebhookMode, + event: any, + mode: StripeWebhookMode, ): void | Promise { // The implementation for this method is overridden by the containing module - throw new Error('Not implemented'); + console.log(Event, mode); } } diff --git a/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts b/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts index 1dab95227..ee6a1ff71 100644 --- a/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts +++ b/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts @@ -184,7 +184,7 @@ describe('Stripe Thin Webhooks (e2e)', () => { hydratePayloadFn = jest .spyOn(stripePayloadService, 'tryHydratePayload') - .mockImplementation((sig, buff, mode) => buff as any); + .mockImplementation((sig, buff) => buff as any); }); it('routes thin events to their handlers when mode=thin', () => { @@ -234,7 +234,6 @@ describe('Stripe Thin Webhooks (e2e)', () => { // Tests for mixed snapshot and thin handlers describe('Stripe Mixed Snapshot and Thin Webhooks (e2e)', () => { let app: INestApplication; - let hydratePayloadFn: jest.SpyInstance; const moduleConfig: StripeModuleConfig = { apiKey: '123', @@ -264,9 +263,9 @@ describe('Stripe Mixed Snapshot and Thin Webhooks (e2e)', () => { const stripePayloadService = app.get(StripePayloadService); - hydratePayloadFn = jest + jest .spyOn(stripePayloadService, 'tryHydratePayload') - .mockImplementation((sig, buff, mode) => buff as any); + .mockImplementation((sig, buff) => buff as any); }); it('routes snapshot events to snapshot handlers', () => { @@ -327,7 +326,7 @@ describe('Stripe Wildcard Handlers (e2e)', () => { jest .spyOn(stripePayloadService, 'tryHydratePayload') - .mockImplementation((sig, buff, mode) => buff as any); + .mockImplementation((sig, buff) => buff as any); }); it('wildcard snapshot handler receives all snapshot events', () => { From d3e93cb30a9b42228a688f13f594aadab536d30c Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Mon, 15 Dec 2025 17:00:42 -0300 Subject: [PATCH 6/9] fix --- packages/stripe/src/stripe.webhook.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stripe/src/stripe.webhook.service.ts b/packages/stripe/src/stripe.webhook.service.ts index f895451ef..6f8fdd98d 100644 --- a/packages/stripe/src/stripe.webhook.service.ts +++ b/packages/stripe/src/stripe.webhook.service.ts @@ -10,6 +10,6 @@ export class StripeWebhookService { mode: StripeWebhookMode, ): void | Promise { // The implementation for this method is overridden by the containing module - console.log(Event, mode); + console.log(event, mode); } } From ad79f926d4beb3308c78aaba1822d93d105a3109 Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Mon, 15 Dec 2025 17:01:04 -0300 Subject: [PATCH 7/9] code --- packages/stripe/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 2e6b969a2..3786345b0 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -59,4 +59,4 @@ "testEnvironment": "node" }, "gitHead": "6f97aab8ce9d65dc074750a3ee467ec5ff3b9908" -} \ No newline at end of file +} From 45c9a73f4540981688a14a7ea266e1ff43a90aec Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Mon, 15 Dec 2025 17:04:35 -0300 Subject: [PATCH 8/9] chore: changes --- docs/modules/stripe.md | 8 +- .../src/tests/stripe.thin-webhook.e2e.spec.ts | 284 ++++++++++++++++++ .../src/tests/stripe.webhook.e2e.spec.ts | 255 +--------------- 3 files changed, 289 insertions(+), 258 deletions(-) create mode 100644 packages/stripe/src/tests/stripe.thin-webhook.e2e.spec.ts diff --git a/docs/modules/stripe.md b/docs/modules/stripe.md index 6304af181..f1258dfb4 100644 --- a/docs/modules/stripe.md +++ b/docs/modules/stripe.md @@ -169,11 +169,11 @@ class BillingService { constructor(@InjectStripeClient() private stripe: Stripe) {} @StripeThinWebhookHandler('v1.billing.meter.error_report_triggered') - async handleBillingMeterError(evt: Stripe.V2.Core.Event) { + async handleBillingMeterError( + evt: Stripe.Events.V1BillingMeterErrorReportTriggeredEventNotification, + ) { // Thin events require fetching the full object - const meter = await this.stripe.v2.billing.meters.retrieve( - evt.related_object.id, - ); + const meter = await evt.fetchRelatedObject(); // execute your custom business logic } } diff --git a/packages/stripe/src/tests/stripe.thin-webhook.e2e.spec.ts b/packages/stripe/src/tests/stripe.thin-webhook.e2e.spec.ts new file mode 100644 index 000000000..b8bdb147f --- /dev/null +++ b/packages/stripe/src/tests/stripe.thin-webhook.e2e.spec.ts @@ -0,0 +1,284 @@ +import { ConsoleLogger, INestApplication, Injectable } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as request from 'supertest'; +import { + StripeWebhookHandler, + StripeThinWebhookHandler, +} from '../stripe.decorators'; +import { StripeModuleConfig, StripeWebhookMode } from '../stripe.interfaces'; +import { StripePayloadService } from '../stripe.payload.service'; +import { StripeModule } from '../stripe.module'; + +const testReceiveStripeFn = jest.fn(); +const testReceiveThinStripeFn = jest.fn(); +const testReceiveWildcardFn = jest.fn(); +const testReceiveThinWildcardFn = jest.fn(); +const defaultStripeWebhookEndpoint = '/stripe/webhook'; +const eventType = 'payment_intent.created'; +const thinEventType = 'v1.billing.meter.error_report_triggered'; +const expectedEvent = { type: eventType }; +const expectedThinEvent = { type: thinEventType }; +const stripeSig = 'stripeSignatureValue'; + +@Injectable() +class SilentLogger extends ConsoleLogger { + constructor() { + super(); + } + error() { + // ignore + } +} + +@Injectable() +class PaymentCreatedService { + @StripeWebhookHandler(eventType) + handlePaymentIntentCreated(evt: any) { + testReceiveStripeFn(evt); + } +} + +@Injectable() +class BillingMeterService { + @StripeThinWebhookHandler(thinEventType) + handleBillingMeterError(evt: any) { + testReceiveThinStripeFn(evt); + } +} + +@Injectable() +class WildcardSnapshotService { + @StripeWebhookHandler('*') + handleAllSnapshotEvents(evt: any) { + testReceiveWildcardFn(evt); + } +} + +@Injectable() +class WildcardThinService { + @StripeThinWebhookHandler('*') + handleAllThinEvents(evt: any) { + testReceiveThinWildcardFn(evt); + } +} + +// Tests for thin webhook functionality +describe('Stripe Thin Webhooks (e2e)', () => { + let app: INestApplication; + let hydratePayloadFn: jest.SpyInstance; + + const moduleConfig: StripeModuleConfig = { + apiKey: '123', + webhookConfig: { + stripeSecrets: { + account: '123', + }, + stripeThinSecrets: { + account: 'thin-secret-123', + }, + loggingConfiguration: { + logMatchingEventHandlers: true, + }, + }, + }; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [StripeModule.forRoot(moduleConfig)], + providers: [BillingMeterService], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useLogger(new SilentLogger()); + await app.init(); + + const stripePayloadService = + app.get(StripePayloadService); + + hydratePayloadFn = jest + .spyOn(stripePayloadService, 'tryHydratePayload') + .mockImplementation((sig, buff) => buff as any); + }); + + it('routes thin events to their handlers when mode=thin', () => { + return request(app.getHttpServer()) + .post(`${defaultStripeWebhookEndpoint}?mode=${StripeWebhookMode.THIN}`) + .send(expectedThinEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveThinStripeFn).toHaveBeenCalledTimes(1); + expect(hydratePayloadFn).toHaveBeenCalledTimes(1); + expect(hydratePayloadFn).toHaveBeenCalledWith( + stripeSig, + expectedThinEvent, + StripeWebhookMode.THIN, + ); + expect(testReceiveThinStripeFn).toHaveBeenCalledWith(expectedThinEvent); + }); + }); + + it('returns error for invalid mode parameter', () => { + return request(app.getHttpServer()) + .post(`${defaultStripeWebhookEndpoint}?mode=invalid`) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(500); + }); + + it('defaults to snapshot mode when mode parameter is missing', () => { + return request(app.getHttpServer()) + .post(defaultStripeWebhookEndpoint) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(hydratePayloadFn).toHaveBeenCalledWith( + stripeSig, + expectedEvent, + StripeWebhookMode.SNAPSHOT, + ); + }); + }); + + afterEach(() => jest.resetAllMocks()); +}); + +// Tests for mixed snapshot and thin handlers +describe('Stripe Mixed Snapshot and Thin Webhooks (e2e)', () => { + let app: INestApplication; + + const moduleConfig: StripeModuleConfig = { + apiKey: '123', + webhookConfig: { + stripeSecrets: { + account: '123', + }, + stripeThinSecrets: { + account: 'thin-secret-123', + }, + loggingConfiguration: { + logMatchingEventHandlers: true, + }, + }, + }; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [StripeModule.forRoot(moduleConfig)], + providers: [PaymentCreatedService, BillingMeterService], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useLogger(new SilentLogger()); + await app.init(); + + const stripePayloadService = + app.get(StripePayloadService); + + jest + .spyOn(stripePayloadService, 'tryHydratePayload') + .mockImplementation((sig, buff) => buff as any); + }); + + it('routes snapshot events to snapshot handlers', () => { + return request(app.getHttpServer()) + .post(defaultStripeWebhookEndpoint) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveStripeFn).toHaveBeenCalledTimes(1); + expect(testReceiveThinStripeFn).toHaveBeenCalledTimes(0); + }); + }); + + it('routes thin events to thin handlers', () => { + return request(app.getHttpServer()) + .post(`${defaultStripeWebhookEndpoint}?mode=${StripeWebhookMode.THIN}`) + .send(expectedThinEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveThinStripeFn).toHaveBeenCalledTimes(1); + expect(testReceiveStripeFn).toHaveBeenCalledTimes(0); + }); + }); + + afterEach(() => jest.resetAllMocks()); +}); + +// Tests for wildcard handlers +describe('Stripe Wildcard Handlers (e2e)', () => { + let app: INestApplication; + + const moduleConfig: StripeModuleConfig = { + apiKey: '123', + webhookConfig: { + stripeSecrets: { + account: '123', + }, + stripeThinSecrets: { + account: 'thin-secret-123', + }, + }, + }; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [StripeModule.forRoot(moduleConfig)], + providers: [WildcardSnapshotService, WildcardThinService], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useLogger(new SilentLogger()); + await app.init(); + + const stripePayloadService = + app.get(StripePayloadService); + + jest + .spyOn(stripePayloadService, 'tryHydratePayload') + .mockImplementation((sig, buff) => buff as any); + }); + + it('wildcard snapshot handler receives all snapshot events', () => { + return request(app.getHttpServer()) + .post(defaultStripeWebhookEndpoint) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveWildcardFn).toHaveBeenCalledTimes(1); + expect(testReceiveWildcardFn).toHaveBeenCalledWith(expectedEvent); + }); + }); + + it('wildcard thin handler receives all thin events', () => { + return request(app.getHttpServer()) + .post(`${defaultStripeWebhookEndpoint}?mode=${StripeWebhookMode.THIN}`) + .send(expectedThinEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveThinWildcardFn).toHaveBeenCalledTimes(1); + expect(testReceiveThinWildcardFn).toHaveBeenCalledWith( + expectedThinEvent, + ); + }); + }); + + it('wildcard handlers do not cross between modes', () => { + return request(app.getHttpServer()) + .post(defaultStripeWebhookEndpoint) + .send(expectedEvent) + .set('stripe-signature', stripeSig) + .expect(201) + .then(() => { + expect(testReceiveWildcardFn).toHaveBeenCalledTimes(1); + expect(testReceiveThinWildcardFn).toHaveBeenCalledTimes(0); + }); + }); + + afterEach(() => jest.resetAllMocks()); +}); diff --git a/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts b/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts index ee6a1ff71..4e2852425 100644 --- a/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts +++ b/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts @@ -1,23 +1,15 @@ import { ConsoleLogger, INestApplication, Injectable } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import * as request from 'supertest'; -import { - StripeWebhookHandler, - StripeThinWebhookHandler, -} from '../stripe.decorators'; +import { StripeWebhookHandler } from '../stripe.decorators'; import { StripeModuleConfig, StripeWebhookMode } from '../stripe.interfaces'; import { StripePayloadService } from '../stripe.payload.service'; import { StripeModule } from '../stripe.module'; const testReceiveStripeFn = jest.fn(); -const testReceiveThinStripeFn = jest.fn(); -const testReceiveWildcardFn = jest.fn(); -const testReceiveThinWildcardFn = jest.fn(); const defaultStripeWebhookEndpoint = '/stripe/webhook'; const eventType = 'payment_intent.created'; -const thinEventType = 'v1.billing.meter.error_report_triggered'; const expectedEvent = { type: eventType }; -const expectedThinEvent = { type: thinEventType }; const stripeSig = 'stripeSignatureValue'; @Injectable() @@ -38,30 +30,6 @@ class PaymentCreatedService { } } -@Injectable() -class BillingMeterService { - @StripeThinWebhookHandler(thinEventType) - handleBillingMeterError(evt: any) { - testReceiveThinStripeFn(evt); - } -} - -@Injectable() -class WildcardSnapshotService { - @StripeWebhookHandler('*') - handleAllSnapshotEvents(evt: any) { - testReceiveWildcardFn(evt); - } -} - -@Injectable() -class WildcardThinService { - @StripeThinWebhookHandler('*') - handleAllThinEvents(evt: any) { - testReceiveThinWildcardFn(evt); - } -} - type ModuleType = 'forRoot' | 'forRootAsync'; const cases: [ModuleType, string | undefined][] = [ ['forRoot', undefined], @@ -148,224 +116,3 @@ describe.each(cases)( afterEach(() => jest.resetAllMocks()); }, ); - -// Tests for thin webhook functionality -describe('Stripe Thin Webhooks (e2e)', () => { - let app: INestApplication; - let hydratePayloadFn: jest.SpyInstance; - - const moduleConfig: StripeModuleConfig = { - apiKey: '123', - webhookConfig: { - stripeSecrets: { - account: '123', - }, - stripeThinSecrets: { - account: 'thin-secret-123', - }, - loggingConfiguration: { - logMatchingEventHandlers: true, - }, - }, - }; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [StripeModule.forRoot(moduleConfig)], - providers: [BillingMeterService], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useLogger(new SilentLogger()); - await app.init(); - - const stripePayloadService = - app.get(StripePayloadService); - - hydratePayloadFn = jest - .spyOn(stripePayloadService, 'tryHydratePayload') - .mockImplementation((sig, buff) => buff as any); - }); - - it('routes thin events to their handlers when mode=thin', () => { - return request(app.getHttpServer()) - .post(`${defaultStripeWebhookEndpoint}?mode=${StripeWebhookMode.THIN}`) - .send(expectedThinEvent) - .set('stripe-signature', stripeSig) - .expect(201) - .then(() => { - expect(testReceiveThinStripeFn).toHaveBeenCalledTimes(1); - expect(hydratePayloadFn).toHaveBeenCalledTimes(1); - expect(hydratePayloadFn).toHaveBeenCalledWith( - stripeSig, - expectedThinEvent, - StripeWebhookMode.THIN, - ); - expect(testReceiveThinStripeFn).toHaveBeenCalledWith(expectedThinEvent); - }); - }); - - it('returns error for invalid mode parameter', () => { - return request(app.getHttpServer()) - .post(`${defaultStripeWebhookEndpoint}?mode=invalid`) - .send(expectedEvent) - .set('stripe-signature', stripeSig) - .expect(500); - }); - - it('defaults to snapshot mode when mode parameter is missing', () => { - return request(app.getHttpServer()) - .post(defaultStripeWebhookEndpoint) - .send(expectedEvent) - .set('stripe-signature', stripeSig) - .expect(201) - .then(() => { - expect(hydratePayloadFn).toHaveBeenCalledWith( - stripeSig, - expectedEvent, - StripeWebhookMode.SNAPSHOT, - ); - }); - }); - - afterEach(() => jest.resetAllMocks()); -}); - -// Tests for mixed snapshot and thin handlers -describe('Stripe Mixed Snapshot and Thin Webhooks (e2e)', () => { - let app: INestApplication; - - const moduleConfig: StripeModuleConfig = { - apiKey: '123', - webhookConfig: { - stripeSecrets: { - account: '123', - }, - stripeThinSecrets: { - account: 'thin-secret-123', - }, - loggingConfiguration: { - logMatchingEventHandlers: true, - }, - }, - }; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [StripeModule.forRoot(moduleConfig)], - providers: [PaymentCreatedService, BillingMeterService], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useLogger(new SilentLogger()); - await app.init(); - - const stripePayloadService = - app.get(StripePayloadService); - - jest - .spyOn(stripePayloadService, 'tryHydratePayload') - .mockImplementation((sig, buff) => buff as any); - }); - - it('routes snapshot events to snapshot handlers', () => { - return request(app.getHttpServer()) - .post(defaultStripeWebhookEndpoint) - .send(expectedEvent) - .set('stripe-signature', stripeSig) - .expect(201) - .then(() => { - expect(testReceiveStripeFn).toHaveBeenCalledTimes(1); - expect(testReceiveThinStripeFn).toHaveBeenCalledTimes(0); - }); - }); - - it('routes thin events to thin handlers', () => { - return request(app.getHttpServer()) - .post(`${defaultStripeWebhookEndpoint}?mode=${StripeWebhookMode.THIN}`) - .send(expectedThinEvent) - .set('stripe-signature', stripeSig) - .expect(201) - .then(() => { - expect(testReceiveThinStripeFn).toHaveBeenCalledTimes(1); - expect(testReceiveStripeFn).toHaveBeenCalledTimes(0); - }); - }); - - afterEach(() => jest.resetAllMocks()); -}); - -// Tests for wildcard handlers -describe('Stripe Wildcard Handlers (e2e)', () => { - let app: INestApplication; - - const moduleConfig: StripeModuleConfig = { - apiKey: '123', - webhookConfig: { - stripeSecrets: { - account: '123', - }, - stripeThinSecrets: { - account: 'thin-secret-123', - }, - }, - }; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [StripeModule.forRoot(moduleConfig)], - providers: [WildcardSnapshotService, WildcardThinService], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useLogger(new SilentLogger()); - await app.init(); - - const stripePayloadService = - app.get(StripePayloadService); - - jest - .spyOn(stripePayloadService, 'tryHydratePayload') - .mockImplementation((sig, buff) => buff as any); - }); - - it('wildcard snapshot handler receives all snapshot events', () => { - return request(app.getHttpServer()) - .post(defaultStripeWebhookEndpoint) - .send(expectedEvent) - .set('stripe-signature', stripeSig) - .expect(201) - .then(() => { - expect(testReceiveWildcardFn).toHaveBeenCalledTimes(1); - expect(testReceiveWildcardFn).toHaveBeenCalledWith(expectedEvent); - }); - }); - - it('wildcard thin handler receives all thin events', () => { - return request(app.getHttpServer()) - .post(`${defaultStripeWebhookEndpoint}?mode=${StripeWebhookMode.THIN}`) - .send(expectedThinEvent) - .set('stripe-signature', stripeSig) - .expect(201) - .then(() => { - expect(testReceiveThinWildcardFn).toHaveBeenCalledTimes(1); - expect(testReceiveThinWildcardFn).toHaveBeenCalledWith( - expectedThinEvent, - ); - }); - }); - - it('wildcard handlers do not cross between modes', () => { - return request(app.getHttpServer()) - .post(defaultStripeWebhookEndpoint) - .send(expectedEvent) - .set('stripe-signature', stripeSig) - .expect(201) - .then(() => { - expect(testReceiveWildcardFn).toHaveBeenCalledTimes(1); - expect(testReceiveThinWildcardFn).toHaveBeenCalledTimes(0); - }); - }); - - afterEach(() => jest.resetAllMocks()); -}); From f50c6b6a4fe09f2607415f57e576b4b260ab16c0 Mon Sep 17 00:00:00 2001 From: Arthur Fiorette <47537704+arthurfiorette@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:49:38 -0300 Subject: [PATCH 9/9] Update packages/stripe/src/stripe.webhook.controller.ts --- packages/stripe/src/stripe.webhook.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stripe/src/stripe.webhook.controller.ts b/packages/stripe/src/stripe.webhook.controller.ts index 196cacbd3..15358eab8 100644 --- a/packages/stripe/src/stripe.webhook.controller.ts +++ b/packages/stripe/src/stripe.webhook.controller.ts @@ -29,7 +29,7 @@ export class StripeWebhookController { } if (mode !== 'thin' && mode !== 'snapshot') { - throw new Error('Invalid mode query parameter'); + throw new Error(`Invalid mode ${mode} query parameter`); } const rawBody = request[this.requestBodyProperty];