mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-12 12:47:45 +02:00
Fix stripe events (#10602)
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user