Fix stripe webhook (#10600)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-03-09 15:18:29 +07:00
committed by GitHub
parent d8866abe0a
commit fc0edbc9b3
4 changed files with 450 additions and 2 deletions
@@ -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<AccountClient>
let stripeClient: jest.Mocked<StripeClient>
let ctx: jest.Mocked<MeasureContext>
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)
})
})
@@ -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<Request>
let res: Partial<Response>
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 })
})
})
@@ -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<string, string>
@@ -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)
})
}
}
@@ -31,6 +31,7 @@ export async function handleStripeWebhook (
accountsUrl: string,
serviceToken: string,
webhookSecret: string,
stripeApiKey: string,
req: Request,
res: Response
): Promise<void> {
@@ -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