From 8da0fb93a98c2ea6d9c2eafc9496abdce1f1ae31 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Mon, 9 Mar 2026 22:49:48 +0700 Subject: [PATCH] Fix stripe events (#10602) Signed-off-by: Artem Savchenko --- .../providers/stripe/__tests__/utils.test.ts | 82 +++++++++++++++++++ .../stripe/__tests__/webhook.test.ts | 79 +++++++++++++++++- .../pod-payment/src/providers/stripe/utils.ts | 54 +++++++++++- .../src/providers/stripe/webhook.ts | 26 ++++-- 4 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 services/payment/pod-payment/src/providers/stripe/__tests__/utils.test.ts diff --git a/services/payment/pod-payment/src/providers/stripe/__tests__/utils.test.ts b/services/payment/pod-payment/src/providers/stripe/__tests__/utils.test.ts new file mode 100644 index 0000000000..62e5666489 --- /dev/null +++ b/services/payment/pod-payment/src/providers/stripe/__tests__/utils.test.ts @@ -0,0 +1,82 @@ +import Stripe from 'stripe' +import type { MeasureContext } from '@hcengineering/core' + +import { createSubscriptionEventFromInvoiceEvent } from '../utils' + +describe('Stripe utils - createSubscriptionEventFromInvoiceEvent', () => { + const stripeApiKey = 'sk_test_123' + + let ctx: MeasureContext + + beforeEach(() => { + ctx = { + info: jest.fn(), + error: jest.fn() + } as any + }) + + test('returns null and logs when invoice has no subscription', async () => { + const event: Stripe.Event = { + id: 'evt_no_sub', + type: 'invoice.payment_succeeded', + created: Date.now() / 1000, + data: { + object: { + id: 'in_123', + status: 'paid', + subscription: null + } as any + }, + livemode: false, + object: 'event', + pending_webhooks: 0, + request: { + id: null, + idempotency_key: null + }, + api_version: '2025-02-24.acacia' + } + + const stripe = new Stripe(stripeApiKey, { apiVersion: '2025-02-24.acacia' }) + jest.spyOn(stripe.subscriptions, 'retrieve').mockResolvedValue({} as any) + + const result = await createSubscriptionEventFromInvoiceEvent(ctx, stripe, event) + + expect(result).toBeNull() + expect((ctx.info as jest.Mock).mock.calls[0][0]).toBe('Invoice event without subscription, skipping') + }) + + test('fetches subscription and returns subscription event for invoice with subscription', async () => { + const event: Stripe.Event = { + id: 'evt_1T8HuWLfExample', + type: 'invoice.payment_succeeded', + created: 1772878598, + data: { + object: { + id: 'in_1T8HuSLfExample', + status: 'paid', + subscription: 'sub_1T8HuSLfExample' + } as any + }, + livemode: true, + object: 'event', + pending_webhooks: 1, + request: { + id: null, + idempotency_key: null + }, + api_version: '2024-11-20.acacia' + } + + const stripe = new Stripe(stripeApiKey, { apiVersion: '2025-02-24.acacia' }) + const retrieveMock = jest + .spyOn(stripe.subscriptions, 'retrieve') + .mockResolvedValue({ id: 'sub_1T8HuSLfExample', status: 'active' } as any) + + const result = await createSubscriptionEventFromInvoiceEvent(ctx, stripe, event) + + expect(retrieveMock).toHaveBeenCalledWith('sub_1T8HuSLfExample') + expect(result).not.toBeNull() + expect(result?.data.object).toEqual(expect.objectContaining({ id: 'sub_1T8HuSLfExample', status: 'active' })) + }) +}) 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 index 8543064080..4718cbd0bd 100644 --- a/services/payment/pod-payment/src/providers/stripe/__tests__/webhook.test.ts +++ b/services/payment/pod-payment/src/providers/stripe/__tests__/webhook.test.ts @@ -2,14 +2,15 @@ import Stripe from 'stripe' import type { Request, Response } from 'express' import { handleStripeWebhook } from '../webhook' import { getAccountClient } from '../../../utils' -import { transformStripeSubscriptionToData } from '../utils' +import { createSubscriptionEventFromInvoiceEvent, transformStripeSubscriptionToData } from '../utils' jest.mock('stripe') jest.mock('../../../utils', () => ({ getAccountClient: jest.fn() })) jest.mock('../utils', () => ({ - transformStripeSubscriptionToData: jest.fn() + transformStripeSubscriptionToData: jest.fn(), + createSubscriptionEventFromInvoiceEvent: jest.fn() })) describe('handleStripeWebhook', () => { @@ -174,6 +175,80 @@ describe('handleStripeWebhook', () => { expect(jsonMock).toHaveBeenCalledWith({ received: true }) }) + test('fetches subscription for invoice events and upserts it (via helper)', async () => { + const event: Stripe.Event = { + id: 'evt_456', + type: 'invoice.payment_succeeded', + created: Date.now() / 1000, + data: { + object: { + id: 'in_123', + status: 'paid', + subscription: 'sub_456' + } 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 + } + })) + + const accountClient = { + upsertSubscription: jest.fn() + } + + ;(getAccountClient as jest.Mock).mockReturnValue(accountClient) + ;(transformStripeSubscriptionToData as jest.Mock).mockReturnValue({ + id: 'sub_456', + status: 'active', + providerData: {} + }) + + const subscriptionEvent: Stripe.Event = { + ...event, + data: { + ...event.data, + object: { + id: 'sub_456', + status: 'active' + } as any + } + } + + ;(createSubscriptionEventFromInvoiceEvent as jest.Mock).mockResolvedValue(subscriptionEvent) + + await handleStripeWebhook( + ctx, + accountsUrl, + serviceToken, + webhookSecret, + stripeApiKey, + req as Request, + res as Response + ) + + expect(constructEventMock).toHaveBeenCalledWith(req.body, 'sig_header', webhookSecret) + expect(createSubscriptionEventFromInvoiceEvent).toHaveBeenCalledWith(ctx, expect.anything(), event) + expect(accountClient.upsertSubscription).toHaveBeenCalledWith( + expect.objectContaining({ id: 'sub_456', 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', diff --git a/services/payment/pod-payment/src/providers/stripe/utils.ts b/services/payment/pod-payment/src/providers/stripe/utils.ts index b7fc159251..e9111123dd 100644 --- a/services/payment/pod-payment/src/providers/stripe/utils.ts +++ b/services/payment/pod-payment/src/providers/stripe/utils.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { type AccountUuid, type WorkspaceUuid } from '@hcengineering/core' +import { type AccountUuid, type WorkspaceUuid, type MeasureContext } from '@hcengineering/core' import type { SubscriptionData } from '@hcengineering/account-client' import { SubscriptionStatus, SubscriptionType } from '@hcengineering/account-client' import type Stripe from 'stripe' @@ -70,6 +70,18 @@ export function transformStripeSubscriptionToData (subscription: Stripe.Subscrip subscriptionType === undefined || subscriptionPlan === undefined ) { + const missing: string[] = [] + if (accountUuid === undefined) missing.push('accountUuid') + if (workspaceUuid === undefined) missing.push('workspaceUuid') + if (subscriptionType === undefined) missing.push('subscriptionType') + if (subscriptionPlan === undefined) missing.push('subscriptionPlan') + + console.warn('Stripe subscription missing required metadata, ignoring update', { + subscriptionId: subscription.id, + status: subscription.status, + missingFields: missing + }) + return null } @@ -82,6 +94,10 @@ export function transformStripeSubscriptionToData (subscription: Stripe.Subscrip if (status === null) { // Ignore updates for subscriptions in irrelevant states + console.warn('Stripe subscription status is missing', { + subscriptionId: subscription.id, + status: subscription.status + }) return null } @@ -129,3 +145,39 @@ export function transformStripeSubscriptionToData (subscription: Stripe.Subscrip return subscriptionData } + +/** + * For invoice.* events, load the related subscription from Stripe and + * return a new Event whose data.object is the Subscription. + * + * Returns null when the invoice has no subscription. + */ +export async function createSubscriptionEventFromInvoiceEvent ( + ctx: MeasureContext, + stripe: Stripe, + event: Stripe.Event +): Promise { + // Stripe sends the invoice in data.object for invoice.* events + const invoice = event.data.object as Stripe.Invoice + const subscriptionId = typeof invoice.subscription === 'string' ? invoice.subscription : invoice.subscription?.id + + if (subscriptionId === undefined) { + ctx.info('Invoice event without subscription, skipping', { + invoiceId: invoice.id, + status: invoice.status + }) + return null + } + + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + + // We only care that data.object is a Subscription; the exact event + // subtype is not used by the subscription handler. + return { + ...event, + data: { + ...event.data, + object: subscription + } + } as unknown as Stripe.Event +} diff --git a/services/payment/pod-payment/src/providers/stripe/webhook.ts b/services/payment/pod-payment/src/providers/stripe/webhook.ts index 6b9bba0b88..c0613b0526 100644 --- a/services/payment/pod-payment/src/providers/stripe/webhook.ts +++ b/services/payment/pod-payment/src/providers/stripe/webhook.ts @@ -18,7 +18,7 @@ import Stripe from 'stripe' import { type MeasureContext } from '@hcengineering/core' import { getAccountClient } from '../../utils' -import { transformStripeSubscriptionToData } from './utils' +import { createSubscriptionEventFromInvoiceEvent, transformStripeSubscriptionToData } from './utils' /** * Handle Stripe webhook events @@ -69,15 +69,29 @@ export async function handleStripeWebhook ( switch (event.type) { case 'customer.subscription.created': case 'customer.subscription.updated': - case 'customer.subscription.deleted': - case 'invoice.payment_succeeded': - case 'invoice.payment_failed': + case 'customer.subscription.deleted': { void handleSubscriptionUpdated(ctx, accountsUrl, serviceToken, event).catch((err) => { ctx.error('Failed to process Stripe webhook event', { event, err }) }) break - default: + } + case 'invoice.payment_succeeded': + case 'invoice.payment_failed': { + void (async () => { + try { + const subscriptionEvent = await createSubscriptionEventFromInvoiceEvent(ctx, stripe, event) + if (subscriptionEvent == null) return + + await handleSubscriptionUpdated(ctx, accountsUrl, serviceToken, subscriptionEvent) + } catch (err) { + ctx.error('Failed to process Stripe invoice event', { event, err }) + } + })() + break + } + default: { ctx.info('Unhandled Stripe webhook event type', { type: event.type }) + } } res.status(200).json({ received: true }) @@ -105,7 +119,7 @@ async function handleSubscriptionUpdated ( const subscriptionData = transformStripeSubscriptionToData(subscription) if (subscriptionData === null) { - ctx.info('Ignoring subscription in irrelevant state', { + ctx.warn('Ignoring subscription in irrelevant state', { subscriptionId: subscription.id, status: subscription.status })