文件
OmniCard/backend/src/modules/team/service/stripe.ts

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,
};
}
}
}
}