Fix stripe events (#10602)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-03-09 22:49:48 +07:00
committed by GitHub
parent e8c0f8ef4d
commit 8da0fb93a9
4 changed files with 232 additions and 9 deletions
@@ -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' }))
})
})
@@ -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',
@@ -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.Event | null> {
// 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
}
@@ -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
})