import * as crypto from 'crypto'; import axios from 'axios'; import { CoolCommException } from '@cool-midway/core'; import { TeamStripeService } from '../../../src/modules/team/service/stripe'; import { TeamSubscriptionEntity } from '../../../src/modules/team/entity/subscription'; import { AdminTeamBillingController } from '../../../src/modules/team/controller/admin/billing'; import { StripeCheckoutParams, StripeWebhookEvent, } from '../../../src/comm/types/omni'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入账', () => { let stripeService: TeamStripeService; let subscriptions: TeamSubscriptionEntity[]; let processedStripeEvents: { id?: number; eventId: string; eventType?: string }[]; const testWebhookSecret = 'whsec_test_secret_key_1234567890abcdef'; const originalStripeSecret = process.env.STRIPE_SECRET_KEY; const createMockSubscriptionRepo = () => ({ findOne: jest.fn(async ({ where }: { where: { tenantId?: number | string; stripeSubscriptionId?: string } }) => { return subscriptions.find(s => { if (where.tenantId !== undefined && s.tenantId === Number(where.tenantId)) return true; if (where.stripeSubscriptionId && s.stripeSubscriptionId === where.stripeSubscriptionId) return true; return false; }) || null; }), findOneBy: jest.fn(async (q: { tenantId?: number | string; stripeSubscriptionId?: string }) => { return subscriptions.find(s => { if (q.tenantId !== undefined && s.tenantId === Number(q.tenantId)) return true; if (q.stripeSubscriptionId && s.stripeSubscriptionId === q.stripeSubscriptionId) return true; return false; }) || null; }), save: jest.fn(async (entity: TeamSubscriptionEntity) => { if (!entity.id) { entity.id = subscriptions.length ? Math.max(...subscriptions.map(s => s.id || 0)) + 1 : 1; subscriptions.push(entity); return entity; } const idx = subscriptions.findIndex(s => s.id === entity.id); if (idx >= 0) { Object.assign(subscriptions[idx], entity); return subscriptions[idx]; } subscriptions.push(entity); return entity; }), }); const createMockStripeEventRepo = () => ({ findOne: jest.fn(async ({ where }: { where: { eventId?: string } }) => { return processedStripeEvents.find(e => e.eventId === where.eventId) || null; }), save: jest.fn(async (entity: { id?: number; eventId: string; eventType?: string }) => { const existing = processedStripeEvents.find(e => e.eventId === entity.eventId); if (existing) { Object.assign(existing, entity); return existing; } entity.id = processedStripeEvents.length ? Math.max(...processedStripeEvents.map(e => e.id || 0)) + 1 : 1; processedStripeEvents.push(entity); return entity; }), }); beforeEach(() => { subscriptions = []; processedStripeEvents = []; delete process.env.STRIPE_SECRET_KEY; mockedAxios.post.mockReset(); stripeService = new TeamStripeService(); stripeService.teamSubscriptionEntity = createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity']; stripeService.teamStripeEventEntity = createMockStripeEventRepo() as unknown as TeamStripeService['teamStripeEventEntity']; stripeService.webhookSecret = testWebhookSecret; }); afterEach(() => { if (originalStripeSecret === undefined) { delete process.env.STRIPE_SECRET_KEY; } else { process.env.STRIPE_SECRET_KEY = originalStripeSecret; } }); // ========================================================================= // 1. Checkout 会话创建 (createCheckoutSession) // ========================================================================= describe('1. Checkout 会话创建 (createCheckoutSession)', () => { it('1.0 未配置 STRIPE_SECRET_KEY 时抛出 CoolCommException,不返回 sessionId/url', async () => { await expect( stripeService.createCheckoutSession({ tenantId: 101, plan: 'pro', billingCycle: 'monthly', }) ).rejects.toThrow(CoolCommException); await expect( stripeService.createCheckoutSession({ tenantId: 101, plan: 'pro', billingCycle: 'monthly', }) ).rejects.toThrow(/未配置 Stripe/); expect(mockedAxios.post).not.toHaveBeenCalled(); }); it('1.1 有密钥时通过 Stripe API 创建 Pro 月付结算会话,映射 id/url', async () => { process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key'; mockedAxios.post.mockResolvedValue({ data: { id: 'cs_test_pro_monthly_abc', url: 'https://checkout.stripe.com/c/pay/cs_test_pro_monthly_abc', }, }); const session = await stripeService.createCheckoutSession({ tenantId: 101, plan: 'pro', billingCycle: 'monthly', }); expect(session).toBeDefined(); expect(session.tenantId).toBe(101); expect(session.plan).toBe('pro'); expect(session.billingCycle).toBe('monthly'); expect(session.sessionId).toBe('cs_test_pro_monthly_abc'); expect(session.url).toBe( 'https://checkout.stripe.com/c/pay/cs_test_pro_monthly_abc' ); expect(session.currency).toBe('usd'); expect(session.amount).toBe(1900); // $19.00 / 月 expect(mockedAxios.post).toHaveBeenCalledWith( 'https://api.stripe.com/v1/checkout/sessions', expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer sk_test_unit_mock_key', 'Content-Type': 'application/x-www-form-urlencoded', }), }) ); }); it('1.2 有密钥时创建 Enterprise 年付结算会话,金额折算符合 SaaS 阶梯定价', async () => { process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key'; mockedAxios.post.mockResolvedValue({ data: { id: 'cs_test_ent_yearly_xyz', url: 'https://checkout.stripe.com/c/pay/cs_test_ent_yearly_xyz', }, }); const session = await stripeService.createCheckoutSession({ tenantId: 202, plan: 'enterprise', billingCycle: 'yearly', }); expect(session.tenantId).toBe(202); expect(session.plan).toBe('enterprise'); expect(session.billingCycle).toBe('yearly'); expect(session.amount).toBe(99000); // $990.00 / 年 (优惠2个月) expect(session.sessionId).toBe('cs_test_ent_yearly_xyz'); expect(session.url).toContain('checkout.stripe.com'); }); it('1.3 有密钥时默认周期为 monthly,且兼容独立参数传参 (tenantId, plan, billingCycle)', async () => { process.env.STRIPE_SECRET_KEY = 'sk_test_unit_mock_key'; mockedAxios.post.mockResolvedValue({ data: { id: 'cs_test_default_monthly', url: 'https://checkout.stripe.com/c/pay/cs_test_default_monthly', }, }); const session = await stripeService.createCheckoutSession(303, 'pro'); expect(session.tenantId).toBe(303); expect(session.billingCycle).toBe('monthly'); expect(session.amount).toBe(1900); }); it('1.4 参数校验:缺少 tenantId 抛出 CoolCommException 异常', async () => { await expect( stripeService.createCheckoutSession({ tenantId: null as unknown as number, plan: 'pro', }) ).rejects.toThrow(CoolCommException); await expect( stripeService.createCheckoutSession(0, 'pro') ).rejects.toThrow(CoolCommException); }); it('1.5 参数校验:无需为 free 套餐创建结算,或非法套餐拒绝创建', async () => { await expect( stripeService.createCheckoutSession({ tenantId: 101, plan: 'free', }) ).rejects.toThrow('无需为免费套餐创建结算'); await expect( stripeService.createCheckoutSession({ tenantId: 101, plan: 'invalid_plan' as unknown as StripeCheckoutParams['plan'], }) ).rejects.toThrow(); }); }); // ========================================================================= // 2. Webhook 签名验证与防伪机制 (verifyWebhookSignature) // ========================================================================= describe('2. Webhook 签名验证与防伪机制 (verifyWebhookSignature)', () => { const generateStripeSignature = ( payload: string, secret: string, timestamp: number ): string => { const signedPayload = `${timestamp}.${payload}`; const hmac = crypto.createHmac('sha256', secret); hmac.update(signedPayload, 'utf8'); const v1Signature = hmac.digest('hex'); return `t=${timestamp},v1=${v1Signature}`; }; it('2.1 基于 HMAC-SHA256 正确验证 Stripe 官方签名头', () => { const now = Math.floor(Date.now() / 1000); const payload = JSON.stringify({ id: 'evt_test_1', type: 'checkout.session.completed' }); const signature = generateStripeSignature(payload, testWebhookSecret, now); const isValid = stripeService.verifyWebhookSignature(payload, signature, testWebhookSecret, now); expect(isValid).toBe(true); }); it('2.2 篡改 payload 内容时验签失败,防伪拦截', () => { const now = Math.floor(Date.now() / 1000); const originalPayload = JSON.stringify({ id: 'evt_test_1', amount: 1900 }); const tamperedPayload = JSON.stringify({ id: 'evt_test_1', amount: 9900 }); const signature = generateStripeSignature(originalPayload, testWebhookSecret, now); const isValid = stripeService.verifyWebhookSignature(tamperedPayload, signature, testWebhookSecret, now); expect(isValid).toBe(false); }); it('2.3 密钥 secret 错误时验签失败', () => { const now = Math.floor(Date.now() / 1000); const payload = JSON.stringify({ id: 'evt_test_1' }); const signature = generateStripeSignature(payload, 'wrong_secret', now); const isValid = stripeService.verifyWebhookSignature(payload, signature, testWebhookSecret, now); expect(isValid).toBe(false); }); it('2.4 签名头缺失或格式错误 (缺少 t 或 v1) 时安全拒绝', () => { const payload = JSON.stringify({ id: 'evt_test_1' }); expect(stripeService.verifyWebhookSignature(payload, '', testWebhookSecret)).toBe(false); expect(stripeService.verifyWebhookSignature(payload, 'invalid_header', testWebhookSecret)).toBe(false); expect(stripeService.verifyWebhookSignature(payload, 't=123456', testWebhookSecret)).toBe(false); expect(stripeService.verifyWebhookSignature(payload, 'v1=abcdef', testWebhookSecret)).toBe(false); }); it('2.5 防重放攻击:签名时间戳超出 tolerance 宽容度时判定失效', () => { const pastTime = Math.floor(Date.now() / 1000) - 600; // 10分钟前 const now = Math.floor(Date.now() / 1000); const payload = JSON.stringify({ id: 'evt_replay' }); const signature = generateStripeSignature(payload, testWebhookSecret, pastTime); // 宽容度 300 秒,pastTime 差 600 秒应拒绝 const isValid = stripeService.verifyWebhookSignature(payload, signature, testWebhookSecret, now); expect(isValid).toBe(false); }); }); // ========================================================================= // 3. 三大核心 Webhook 事件流转与状态机断言 (handleWebhookEvent) // ========================================================================= describe('3. 三大核心 Webhook 事件流转与状态机断言 (handleWebhookEvent)', () => { it('3.1 处理 checkout.session.completed:套餐即时升级入账,生成或激活订阅', async () => { const event: StripeWebhookEvent = { id: 'evt_checkout_success', type: 'checkout.session.completed', created: Math.floor(Date.now() / 1000), data: { object: { id: 'cs_test_session_123', client_reference_id: '1001', customer: 'cus_stripe_1001', subscription: 'sub_stripe_1001', metadata: { tenantId: '1001', plan: 'pro', billingCycle: 'monthly', }, }, }, }; const result = await stripeService.handleWebhookEvent(event); expect(result.success).toBe(true); expect(result.eventType).toBe('checkout.session.completed'); expect(result.tenantId).toBe(1001); expect(result.plan).toBe('pro'); expect(result.action).toBe('upgraded'); // 状态机断言:订阅记录入库 expect(subscriptions.length).toBe(1); const sub = subscriptions[0]; expect(sub.tenantId).toBe(1001); expect(sub.planType).toBe('pro'); expect(sub.status).toBe('active'); expect(sub.stripeCustomerId).toBe('cus_stripe_1001'); expect(sub.stripeSubscriptionId).toBe('sub_stripe_1001'); expect(sub.currentPeriodEnd).toBeDefined(); }); it('3.2 处理 invoice.payment_succeeded:周期续费成功,延长订阅有效期并维持 active', async () => { // 预置已有 Pro 订阅 const existingSub = new TeamSubscriptionEntity(); existingSub.id = 1; existingSub.tenantId = 1001; existingSub.planType = 'pro'; existingSub.status = 'active'; existingSub.billingCycle = 'monthly'; existingSub.stripeSubscriptionId = 'sub_stripe_1001'; existingSub.currentPeriodEnd = '2026-09-20T00:00:00.000Z'; subscriptions.push(existingSub); const event: StripeWebhookEvent = { id: 'evt_invoice_paid', type: 'invoice.payment_succeeded', created: Math.floor(Date.now() / 1000), data: { object: { id: 'in_test_123', customer: 'cus_stripe_1001', subscription: 'sub_stripe_1001', lines: { data: [ { period: { end: Math.floor(Date.now() / 1000) + 30 * 86400, }, }, ], }, metadata: { tenantId: '1001', }, }, }, }; const result = await stripeService.handleWebhookEvent(event); expect(result.success).toBe(true); expect(result.eventType).toBe('invoice.payment_succeeded'); expect(result.tenantId).toBe(1001); expect(result.action).toBe('renewed'); // 状态机断言:周期已刷新且保持 active const sub = subscriptions[0]; expect(sub.status).toBe('active'); expect(new Date(sub.currentPeriodEnd!).getTime()).toBeGreaterThan( new Date('2026-09-20T00:00:00.000Z').getTime() ); }); it('3.3 处理 customer.subscription.deleted:订阅终止,租户安全降级为 free 并置为 canceled', async () => { // 预置 Enterprise 订阅 const activeSub = new TeamSubscriptionEntity(); activeSub.id = 2; activeSub.tenantId = 2002; activeSub.planType = 'enterprise'; activeSub.status = 'active'; activeSub.stripeSubscriptionId = 'sub_stripe_2002'; subscriptions.push(activeSub); const event: StripeWebhookEvent = { id: 'evt_sub_deleted', type: 'customer.subscription.deleted', created: Math.floor(Date.now() / 1000), data: { object: { id: 'sub_stripe_2002', metadata: { tenantId: '2002', }, }, }, }; const result = await stripeService.handleWebhookEvent(event); expect(result.success).toBe(true); expect(result.eventType).toBe('customer.subscription.deleted'); expect(result.tenantId).toBe(2002); expect(result.plan).toBe('free'); expect(result.action).toBe('downgraded'); // 状态机断言:降级并标记已取消 const sub = subscriptions.find(s => s.tenantId === 2002); expect(sub?.planType).toBe('free'); expect(sub?.status).toBe('canceled'); }); it('3.4 其它不相关事件安全被忽略,不改变系统状态', async () => { const event: StripeWebhookEvent = { id: 'evt_other', type: 'charge.succeeded', created: Math.floor(Date.now() / 1000), data: { object: {} }, }; const result = await stripeService.handleWebhookEvent(event); expect(result.success).toBe(true); expect(result.action).toBe('ignored'); expect(result.ignored).toBe(true); }); }); // ========================================================================= // 4. 控制器端点集成与调用测试 (AdminTeamBillingController) // ========================================================================= describe('4. 控制器端点集成 (AdminTeamBillingController)', () => { let controller: AdminTeamBillingController; let mockStripeService: Partial; beforeEach(() => { mockStripeService = { createCheckoutSession: jest.fn(async (params) => { const tenantId = typeof params === 'object' ? params.tenantId : params; return { sessionId: 'cs_test_ctrl_123', url: 'https://checkout.stripe.com/c/pay/cs_test_ctrl_123', tenantId, plan: 'pro' as const, billingCycle: 'monthly' as const, amount: 1900, currency: 'usd', }; }), verifyWebhookSignature: jest.fn((_payload, signature) => { return signature === 'valid_stripe_sig'; }), handleWebhookEvent: jest.fn(async (event) => ({ success: true, eventType: event.type, tenantId: 999, action: 'upgraded' as const, })), }; controller = new AdminTeamBillingController(); controller.teamStripeService = mockStripeService as TeamStripeService; controller.ctx = { admin: { tenantId: 999 }, get: jest.fn((header: string) => { if (header.toLowerCase() === 'stripe-signature') return 'valid_stripe_sig'; return ''; }), headers: { 'stripe-signature': 'valid_stripe_sig', }, request: { body: { id: 'evt_123', type: 'checkout.session.completed', created: 1700000000, data: { object: {} }, }, }, } as unknown as AdminTeamBillingController['ctx']; controller.ok = (data: unknown) => ({ code: 1000, message: 'success', data } as any); controller.fail = (message: string, code = 1001) => ({ code, message, data: undefined } as any); }); it('4.1 POST /admin/team/billing/stripe-checkout 返回有效结账链接', async () => { const res = await controller.createCheckout({ plan: 'pro', billingCycle: 'monthly', }); expect(res.code).toBe(1000); expect((res as unknown as { data: { sessionId: string } }).data.sessionId).toBe('cs_test_ctrl_123'); expect(mockStripeService.createCheckoutSession).toHaveBeenCalled(); }); it('4.2 POST /admin/team/billing/webhook 验签合法时入账成功', async () => { const res = await controller.webhook({ id: 'evt_123', type: 'checkout.session.completed', created: 1700000000, data: { object: {} }, }); expect(res.code).toBe(1000); expect((res as unknown as { data: { received: boolean } }).data.received).toBe(true); expect(mockStripeService.handleWebhookEvent).toHaveBeenCalled(); }); it('4.3 POST /admin/team/billing/webhook 验签失败时拦截并返回 400', async () => { // 模拟非法签名 (controller.ctx.get as jest.Mock).mockReturnValue('invalid_sig'); controller.ctx.headers['stripe-signature'] = 'invalid_sig'; const res = await controller.webhook({ id: 'evt_fake', type: 'checkout.session.completed', created: 1700000000, data: { object: {} }, }); expect(res.code).toBe(400); expect(res.message).toContain('签名验证失败'); }); }); // ========================================================================= // 5. Webhook 幂等去重与异常事件状态机 (Idempotency & Failure Grace Period) // ========================================================================= describe('5. Webhook 幂等去重与异常事件状态机', () => { it('5.1 同一 event.id 重复推送时触发幂等拦截,防止重复入账', async () => { const event: StripeWebhookEvent = { id: 'evt_unique_1001', type: 'checkout.session.completed', created: 1700000000, data: { object: { client_reference_id: 88, metadata: { tenantId: '88', plan: 'pro' }, }, }, }; const firstRes = await stripeService.handleWebhookEvent(event); expect(firstRes.action).toBe('upgraded'); expect(subscriptions.length).toBe(1); expect(processedStripeEvents.some(e => e.eventId === 'evt_unique_1001')).toBe( true ); // 重复推送相同事件:只入账一次 const secondRes = await stripeService.handleWebhookEvent(event); expect(secondRes.success).toBe(true); expect(secondRes.action).toBe('idempotent_ignored'); expect(subscriptions.length).toBe(1); expect( ( stripeService.teamSubscriptionEntity .save as unknown as jest.Mock ).mock.calls.length ).toBe(1); }); it('5.2 收到 invoice.payment_failed 扣款失败时流转为 past_due 宽限期', async () => { // 先建立活跃订阅 subscriptions.push({ id: 99, tenantId: 66, planType: 'pro', billingCycle: 'monthly', status: 'active', stripeSubscriptionId: 'sub_pastdue_test', currentPeriodEnd: new Date().toISOString(), } as TeamSubscriptionEntity); const failEvent: StripeWebhookEvent = { id: 'evt_fail_999', type: 'invoice.payment_failed', created: 1700000000, data: { object: { subscription: 'sub_pastdue_test', metadata: { tenantId: '66' }, }, }, }; const res = await stripeService.handleWebhookEvent(failEvent); expect(res.status).toBe('past_due'); expect(res.action).toBe('payment_failed'); const updated = subscriptions.find(s => s.tenantId === 66); expect(updated?.status).toBe('past_due'); }); it('5.3 进程重启后凭持久化记录仍识别已处理 event.id,不重复入账', async () => { const event: StripeWebhookEvent = { id: 'evt_persist_restart_2002', type: 'checkout.session.completed', created: 1700000000, data: { object: { client_reference_id: '2002', metadata: { tenantId: '2002', plan: 'pro' }, }, }, }; const firstRes = await stripeService.handleWebhookEvent(event); expect(firstRes.action).toBe('upgraded'); expect(subscriptions.length).toBe(1); expect(processedStripeEvents).toEqual( expect.arrayContaining([ expect.objectContaining({ eventId: 'evt_persist_restart_2002' }), ]) ); // 模拟进程重启:新 Service 实例清空内存 Set,但共享同一持久化表 const restarted = new TeamStripeService(); restarted.teamSubscriptionEntity = createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity']; restarted.teamStripeEventEntity = createMockStripeEventRepo() as unknown as TeamStripeService['teamStripeEventEntity']; restarted.webhookSecret = testWebhookSecret; expect(restarted.processedEventIds.size).toBe(0); const secondRes = await restarted.handleWebhookEvent(event); expect(secondRes.action).toBe('idempotent_ignored'); expect(subscriptions.length).toBe(1); expect( (restarted.teamSubscriptionEntity.save as unknown as jest.Mock).mock .calls.length ).toBe(0); }); it('5.4 无事件表 repository 时仍可用内存 Set 兜底幂等', async () => { const memoryOnly = new TeamStripeService(); memoryOnly.teamSubscriptionEntity = createMockSubscriptionRepo() as unknown as TeamStripeService['teamSubscriptionEntity']; memoryOnly.teamStripeEventEntity = undefined as unknown as TeamStripeService['teamStripeEventEntity']; memoryOnly.webhookSecret = testWebhookSecret; const event: StripeWebhookEvent = { id: 'evt_memory_only_55', type: 'checkout.session.completed', created: 1700000000, data: { object: { client_reference_id: '55', metadata: { tenantId: '55', plan: 'pro' }, }, }, }; const firstRes = await memoryOnly.handleWebhookEvent(event); expect(firstRes.action).toBe('upgraded'); expect(subscriptions.length).toBe(1); const secondRes = await memoryOnly.handleWebhookEvent(event); expect(secondRes.action).toBe('idempotent_ignored'); expect(subscriptions.length).toBe(1); }); }); });