499 行
19 KiB
TypeScript
499 行
19 KiB
TypeScript
import * as crypto from 'crypto';
|
||
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';
|
||
|
||
describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入账', () => {
|
||
let stripeService: TeamStripeService;
|
||
let subscriptions: TeamSubscriptionEntity[];
|
||
const testWebhookSecret = 'whsec_test_secret_key_1234567890abcdef';
|
||
|
||
beforeEach(() => {
|
||
subscriptions = [];
|
||
|
||
// Mock Subscription Repository
|
||
const mockSubscriptionRepo = {
|
||
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;
|
||
}),
|
||
};
|
||
|
||
stripeService = new TeamStripeService();
|
||
stripeService.teamSubscriptionEntity = mockSubscriptionRepo as unknown as TeamStripeService['teamSubscriptionEntity'];
|
||
stripeService.webhookSecret = testWebhookSecret;
|
||
});
|
||
|
||
// =========================================================================
|
||
// 1. Checkout 会话创建 (createCheckoutSession)
|
||
// =========================================================================
|
||
describe('1. Checkout 会话创建 (createCheckoutSession)', () => {
|
||
it('1.1 成功为租户创建 Pro 月付结算会话,返回模拟/真实 Stripe URL 与 sessionId', async () => {
|
||
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).toMatch(/^cs_test_[a-zA-Z0-9_-]+$/);
|
||
expect(session.url).toContain('checkout.stripe.com');
|
||
expect(session.url).toContain(session.sessionId);
|
||
expect(session.currency).toBe('usd');
|
||
expect(session.amount).toBe(1900); // $19.00 / 月
|
||
});
|
||
|
||
it('1.2 成功为租户创建 Enterprise 年付结算会话,金额折算符合 SaaS 阶梯定价', async () => {
|
||
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.url).toContain('checkout.stripe.com');
|
||
});
|
||
|
||
it('1.3 默认周期为 monthly,且兼容独立参数传参 (tenantId, plan, billingCycle)', async () => {
|
||
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');
|
||
|
||
// 重复推送相同事件
|
||
const secondRes = await stripeService.handleWebhookEvent(event);
|
||
expect(secondRes.success).toBe(true);
|
||
expect(secondRes.action).toBe('idempotent_ignored');
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
});
|