feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -8,56 +9,121 @@ import {
|
||||
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 = [];
|
||||
|
||||
// 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;
|
||||
}),
|
||||
};
|
||||
processedStripeEvents = [];
|
||||
delete process.env.STRIPE_SECRET_KEY;
|
||||
mockedAxios.post.mockReset();
|
||||
|
||||
stripeService = new TeamStripeService();
|
||||
stripeService.teamSubscriptionEntity = mockSubscriptionRepo as unknown as TeamStripeService['teamSubscriptionEntity'];
|
||||
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.1 成功为租户创建 Pro 月付结算会话,返回模拟/真实 Stripe URL 与 sessionId', async () => {
|
||||
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',
|
||||
@@ -68,14 +134,33 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
|
||||
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.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 () => {
|
||||
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',
|
||||
@@ -86,10 +171,19 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
|
||||
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 () => {
|
||||
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);
|
||||
@@ -456,11 +550,22 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
|
||||
|
||||
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 () => {
|
||||
@@ -494,5 +599,73 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户