import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; export interface CreateTenantAccountDto { tenantId: string; balanceCents?: number; creditCents?: number; status?: string; } export interface UpdateCreditLimitDto { creditCents: number; operatorId?: string; remark?: string; } export interface CreateAccountTransactionDto { tenantId: string; transactionType: string; idempotencyKey?: string; amountCents?: number; balanceAfter?: number; relatedType?: string; relatedId?: string; remark?: string; } export interface CreateBillingRuleDto { code: string; name: string; chargeBasis?: string; unitPrice: number; status?: string; } export interface CreateRechargeOrderDto { tenantId: string; amountCents: number; payMethod?: string; operatorId?: string; remark?: string; } export interface CreateManualRechargeDto { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; operatorId?: string; remark?: string; } export interface ManualRechargePreflightDto { tenantId: string; amountCents: number; } export interface EstimateSmsCostDto { tenantId: string; applicationId?: string; content: string; phoneCount: number; unitPrice?: number; taskId?: string; } export interface BillingActionDto { tenantId: string; idempotencyKey?: string; amountCents?: number; relatedType?: string; relatedId?: string; remark?: string; } export interface CreateSmsBillingRecordDto { tenantId: string; applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; content: string; unitPrice?: number; } @Injectable() export class BillingService { constructor(private readonly prisma: PrismaService) {} listAccounts() { return this.prisma.tenantAccount.findMany({ include: { tenant: true }, orderBy: { createdAt: 'desc' }, }); } createAccount(data: CreateTenantAccountDto) { assertMoneyUnits(data.balanceCents ?? 0, '账户余额', { allowNegative: true }); assertMoneyUnits(data.creditCents ?? 0, '授信额度', { allowNegative: true }); const createData: Prisma.TenantAccountUncheckedCreateInput = { tenantId: data.tenantId, balanceCents: data.balanceCents ?? 0, creditCents: data.creditCents ?? 0, status: data.status ?? 'active', }; return this.prisma.tenantAccount.create({ data: createData }); } async updateCreditLimit(tenantId: string, data: UpdateCreditLimitDto) { assertMoneyUnits(data.creditCents, '授信额度', { allowNegative: true }); const account = await this.getAccountOrCreate(tenantId); const updated = await this.prisma.tenantAccount.update({ where: { tenantId }, data: { creditCents: data.creditCents }, }); await this.prisma.operationLog.create({ data: { tenantId, userId: data.operatorId, action: 'billing.credit_limit_updated', resource: 'tenant_account', resourceId: account.id, detail: { previousCreditCents: moneyToNumber(account.creditCents), creditCents: data.creditCents, remark: data.remark, } as Prisma.InputJsonValue, }, }); return updated; } listRechargeOrders(tenantId?: string) { return this.prisma.rechargeOrder.findMany({ where: tenantId ? { tenantId } : undefined, orderBy: { createdAt: 'desc' }, }); } async listRechargeOrdersPage(query: { tenantId?: string; page?: number; pageSize?: number }) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.RechargeOrderWhereInput = query.tenantId ? { tenantId: query.tenantId } : {}; const [items, total] = await Promise.all([ this.prisma.rechargeOrder.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), this.prisma.rechargeOrder.count({ where }), ]); return { items, total, page, pageSize }; } async listManualRechargeRecords(tenantId?: string) { const orders = await this.prisma.rechargeOrder.findMany({ where: { tenantId, payMethod: 'manual_topup', }, orderBy: { createdAt: 'desc' }, }); const orderIds = orders.map((order) => order.id); if (orderIds.length === 0) { return orders; } const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))]; const [transactions, operators] = await Promise.all([ this.prisma.accountTransaction.findMany({ where: { relatedType: 'recharge_order', relatedId: { in: orderIds }, }, select: { relatedId: true, balanceAfter: true }, }), operatorIds.length ? this.prisma.user.findMany({ where: { id: { in: operatorIds } }, select: { id: true, displayName: true, username: true }, }) : [], ]); const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)])); const operatorNameById = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username])); return orders.map((order) => ({ ...order, balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null, operatorName: order.operatorId ? operatorNameById.get(order.operatorId) ?? null : null, })); } async listManualRechargeRecordsPage(query: { tenantId?: string; enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.RechargeOrderWhereInput = { tenantId: query.tenantId, payMethod: 'manual_topup', tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined, createdAt: query.createdAtFrom || query.createdAtTo ? { gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined, lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined, } : undefined, }; const [orders, total] = await Promise.all([ this.prisma.rechargeOrder.findMany({ where, include: { tenant: true }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), this.prisma.rechargeOrder.count({ where }), ]); const orderIds = orders.map((order) => order.id); const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))]; const [transactions, operators] = await Promise.all([ orderIds.length ? this.prisma.accountTransaction.findMany({ where: { relatedType: 'recharge_order', relatedId: { in: orderIds } }, select: { relatedId: true, balanceAfter: true }, }) : [], operatorIds.length ? this.prisma.user.findMany({ where: { id: { in: operatorIds } }, select: { id: true, displayName: true, username: true }, }) : [], ]); const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)])); const operatorNames = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username])); return { items: orders.map((order) => ({ ...order, balanceAfterCents: balances.get(order.id) ?? null, operatorName: order.operatorId ? operatorNames.get(order.operatorId) ?? null : null, })), total, page, pageSize, }; } async createRechargeOrder(data: CreateRechargeOrderDto) { const amountCents = data.amountCents; assertMoneyUnits(amountCents, '充值金额', { allowNegative: true, allowZero: false }); const order = await this.prisma.rechargeOrder.create({ data: { tenantId: data.tenantId, orderNo: `R${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, amountCents, status: 'paid', payMethod: data.payMethod ?? 'manual', paidAt: new Date(), operatorId: data.operatorId, remark: data.remark, }, }); await this.applyAccountDelta({ tenantId: data.tenantId, transactionType: 'recharge', amountCents, relatedType: 'recharge_order', relatedId: order.id, remark: data.remark, }); return order; } async createManualRecharge(data: CreateManualRechargeDto) { assertMoneyUnits(data.amountCents, '充值金额', { allowNegative: true, allowZero: false }); const idempotencyKey = data.idempotencyKey?.trim(); if (!idempotencyKey || idempotencyKey.length < 8 || idempotencyKey.length > 128) { throw new BadRequestException('人工充值幂等键长度必须为 8 至 128 个字符'); } const expectedUpdatedAt = new Date(data.expectedAccountUpdatedAt); if (Number.isNaN(expectedUpdatedAt.getTime())) throw new BadRequestException('账户版本无效,请重新核对充值信息'); const replay = await this.prisma.operationLog.findFirst({ where: { action: 'billing.manual_recharge', detail: { path: ['idempotencyKey'], equals: idempotencyKey } }, orderBy: { createdAt: 'desc' }, }); if (replay) return this.manualRechargeReplay(replay, data); return this.prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'manual-recharge:' + idempotencyKey}))`; const existing = await tx.operationLog.findFirst({ where: { action: 'billing.manual_recharge', detail: { path: ['idempotencyKey'], equals: idempotencyKey } }, orderBy: { createdAt: 'desc' }, }); if (existing) { const detail = asRecord(existing.detail); if (existing.tenantId !== data.tenantId || Number(detail.amountCents) !== data.amountCents) { throw new ConflictException('该幂等键已用于另一笔人工充值'); } const order = existing.resourceId ? await tx.rechargeOrder.findUnique({ where: { id: existing.resourceId } }) : null; if (!order) throw new ConflictException('幂等充值记录不完整,请联系管理员核查'); const transaction = await tx.accountTransaction.findFirst({ where: { relatedType: 'recharge_order', relatedId: order.id }, orderBy: { createdAt: 'desc' }, }); return { ...order, balanceAfterCents: moneyToNumber(transaction?.balanceAfter), operationId: existing.id, replayed: true }; } const tenant = await tx.tenant.findFirst({ where: { id: data.tenantId, status: { not: 'deleted' } }, select: { id: true } }); if (!tenant) throw new NotFoundException('充值企业不存在或已删除'); const account = await tx.tenantAccount.findUnique({ where: { tenantId: data.tenantId } }); if (!account) throw new ConflictException('企业账户尚未初始化,请重新核对充值信息'); const previousBalanceCents = moneyToNumber(account.balanceCents); const changed = await tx.tenantAccount.updateMany({ where: { tenantId: data.tenantId, updatedAt: expectedUpdatedAt }, data: { balanceCents: { increment: data.amountCents } }, }); if (changed.count !== 1) throw new ConflictException('企业余额已变化,请重新核对后再充值'); const balanceAfterCents = previousBalanceCents + data.amountCents; const order = await tx.rechargeOrder.create({ data: { tenantId: data.tenantId, orderNo: `MR${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, amountCents: data.amountCents, status: 'paid', payMethod: 'manual_topup', paidAt: new Date(), operatorId: data.operatorId, remark: data.remark, }, }); await tx.accountTransaction.create({ data: { tenantId: data.tenantId, transactionType: 'recharge', amountCents: data.amountCents, balanceAfter: balanceAfterCents, relatedType: 'recharge_order', relatedId: order.id, remark: data.remark, }, }); const operation = await tx.operationLog.create({ data: { tenantId: data.tenantId, userId: data.operatorId, action: 'billing.manual_recharge', resource: 'recharge_order', resourceId: order.id, detail: { idempotencyKey, amountCents: data.amountCents, previousBalanceCents, balanceAfterCents, orderNo: order.orderNo, remark: data.remark, } as Prisma.InputJsonValue, }, }); return { ...order, balanceAfterCents, operationId: operation.id, replayed: false }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); } async manualRechargePreflight(data: ManualRechargePreflightDto) { assertMoneyUnits(data.amountCents, '充值金额', { allowNegative: true, allowZero: false }); const tenant = await this.prisma.tenant.findFirst({ where: { id: data.tenantId, status: { not: 'deleted' } }, select: { id: true, name: true, code: true }, }); if (!tenant) throw new NotFoundException('充值企业不存在或已删除'); const account = await this.getAccountOrCreate(data.tenantId); const balanceCents = moneyToNumber(account.balanceCents); return { tenant, accountId: account.id, expectedAccountUpdatedAt: account.updatedAt.toISOString(), balanceCents, creditCents: moneyToNumber(account.creditCents), amountCents: data.amountCents, balanceAfterCents: balanceCents + data.amountCents, direction: data.amountCents > 0 ? 'topup' : 'correction', allowedActions: ['confirm'], blockedReasons: [], }; } private async manualRechargeReplay(log: { id: string; tenantId: string | null; resourceId: string | null; detail: Prisma.JsonValue | null }, data: CreateManualRechargeDto) { const detail = asRecord(log.detail); if (log.tenantId !== data.tenantId || Number(detail.amountCents) !== data.amountCents) { throw new ConflictException('该幂等键已用于另一笔人工充值'); } const order = log.resourceId ? await this.prisma.rechargeOrder.findUnique({ where: { id: log.resourceId } }) : null; if (!order) throw new ConflictException('幂等充值记录不完整,请联系管理员核查'); const transaction = await this.prisma.accountTransaction.findFirst({ where: { relatedType: 'recharge_order', relatedId: order.id }, orderBy: { createdAt: 'desc' }, }); return { ...order, balanceAfterCents: moneyToNumber(transaction?.balanceAfter), operationId: log.id, replayed: true }; } estimateSmsCost(data: EstimateSmsCostDto) { const billingUnits = estimateBillingUnits(data.content); const unitPrice = data.unitPrice ?? 0; assertMoneyUnits(unitPrice, '短信单价'); const totalUnits = billingUnits * data.phoneCount; const amountCents = totalUnits * unitPrice; assertMoneyUnits(amountCents, '短信计费金额'); return { tenantId: data.tenantId, applicationId: data.applicationId, taskId: data.taskId, contentLength: [...data.content].length, phoneCount: data.phoneCount, billingUnitsPerMessage: billingUnits, totalBillingUnits: totalUnits, unitPrice, amountCents, }; } async checkAccount(data: BillingActionDto) { const account = await this.getAccountOrCreate(data.tenantId); const requiredAmount = data.amountCents ?? 0; const balanceCents = moneyToNumber(account.balanceCents); const creditCents = moneyToNumber(account.creditCents); const availableAmount = balanceCents + creditCents; return { tenantId: data.tenantId, requiredAmount, availableAmount, balanceCents, creditCents, canSend: availableAmount >= requiredAmount, }; } freeze(data: BillingActionDto) { return this.applyAccountDelta({ ...data, transactionType: 'frozen', amountCents: -(data.amountCents ?? 0), }); } charge(data: BillingActionDto) { return this.applyAccountDelta({ ...data, transactionType: 'charged', amountCents: -(data.amountCents ?? 0), }); } async settleFrozenCharge(data: { tenantId: string; amountCents: number; messageId: string; taskId: string; remark?: string }) { const amountCents = data.amountCents ?? 0; if (amountCents <= 0) return null; const releaseKey = `sms-charge-release:${data.messageId}`; const chargeKey = `sms-charge:${data.messageId}`; const existing = await this.prisma.accountTransaction.findMany({ where: { idempotencyKey: { in: [releaseKey, chargeKey] } }, }); const charge = existing.find((row) => row.idempotencyKey === chargeKey); if (charge) return charge; const release = existing.find((row) => row.idempotencyKey === releaseKey); if (release) { // Recover the legacy two-transaction boundary: a crash may have committed // release before charge, so this path must perform the missing balance debit. return this.applyAccountDelta({ tenantId: data.tenantId, transactionType: 'charged', idempotencyKey: chargeKey, amountCents: -amountCents, relatedType: 'sms_message_record', relatedId: data.messageId, remark: '提交成功扣费(恢复既有已释放冻结)', }); } const rows = await this.prisma.$queryRaw>(Prisma.sql` WITH account AS ( SELECT "balanceCents" FROM "TenantAccount" WHERE "tenantId" = ${data.tenantId} ), inserted AS ( INSERT INTO "AccountTransaction" ( id, "tenantId", "transactionType", "idempotencyKey", "amountCents", "balanceAfter", "relatedType", "relatedId", remark, "createdAt" ) SELECT gen_random_uuid()::text, ${data.tenantId}, ledger."transactionType", ledger."idempotencyKey", ledger."amountCents", account."balanceCents" + ledger."balanceDelta", ledger."relatedType", ledger."relatedId", ledger.remark, (NOW() AT TIME ZONE 'UTC') FROM account CROSS JOIN (VALUES ('released', ${releaseKey}, ${amountCents}::bigint, ${amountCents}::bigint, 'sms_batch_task', ${data.taskId}, ${data.remark ?? null}), ('charged', ${chargeKey}, ${-amountCents}::bigint, 0::bigint, 'sms_message_record', ${data.messageId}, '提交成功扣费') ) AS ledger("transactionType", "idempotencyKey", "amountCents", "balanceDelta", "relatedType", "relatedId", remark) ON CONFLICT ("idempotencyKey") DO NOTHING RETURNING id, "idempotencyKey" ) SELECT id, "idempotencyKey" FROM inserted WHERE "idempotencyKey" = ${chargeKey} UNION ALL SELECT id, "idempotencyKey" FROM "AccountTransaction" WHERE "idempotencyKey" = ${chargeKey} LIMIT 1 `); if (rows[0]) return rows[0]; // A concurrent identical callback can win ON CONFLICT while remaining // invisible to this statement's snapshot; one read repairs that MVCC edge. return this.prisma.accountTransaction.findUniqueOrThrow({ where: { idempotencyKey: chargeKey } }); } release(data: BillingActionDto) { return this.applyAccountDelta({ ...data, transactionType: 'released', amountCents: data.amountCents ?? 0, }); } refund(data: BillingActionDto) { return this.applyAccountDelta({ ...data, transactionType: 'refunded', amountCents: data.amountCents ?? 0, }); } adjust(data: BillingActionDto) { return this.applyAccountDelta({ ...data, transactionType: 'adjusted', amountCents: data.amountCents ?? 0, }); } async createSmsBillingRecord(data: CreateSmsBillingRecordDto) { const estimate = this.estimateSmsCost({ tenantId: data.tenantId, applicationId: data.applicationId, taskId: data.taskId, content: data.content, phoneCount: 1, unitPrice: data.unitPrice ?? 0, }); return this.prisma.smsBillingRecord.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, taskId: data.taskId, messageId: data.messageId, phoneNumber: data.phoneNumber, contentLength: estimate.contentLength, billingUnits: estimate.billingUnitsPerMessage, unitPrice: estimate.unitPrice, amountCents: estimate.amountCents, billingStatus: 'estimated', }, }); } listSmsBillingRecords(tenantId?: string, taskId?: string) { return this.prisma.smsBillingRecord.findMany({ where: { tenantId, taskId }, orderBy: { createdAt: 'desc' }, }); } listRules() { return this.prisma.billingRule.findMany({ orderBy: { createdAt: 'desc' } }); } createRule(data: CreateBillingRuleDto) { assertMoneyUnits(data.unitPrice, '计费规则单价'); return this.prisma.billingRule.create({ data: { code: data.code, name: data.name, chargeBasis: data.chargeBasis ?? 'submit_success', unitPrice: data.unitPrice, status: data.status ?? 'active', }, }); } private async getAccountOrCreate(tenantId: string) { return this.prisma.tenantAccount.upsert({ where: { tenantId }, update: {}, create: { tenantId, balanceCents: 0, creditCents: 0, status: 'active' }, }); } private async applyAccountDelta(data: CreateAccountTransactionDto) { const amountCents = data.amountCents ?? 0; const idempotencyKey = data.idempotencyKey?.trim() || null; return this.prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + data.tenantId}, 0))`; if (idempotencyKey) { const existing = await tx.accountTransaction.findUnique({ where: { idempotencyKey } }); if (existing) { if ( existing.tenantId !== data.tenantId || existing.transactionType !== data.transactionType || moneyToNumber(existing.amountCents) !== amountCents || existing.relatedType !== (data.relatedType ?? null) || existing.relatedId !== (data.relatedId ?? null) ) { throw new ConflictException('账务幂等键已用于另一笔交易'); } return existing; } } await tx.tenantAccount.upsert({ where: { tenantId: data.tenantId }, update: {}, create: { tenantId: data.tenantId, balanceCents: 0, creditCents: 0, status: 'active' }, }); const account = await tx.tenantAccount.update({ where: { tenantId: data.tenantId }, data: { balanceCents: { increment: amountCents } }, }); return tx.accountTransaction.create({ data: { tenantId: data.tenantId, transactionType: data.transactionType, idempotencyKey, amountCents, balanceAfter: account.balanceCents, relatedType: data.relatedType, relatedId: data.relatedId, remark: data.remark, }, }); }); } } function estimateBillingUnits(content: string) { const length = [...content].length; if (length <= 70) { return 1; } return Math.ceil(length / 67); } function asRecord(value: Prisma.JsonValue | null | undefined): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; }