import { BadRequestException, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; 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; 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; operatorId?: string; remark?: string; } export interface EstimateSmsCostDto { tenantId: string; applicationId?: string; content: string; phoneCount: number; unitPrice?: number; taskId?: string; } export interface BillingActionDto { tenantId: 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) { assertCreditAmount(data.creditCents ?? 0); 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) { assertCreditAmount(data.creditCents); 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: 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 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 transactions = await this.prisma.accountTransaction.findMany({ where: { relatedType: 'recharge_order', relatedId: { in: orderIds }, }, select: { relatedId: true, balanceAfter: true }, }); const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, transaction.balanceAfter])); return orders.map((order) => ({ ...order, balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null, })); } async createRechargeOrder(data: CreateRechargeOrderDto) { const amountCents = data.amountCents; 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) { const order = await this.createRechargeOrder({ tenantId: data.tenantId, amountCents: data.amountCents, payMethod: 'manual_topup', operatorId: data.operatorId, remark: data.remark, }); await this.prisma.operationLog.create({ data: { tenantId: data.tenantId, userId: data.operatorId, action: 'billing.manual_recharge', resource: 'recharge_order', resourceId: order.id, detail: { amountCents: data.amountCents, orderNo: order.orderNo, remark: data.remark, } as Prisma.InputJsonValue, }, }); return order; } estimateSmsCost(data: EstimateSmsCostDto) { const billingUnits = estimateBillingUnits(data.content); const unitPrice = data.unitPrice ?? 0; const totalUnits = billingUnits * data.phoneCount; return { tenantId: data.tenantId, applicationId: data.applicationId, taskId: data.taskId, contentLength: [...data.content].length, phoneCount: data.phoneCount, billingUnitsPerMessage: billingUnits, totalBillingUnits: totalUnits, unitPrice, amountCents: totalUnits * unitPrice, }; } async checkAccount(data: BillingActionDto) { const account = await this.getAccountOrCreate(data.tenantId); const requiredAmount = data.amountCents ?? 0; const availableAmount = account.balanceCents + account.creditCents; return { tenantId: data.tenantId, requiredAmount, availableAmount, balanceCents: account.balanceCents, creditCents: account.creditCents, canSend: availableAmount > 0, }; } 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), }); } 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) { 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 account = await this.getAccountOrCreate(data.tenantId); const nextBalance = account.balanceCents + (data.amountCents ?? 0); await this.prisma.tenantAccount.update({ where: { tenantId: data.tenantId }, data: { balanceCents: nextBalance, }, }); return this.prisma.accountTransaction.create({ data: { tenantId: data.tenantId, transactionType: data.transactionType, amountCents: data.amountCents ?? 0, balanceAfter: nextBalance, relatedType: data.relatedType, relatedId: data.relatedId, remark: data.remark, }, }); } } function assertCreditAmount(creditCents: number) { if (!Number.isInteger(creditCents)) { throw new BadRequestException('授信额度必须为整数金额(分)'); } } function estimateBillingUnits(content: string) { const length = [...content].length; if (length <= 70) { return 1; } return Math.ceil(length / 67); }