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