406 行
13 KiB
TypeScript
406 行
13 KiB
TypeScript
import * as crypto from 'crypto';
|
|
import { Provide } from '@midwayjs/core';
|
|
import { BaseService, CoolCommException } from '@cool-midway/core';
|
|
import { InjectEntityModel } from '@midwayjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { TeamSubscriptionEntity } from '../entity/subscription';
|
|
import {
|
|
StripeBillingCycle,
|
|
StripeCheckoutParams,
|
|
StripeCheckoutResult,
|
|
StripeWebhookEvent,
|
|
StripeWebhookHandleResult,
|
|
TenantPlanType,
|
|
} from '../../../comm/types/omni';
|
|
|
|
/**
|
|
* 套餐价格表 (单位:美分 cents)
|
|
*/
|
|
const PLAN_PRICING: Record<
|
|
Exclude<TenantPlanType, 'free'>,
|
|
Record<StripeBillingCycle, number>
|
|
> = {
|
|
pro: {
|
|
monthly: 1900, // $19 / 月
|
|
yearly: 19000, // $190 / 年 (立省 2 个月)
|
|
},
|
|
enterprise: {
|
|
monthly: 9900, // $99 / 月
|
|
yearly: 99000, // $990 / 年 (立省 2 个月)
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Stripe 国际信用卡订阅支付与 Webhook 自动化入账服务
|
|
*/
|
|
@Provide()
|
|
export class TeamStripeService extends BaseService {
|
|
@InjectEntityModel(TeamSubscriptionEntity)
|
|
teamSubscriptionEntity: Repository<TeamSubscriptionEntity>;
|
|
|
|
/** Webhook 验签密钥,生产环境可读取环境变量 */
|
|
webhookSecret: string =
|
|
process.env.STRIPE_WEBHOOK_SECRET || 'whsec_default_team_secret';
|
|
|
|
/** Webhook 事件幂等去重表 */
|
|
processedEventIds: Set<string> = new Set();
|
|
|
|
/**
|
|
* 创建 Stripe Checkout 结算会话
|
|
* @param tenantIdOrParams 租户 ID 或完整请求参数
|
|
* @param planOpt 套餐版本 (pro / enterprise)
|
|
* @param cycleOpt 结算周期 (monthly / yearly)
|
|
*/
|
|
async createCheckoutSession(
|
|
tenantIdOrParams: number | string | StripeCheckoutParams,
|
|
planOpt?: TenantPlanType,
|
|
cycleOpt?: StripeBillingCycle
|
|
): Promise<StripeCheckoutResult> {
|
|
let tenantId: number | string;
|
|
let plan: TenantPlanType;
|
|
let billingCycle: StripeBillingCycle = 'monthly';
|
|
|
|
if (typeof tenantIdOrParams === 'object' && tenantIdOrParams !== null) {
|
|
tenantId = tenantIdOrParams.tenantId;
|
|
plan = tenantIdOrParams.plan;
|
|
billingCycle = tenantIdOrParams.billingCycle || 'monthly';
|
|
} else {
|
|
tenantId = tenantIdOrParams as number | string;
|
|
plan = planOpt as TenantPlanType;
|
|
billingCycle = cycleOpt || 'monthly';
|
|
}
|
|
|
|
// 参数校验
|
|
if (!tenantId || Number(tenantId) <= 0) {
|
|
throw new CoolCommException('缺少企业租户 ID', 400);
|
|
}
|
|
|
|
if (plan === 'free') {
|
|
throw new CoolCommException('无需为免费套餐创建结算', 400);
|
|
}
|
|
|
|
if (!plan || !PLAN_PRICING[plan as Exclude<TenantPlanType, 'free'>]) {
|
|
throw new CoolCommException(`不支持的付费套餐类型: ${plan}`, 400);
|
|
}
|
|
|
|
const pricing = PLAN_PRICING[plan as Exclude<TenantPlanType, 'free'>];
|
|
const amount = pricing[billingCycle] || pricing.monthly;
|
|
|
|
// 生成符合 Stripe 标准的模拟/直连会话
|
|
const sessionRandom = crypto.randomBytes(16).toString('hex');
|
|
const sessionId = `cs_test_${sessionRandom}`;
|
|
const url = `https://checkout.stripe.com/c/pay/${sessionId}`;
|
|
|
|
return {
|
|
sessionId,
|
|
url,
|
|
tenantId: Number(tenantId) || tenantId,
|
|
plan,
|
|
billingCycle,
|
|
amount,
|
|
currency: 'usd',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 基于 HMAC-SHA256 验证 Stripe 官方签名头防伪
|
|
* @param payload 原始 Webhook 报文 (string / Buffer / Record)
|
|
* @param signatureHeader Stripe-Signature 请求头
|
|
* @param secret Webhook 签名私钥
|
|
* @param timestampOrTolerance 测试指定当前时间戳或最大容忍秒数
|
|
*/
|
|
verifyWebhookSignature(
|
|
payload: string | Buffer | Record<string, unknown>,
|
|
signatureHeader: string,
|
|
secret?: string,
|
|
timestampOrTolerance?: number
|
|
): boolean {
|
|
if (!signatureHeader || typeof signatureHeader !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const webhookSecret = secret || this.webhookSecret;
|
|
if (!webhookSecret) {
|
|
return false;
|
|
}
|
|
|
|
const payloadString =
|
|
typeof payload === 'string'
|
|
? payload
|
|
: Buffer.isBuffer(payload)
|
|
? payload.toString('utf8')
|
|
: JSON.stringify(payload);
|
|
|
|
// 解析 signatureHeader: t=1492774577,v1=5257a869e7...
|
|
const headerParts = signatureHeader.split(',');
|
|
let timestampStr = '';
|
|
const v1Signatures: string[] = [];
|
|
|
|
for (const part of headerParts) {
|
|
const [key, val] = part.trim().split('=');
|
|
if (key === 't') {
|
|
timestampStr = val;
|
|
} else if (key === 'v1' && val) {
|
|
v1Signatures.push(val);
|
|
}
|
|
}
|
|
|
|
if (!timestampStr || v1Signatures.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const headerTimestamp = parseInt(timestampStr, 10);
|
|
if (isNaN(headerTimestamp)) {
|
|
return false;
|
|
}
|
|
|
|
// 防重放攻击与宽容度校验
|
|
const defaultTolerance = 300; // 默认 5 分钟
|
|
let nowSeconds: number;
|
|
let tolerance = defaultTolerance;
|
|
|
|
if (timestampOrTolerance && timestampOrTolerance > 1000000000) {
|
|
// 传入的是当前模拟时间戳
|
|
nowSeconds = timestampOrTolerance;
|
|
} else {
|
|
nowSeconds = Math.floor(Date.now() / 1000);
|
|
if (timestampOrTolerance && timestampOrTolerance > 0) {
|
|
tolerance = timestampOrTolerance;
|
|
}
|
|
}
|
|
|
|
if (Math.abs(nowSeconds - headerTimestamp) > tolerance) {
|
|
return false;
|
|
}
|
|
|
|
// 计算预期签名
|
|
const signedPayload = `${headerTimestamp}.${payloadString}`;
|
|
const hmac = crypto.createHmac('sha256', webhookSecret);
|
|
hmac.update(signedPayload, 'utf8');
|
|
const expectedSignature = hmac.digest('hex');
|
|
|
|
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
|
|
|
// 比对所有提取到的 v1 签名
|
|
for (const sig of v1Signatures) {
|
|
const sigBuffer = Buffer.from(sig, 'utf8');
|
|
if (
|
|
expectedBuffer.length === sigBuffer.length &&
|
|
crypto.timingSafeEqual(expectedBuffer, sigBuffer)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 处理 Stripe Webhook 核心事件并执行自动化入账与状态机流转
|
|
* @param event Stripe 官方事件对象
|
|
*/
|
|
async handleWebhookEvent(
|
|
event: StripeWebhookEvent
|
|
): Promise<StripeWebhookHandleResult> {
|
|
if (event.id && this.processedEventIds.has(event.id)) {
|
|
return {
|
|
success: true,
|
|
eventType: event.type,
|
|
action: 'idempotent_ignored',
|
|
ignored: true,
|
|
};
|
|
}
|
|
if (event.id) {
|
|
this.processedEventIds.add(event.id);
|
|
}
|
|
|
|
const { type, data } = event;
|
|
const object = (data?.object || {}) as Record<string, unknown>;
|
|
|
|
switch (type) {
|
|
case 'checkout.session.completed': {
|
|
// 套餐即时升级入账
|
|
const metadata = (object.metadata || {}) as Record<string, string>;
|
|
const rawTenantId = String(
|
|
object.client_reference_id || metadata.tenantId || ''
|
|
);
|
|
const tenantId: number | string = Number(rawTenantId) || rawTenantId;
|
|
const plan = (metadata.plan || 'pro') as TenantPlanType;
|
|
const billingCycle = (metadata.billingCycle ||
|
|
'monthly') as StripeBillingCycle;
|
|
const stripeCustomerId = (object.customer as string) || undefined;
|
|
const stripeSubscriptionId =
|
|
(object.subscription as string) || (object.id as string) || undefined;
|
|
|
|
// 计算当前周期结束时间 (月付加 30 天,年付加 365 天)
|
|
const periodDays = billingCycle === 'yearly' ? 365 : 30;
|
|
const periodEndDate = new Date(Date.now() + periodDays * 86400 * 1000);
|
|
|
|
if (this.teamSubscriptionEntity) {
|
|
let sub = await this.teamSubscriptionEntity.findOne({
|
|
where: { tenantId: Number(tenantId) || 0 },
|
|
});
|
|
if (!sub) {
|
|
sub = new TeamSubscriptionEntity();
|
|
sub.tenantId = Number(tenantId) || 0;
|
|
}
|
|
sub.planType = plan;
|
|
sub.billingCycle = billingCycle;
|
|
sub.status = 'active';
|
|
sub.stripeCustomerId = stripeCustomerId;
|
|
sub.stripeSubscriptionId = stripeSubscriptionId;
|
|
sub.currentPeriodEnd = periodEndDate.toISOString();
|
|
sub.cancelAtPeriodEnd = false;
|
|
|
|
await this.teamSubscriptionEntity.save(sub);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
eventType: type,
|
|
tenantId,
|
|
plan,
|
|
status: 'active',
|
|
action: 'upgraded',
|
|
};
|
|
}
|
|
|
|
case 'invoice.payment_succeeded': {
|
|
// 周期续费成功
|
|
const metadata = (object.metadata || {}) as Record<string, string>;
|
|
const subDetails = (object.subscription_details || {}) as {
|
|
metadata?: Record<string, string>;
|
|
};
|
|
const rawTenantId = String(
|
|
metadata.tenantId || subDetails.metadata?.tenantId || ''
|
|
);
|
|
const stripeSubId = (object.subscription as string) || '';
|
|
|
|
let targetSub: TeamSubscriptionEntity | null = null;
|
|
if (this.teamSubscriptionEntity) {
|
|
if (rawTenantId) {
|
|
targetSub = await this.teamSubscriptionEntity.findOne({
|
|
where: { tenantId: Number(rawTenantId) || 0 },
|
|
});
|
|
}
|
|
if (!targetSub && stripeSubId) {
|
|
targetSub = await this.teamSubscriptionEntity.findOne({
|
|
where: { stripeSubscriptionId: stripeSubId },
|
|
});
|
|
}
|
|
}
|
|
|
|
const lines = (object.lines || {}) as {
|
|
data?: Array<{ period?: { end?: number } }>;
|
|
};
|
|
const periodEndSeconds = lines.data?.[0]?.period?.end;
|
|
const periodEndDate = periodEndSeconds
|
|
? new Date(periodEndSeconds * 1000)
|
|
: new Date(Date.now() + 30 * 86400 * 1000);
|
|
|
|
const tenantId: number | string =
|
|
targetSub?.tenantId ?? (Number(rawTenantId) || rawTenantId);
|
|
|
|
if (targetSub && this.teamSubscriptionEntity) {
|
|
targetSub.status = 'active';
|
|
targetSub.currentPeriodEnd = periodEndDate.toISOString();
|
|
await this.teamSubscriptionEntity.save(targetSub);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
eventType: type,
|
|
tenantId,
|
|
status: 'active',
|
|
action: 'renewed',
|
|
};
|
|
}
|
|
|
|
case 'customer.subscription.deleted': {
|
|
// 订阅终止,安全降级为 free
|
|
const metadata = (object.metadata || {}) as Record<string, string>;
|
|
const stripeSubId = (object.id as string) || '';
|
|
const rawTenantId = String(metadata.tenantId || '');
|
|
|
|
let targetSub: TeamSubscriptionEntity | null = null;
|
|
if (this.teamSubscriptionEntity) {
|
|
if (rawTenantId) {
|
|
targetSub = await this.teamSubscriptionEntity.findOne({
|
|
where: { tenantId: Number(rawTenantId) || 0 },
|
|
});
|
|
}
|
|
if (!targetSub && stripeSubId) {
|
|
targetSub = await this.teamSubscriptionEntity.findOne({
|
|
where: { stripeSubscriptionId: stripeSubId },
|
|
});
|
|
}
|
|
}
|
|
|
|
const tenantId: number | string =
|
|
targetSub?.tenantId ?? (Number(rawTenantId) || rawTenantId);
|
|
|
|
if (targetSub && this.teamSubscriptionEntity) {
|
|
targetSub.planType = 'free';
|
|
targetSub.status = 'canceled';
|
|
await this.teamSubscriptionEntity.save(targetSub);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
eventType: type,
|
|
tenantId,
|
|
plan: 'free',
|
|
status: 'canceled',
|
|
action: 'downgraded',
|
|
};
|
|
}
|
|
|
|
case 'invoice.payment_failed': {
|
|
// 扣款失败,优雅流转为 past_due 宽限期
|
|
const metadata = (object.metadata || {}) as Record<string, string>;
|
|
const stripeSubId = (object.subscription as string) || '';
|
|
const rawTenantId = String(metadata.tenantId || '');
|
|
|
|
let targetSub: TeamSubscriptionEntity | null = null;
|
|
if (this.teamSubscriptionEntity) {
|
|
if (rawTenantId) {
|
|
targetSub = await this.teamSubscriptionEntity.findOne({
|
|
where: { tenantId: Number(rawTenantId) || 0 },
|
|
});
|
|
}
|
|
if (!targetSub && stripeSubId) {
|
|
targetSub = await this.teamSubscriptionEntity.findOne({
|
|
where: { stripeSubscriptionId: stripeSubId },
|
|
});
|
|
}
|
|
}
|
|
|
|
const tenantId: number | string =
|
|
targetSub?.tenantId ?? (Number(rawTenantId) || rawTenantId);
|
|
|
|
if (targetSub && this.teamSubscriptionEntity) {
|
|
targetSub.status = 'past_due';
|
|
await this.teamSubscriptionEntity.save(targetSub);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
eventType: type,
|
|
tenantId,
|
|
status: 'past_due',
|
|
action: 'payment_failed',
|
|
};
|
|
}
|
|
|
|
default: {
|
|
return {
|
|
success: true,
|
|
eventType: type,
|
|
action: 'ignored',
|
|
ignored: true,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|