Files
lislgosms/api/src/billing/billing.service.ts
T

375 lines
9.8 KiB
TypeScript

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