文件
OmniCard/backend/test/modules/team/stripe.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

672 行
25 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

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<typeof axios>;
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<TeamStripeService>;
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);
});
});
});