375 lines
9.8 KiB
TypeScript
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);
|
|
}
|