From aaf51185eec48520677d0b5c07303b43b6ac63f7 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Mon, 9 Mar 2026 13:45:31 +0700 Subject: [PATCH] Fix stripe webhook Signed-off-by: Artem Savchenko --- .../stripe/__tests__/provider.test.ts | 225 ++++++++++++++++++ .../stripe/__tests__/webhook.test.ts | 220 +++++++++++++++++ .../src/providers/stripe/provider.ts | 4 +- .../src/providers/stripe/webhook.ts | 3 +- 4 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 services/payment/pod-payment/src/providers/stripe/__tests__/provider.test.ts create mode 100644 services/payment/pod-payment/src/providers/stripe/__tests__/webhook.test.ts diff --git a/services/payment/pod-payment/src/providers/stripe/__tests__/provider.test.ts b/services/payment/pod-payment/src/providers/stripe/__tests__/provider.test.ts new file mode 100644 index 00000000000..797c9fa1888 --- /dev/null +++ b/services/payment/pod-payment/src/providers/stripe/__tests__/provider.test.ts @@ -0,0 +1,225 @@ +import type { Express, Request, Response } from 'express' +import type Stripe from 'stripe' +import type { MeasureContext } from '@hcengineering/core' +import { AccountClient, SubscriptionType, type Subscription } from '@hcengineering/account-client' +import { StripeProvider } from '../provider' +import { StripeClient } from '../client' +import { transformStripeSubscriptionToData } from '../utils' +import { getPlanKey } from '../../../utils' +import * as webhookModule from '../webhook' + +jest.mock('../client') +jest.mock('../utils', () => ({ + transformStripeSubscriptionToData: jest.fn() +})) +jest.mock('../../../utils', () => ({ + getPlanKey: jest.fn() +})) + +describe('StripeProvider', () => { + const apiKey = 'sk_test_123' + const webhookSecret = 'whsec_123' + const subscriptionPlans = + 'common@tier:price_common;rare@tier:price_rare;epic@tier:price_epic;legendary@tier:price_legendary' + const frontUrl = 'https://front.example.test' + + let accountClient: jest.Mocked + let stripeClient: jest.Mocked + let ctx: jest.Mocked + + beforeEach(() => { + accountClient = { + getSubscriptions: jest.fn(), + upsertSubscription: jest.fn() + } as any + + stripeClient = { + createCheckout: jest.fn(), + getSubscription: jest.fn(), + getCheckout: jest.fn(), + getActiveSubscriptions: jest.fn(), + cancelSubscription: jest.fn(), + uncancelSubscription: jest.fn(), + updateSubscription: jest.fn() + } as any + ;(StripeClient as unknown as jest.Mock).mockImplementation(() => stripeClient) + + ctx = { + info: jest.fn(), + error: jest.fn(), + with: jest.fn() + } as any + + jest.clearAllMocks() + }) + + test('createSubscription creates checkout with correct parameters', async () => { + const provider = new StripeProvider(apiKey, webhookSecret, subscriptionPlans, frontUrl, accountClient) + + const request = { + type: SubscriptionType.Tier, + plan: 'common', + customerEmail: 'user@example.test', + customerName: 'User' + } as any + + const workspaceUuid = 'workspace-uuid' as any + const workspaceUrl = 'workspace-url' + const accountUuid = 'account-uuid' + + ;(getPlanKey as jest.Mock).mockReturnValue('common@tier') + + // eslint-disable-next-line @typescript-eslint/unbound-method + stripeClient.createCheckout.mockResolvedValue({ + checkoutId: 'cs_test_123', + url: 'https://stripe.test/checkout' + }) + + const result = await provider.createSubscription(ctx, request, workspaceUuid, workspaceUrl, accountUuid) + + expect(getPlanKey).toHaveBeenCalledWith(request.type, request.plan) + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(stripeClient.createCheckout).toHaveBeenCalledWith(ctx, { + priceId: 'price_common', + successUrl: `${frontUrl}/workbench/${workspaceUrl}/setting/setting/billing/subscriptions?payment=success&checkout_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${frontUrl}/workbench/${workspaceUrl}/setting/setting/billing/subscriptions?payment=canceled`, + customerEmail: request.customerEmail, + customerName: request.customerName, + metadata: { + workspaceUuid, + subscriptionType: request.type, + subscriptionPlan: request.plan + } + }) + + expect(result).toEqual({ + checkoutId: 'cs_test_123', + checkoutUrl: 'https://stripe.test/checkout' + }) + }) + + test('getSubscriptionByCheckout returns null when checkout is not complete', async () => { + const provider = new StripeProvider(apiKey, webhookSecret, subscriptionPlans, frontUrl, accountClient) + + stripeClient.getCheckout.mockResolvedValue({ + id: 'cs_test', + status: 'open' + } as any) + + const result = await provider.getSubscriptionByCheckout(ctx, 'cs_test') + + expect(result).toBeNull() + }) + + test('reconcileActiveSubscriptions upserts changed and stale subscriptions', async () => { + const provider = new StripeProvider(apiKey, webhookSecret, subscriptionPlans, frontUrl, accountClient) + + const stripeSubActive: Stripe.Subscription = { + id: 'sub_1', + status: 'active' + } as any + + const ourSub: Subscription = { + providerSubscriptionId: 'sub_1', + providerData: { + modifiedAt: 1 + } + } as any + + stripeClient.getActiveSubscriptions.mockResolvedValue([stripeSubActive]) + accountClient.getSubscriptions.mockResolvedValue([ourSub]) + ;(transformStripeSubscriptionToData as jest.Mock).mockImplementation((sub: Stripe.Subscription) => ({ + id: sub.id, + status: sub.status, + providerData: { + modifiedAt: 2 + } + })) + + await provider.reconcileActiveSubscriptions(ctx, 'https://accounts.test', 'token') + + expect(accountClient.upsertSubscription).toHaveBeenCalledWith({ + id: 'sub_1', + status: 'active', + providerData: { + modifiedAt: 2 + } + }) + }) + + test('updateSubscriptionPlan creates checkout for free subscription', async () => { + const provider = new StripeProvider(apiKey, webhookSecret, subscriptionPlans, frontUrl, accountClient) + + const subscriptionId = 'sub_free' + const newPlan = 'epic' + const workspaceUrl = 'workspace-url' + + const currentSub: Stripe.Subscription = { + id: subscriptionId, + items: { + data: [ + { + price: { + unit_amount: 0 + } + } + ] + } + } as any + + stripeClient.getSubscription.mockResolvedValue(currentSub) + ;(getPlanKey as jest.Mock).mockReturnValue('epic@tier') + + // eslint-disable-next-line @typescript-eslint/unbound-method + stripeClient.createCheckout.mockResolvedValue({ + checkoutId: 'cs_test_new', + url: 'https://stripe.test/checkout/new' + }) + + const result = await provider.updateSubscriptionPlan(ctx, subscriptionId, newPlan, workspaceUrl) + + expect(result).toEqual({ + checkoutId: 'cs_test_new', + checkoutUrl: 'https://stripe.test/checkout/new' + }) + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(stripeClient.createCheckout).toHaveBeenCalledWith( + ctx, + expect.objectContaining({ + priceId: 'price_epic', + subscriptionId + }) + ) + }) + + test('registerWebhookEndpoints wires up express route with correct arguments', () => { + const provider = new StripeProvider(apiKey, webhookSecret, subscriptionPlans, frontUrl, accountClient) + + const appPost = jest.fn() + const app = { + post: appPost + } as any as Express + + const accountsUrl = 'https://accounts.test' + const serviceToken = 'service-token' + + provider.registerWebhookEndpoints(app, ctx, accountsUrl, serviceToken) + + expect(appPost).toHaveBeenCalledWith('/api/v1/webhooks/stripe', expect.any(Function)) + + const handler = appPost.mock.calls[0][1] as (req: Request, res: Response) => void + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const req = {} as Request + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const res = {} as Response + + const handleStripeWebhookSpy = jest.spyOn(webhookModule, 'handleStripeWebhook').mockResolvedValue(undefined as any) + + handler(req, res) + + expect(handleStripeWebhookSpy).toHaveBeenCalledWith(ctx, accountsUrl, serviceToken, webhookSecret, apiKey, req, res) + }) +}) diff --git a/services/payment/pod-payment/src/providers/stripe/__tests__/webhook.test.ts b/services/payment/pod-payment/src/providers/stripe/__tests__/webhook.test.ts new file mode 100644 index 00000000000..8543064080b --- /dev/null +++ b/services/payment/pod-payment/src/providers/stripe/__tests__/webhook.test.ts @@ -0,0 +1,220 @@ +import Stripe from 'stripe' +import type { Request, Response } from 'express' +import { handleStripeWebhook } from '../webhook' +import { getAccountClient } from '../../../utils' +import { transformStripeSubscriptionToData } from '../utils' + +jest.mock('stripe') +jest.mock('../../../utils', () => ({ + getAccountClient: jest.fn() +})) +jest.mock('../utils', () => ({ + transformStripeSubscriptionToData: jest.fn() +})) + +describe('handleStripeWebhook', () => { + const accountsUrl = 'https://accounts.example.test' + const serviceToken = 'service-token' + const webhookSecret = 'whsec_123' + const stripeApiKey = 'sk_test_123' + + let ctx: any + let req: Partial + let res: Partial + let jsonMock: jest.Mock + let statusMock: jest.Mock & ((code: number) => Response) + + beforeEach(() => { + ctx = { + info: jest.fn(), + error: jest.fn() + } + + jsonMock = jest.fn() + statusMock = jest.fn().mockImplementation(() => { + return { + json: jsonMock + } as any + }) + + req = { + body: Buffer.from('payload'), + headers: { + 'stripe-signature': 'sig_header' + } + } + + res = { + status: statusMock as unknown as any + } + + jest.clearAllMocks() + }) + + test('returns 400 when body is not a Buffer or empty', async () => { + req.body = undefined as any + + await handleStripeWebhook( + ctx, + accountsUrl, + serviceToken, + webhookSecret, + stripeApiKey, + req as Request, + res as Response + ) + + expect(ctx.error).toHaveBeenCalledWith('Invalid webhook body') + expect(statusMock).toHaveBeenCalledWith(400) + expect(jsonMock).toHaveBeenCalledWith({ error: 'Invalid body' }) + }) + + test('returns 400 when signature header is missing', async () => { + req.headers = {} + + await handleStripeWebhook( + ctx, + accountsUrl, + serviceToken, + webhookSecret, + stripeApiKey, + req as Request, + res as Response + ) + + expect(ctx.error).toHaveBeenCalledWith('Missing Stripe signature header') + expect(statusMock).toHaveBeenCalledWith(400) + expect(jsonMock).toHaveBeenCalledWith({ error: 'Missing signature' }) + }) + + test('returns 403 when Stripe signature verification fails', async () => { + const constructEventMock = jest.fn(() => { + throw new Error('bad signature') + }) + + ;(Stripe as unknown as jest.Mock).mockImplementation(() => ({ + webhooks: { + constructEvent: constructEventMock + } + })) + + await handleStripeWebhook( + ctx, + accountsUrl, + serviceToken, + webhookSecret, + stripeApiKey, + req as Request, + res as Response + ) + + expect(constructEventMock).toHaveBeenCalled() + expect(ctx.error).toHaveBeenCalledWith('Invalid Stripe webhook signature', expect.any(Object)) + expect(statusMock).toHaveBeenCalledWith(403) + expect(jsonMock).toHaveBeenCalledWith({ error: 'Invalid signature' }) + }) + + test('handles subscription-related events and returns 200', async () => { + const event: Stripe.Event = { + id: 'evt_123', + type: 'customer.subscription.updated', + created: Date.now() / 1000, + data: { + object: { + id: 'sub_123', + status: 'active' + } as any + }, + livemode: false, + object: 'event', + pending_webhooks: 0, + request: { + id: 'req_123', + idempotency_key: null + }, + api_version: '2025-02-24.acacia' + } + + const constructEventMock = jest.fn(() => event) + + ;(Stripe as unknown as jest.Mock).mockImplementation(() => ({ + webhooks: { + constructEvent: constructEventMock + } + })) + + const accountClient = { + upsertSubscription: jest.fn() + } + + ;(getAccountClient as jest.Mock).mockReturnValue(accountClient) + ;(transformStripeSubscriptionToData as jest.Mock).mockReturnValue({ + id: 'sub_123', + status: 'active', + providerData: {} + }) + + await handleStripeWebhook( + ctx, + accountsUrl, + serviceToken, + webhookSecret, + stripeApiKey, + req as Request, + res as Response + ) + + expect(constructEventMock).toHaveBeenCalledWith(req.body, 'sig_header', webhookSecret) + + expect(accountClient.upsertSubscription).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sub_123', status: 'active' }) + ) + + expect(statusMock).toHaveBeenCalledWith(200) + expect(jsonMock).toHaveBeenCalledWith({ received: true }) + }) + + test('logs and returns 200 for unhandled event types', async () => { + const event: Stripe.Event = { + id: 'evt_456', + type: 'charge.succeeded', + created: Date.now() / 1000, + data: { + object: {} as any + }, + livemode: false, + object: 'event', + pending_webhooks: 0, + request: { + id: 'req_456', + idempotency_key: null + }, + api_version: '2025-02-24.acacia' + } + + const constructEventMock = jest.fn(() => event) + + ;(Stripe as unknown as jest.Mock).mockImplementation(() => ({ + webhooks: { + constructEvent: constructEventMock + } + })) + + await handleStripeWebhook( + ctx, + accountsUrl, + serviceToken, + webhookSecret, + stripeApiKey, + req as Request, + res as Response + ) + + expect(ctx.info).toHaveBeenCalledWith('Unhandled Stripe webhook event type', { + type: 'charge.succeeded' + }) + + expect(statusMock).toHaveBeenCalledWith(200) + expect(jsonMock).toHaveBeenCalledWith({ received: true }) + }) +}) diff --git a/services/payment/pod-payment/src/providers/stripe/provider.ts b/services/payment/pod-payment/src/providers/stripe/provider.ts index f231b0502f0..c0cc6c4476e 100644 --- a/services/payment/pod-payment/src/providers/stripe/provider.ts +++ b/services/payment/pod-payment/src/providers/stripe/provider.ts @@ -54,6 +54,7 @@ function hasSubscriptionChanged (ourSub: SubscriptionData, newData: Subscription export class StripeProvider implements PaymentProvider { readonly providerName = 'stripe' private readonly stripe: StripeClient + private readonly stripeApiKey: string private readonly webhookSecret: string // Map: plan@type (Huly) -> priceId (Stripe) private readonly subscriptionPlans: Record @@ -67,6 +68,7 @@ export class StripeProvider implements PaymentProvider { frontUrl: string, accountClient: AccountClient ) { + this.stripeApiKey = apiKey this.stripe = new StripeClient(apiKey) this.webhookSecret = webhookSecret // TODO: support branding @@ -336,7 +338,7 @@ export class StripeProvider implements PaymentProvider { // Register Stripe-specific webhook endpoint (body parsing handled by server middleware) app.post('/api/v1/webhooks/stripe', (req: Request, res: Response) => { - void handleStripeWebhook(ctx, accountsUrl, serviceToken, this.webhookSecret, req, res) + void handleStripeWebhook(ctx, accountsUrl, serviceToken, this.webhookSecret, this.stripeApiKey, req, res) }) } } diff --git a/services/payment/pod-payment/src/providers/stripe/webhook.ts b/services/payment/pod-payment/src/providers/stripe/webhook.ts index 127e5c80517..6b9bba0b88f 100644 --- a/services/payment/pod-payment/src/providers/stripe/webhook.ts +++ b/services/payment/pod-payment/src/providers/stripe/webhook.ts @@ -31,6 +31,7 @@ export async function handleStripeWebhook ( accountsUrl: string, serviceToken: string, webhookSecret: string, + stripeApiKey: string, req: Request, res: Response ): Promise { @@ -52,7 +53,7 @@ export async function handleStripeWebhook ( } // Create Stripe instance for webhook verification - const stripe = new Stripe('', { apiVersion: '2025-02-24.acacia' }) + const stripe = new Stripe(stripeApiKey, { apiVersion: '2025-02-24.acacia' }) // Verify webhook signature and parse event let event: Stripe.Event