diff --git a/docs/modules/stripe.md b/docs/modules/stripe.md index 202a600d4..f1258dfb4 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_***', + accountTest: 'whsec_***', + connect: 'whsec_***', + connectTest: 'whsec_***', + }, }, }), ], @@ -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) + +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. - @StripeWebhookHandler('v1.billing.meter.no_meter_found') - async handleBillingMeterNoMeterFound( - nft: Stripe.Events.V1BillingMeterNoMeterFoundEventNotification, +```typescript +@Injectable() +class BillingService { + constructor(@InjectStripeClient() private stripe: Stripe) {} + + @StripeThinWebhookHandler('v1.billing.meter.error_report_triggered') + async handleBillingMeterError( + evt: Stripe.Events.V1BillingMeterErrorReportTriggeredEventNotification, ) { - const event = await nft.fetchEvent(); + // Thin events require fetching the full object + const meter = await evt.fetchRelatedObject(); // 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..cde9b7da0 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,20 @@ 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'], ) => 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..15358eab8 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 ${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..6f8fdd98d 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); + console.log(event, mode); } } 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 3119397ae..4e2852425 100644 --- a/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts +++ b/packages/stripe/src/tests/stripe.webhook.e2e.spec.ts @@ -2,7 +2,7 @@ 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 { StripeModuleConfig, StripeWebhookMode } from '../stripe.interfaces'; import { StripePayloadService } from '../stripe.payload.service'; import { StripeModule } from '../stripe.module'; @@ -107,6 +107,7 @@ describe.each(cases)( expect(hydratePayloadFn).toHaveBeenCalledWith( stripeSig, expectedEvent, + StripeWebhookMode.SNAPSHOT, ); expect(testReceiveStripeFn).toHaveBeenCalledWith(expectedEvent); });