626 lines
24 KiB
TypeScript
626 lines
24 KiB
TypeScript
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<Array<{ id: string; idempotencyKey: string }>>(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<string, Prisma.JsonValue> {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, Prisma.JsonValue> : {};
|
|
}
|