fix: simplify balance billing and govern operation logs
This commit is contained in:
@@ -6,8 +6,6 @@ import {
|
||||
BillingService,
|
||||
BillingActionDto,
|
||||
CreateManualRechargeDto,
|
||||
CreateRechargeOrderDto,
|
||||
CreateBillingPlanDto,
|
||||
CreateBillingRuleDto,
|
||||
CreateSmsBillingRecordDto,
|
||||
CreateTenantAccountDto,
|
||||
@@ -19,16 +17,6 @@ import {
|
||||
export class BillingController {
|
||||
constructor(private readonly billing: BillingService) {}
|
||||
|
||||
@Get('plans')
|
||||
listPlans() {
|
||||
return this.billing.listPlans();
|
||||
}
|
||||
|
||||
@Post('plans')
|
||||
createPlan(@Body() body: CreateBillingPlanDto) {
|
||||
return this.billing.createPlan(body);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
listAccounts() {
|
||||
return this.billing.listAccounts();
|
||||
@@ -44,11 +32,6 @@ export class BillingController {
|
||||
return this.billing.listRechargeOrders(tenantId);
|
||||
}
|
||||
|
||||
@Post('recharges')
|
||||
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
|
||||
return this.billing.createRechargeOrder(body);
|
||||
}
|
||||
|
||||
@Get('manual-recharges')
|
||||
listManualRechargeRecords(@TenantId() tenantId?: string) {
|
||||
return this.billing.listManualRechargeRecords(tenantId);
|
||||
@@ -123,16 +106,6 @@ export class BillingController {
|
||||
export class ClientBillingController {
|
||||
constructor(private readonly billing: BillingService) {}
|
||||
|
||||
@Get('plans')
|
||||
listPlans() {
|
||||
return this.billing.listPlans();
|
||||
}
|
||||
|
||||
@Post('orders')
|
||||
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
|
||||
return this.billing.createRechargeOrder(body);
|
||||
}
|
||||
|
||||
@Get('orders')
|
||||
listRechargeOrders(@TenantId() tenantId?: string) {
|
||||
return this.billing.listRechargeOrders(tenantId);
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const accountState = { tenantId: 'tenant-1', balanceCents: 1000, smsUnits: 20, creditCents: 200 };
|
||||
const accountState = { tenantId: 'tenant-1', balanceCents: 1000 };
|
||||
return {
|
||||
accountState,
|
||||
billingPlan: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
update: jest.fn().mockImplementation(({ data }) => {
|
||||
accountState.balanceCents = data.balanceCents;
|
||||
accountState.smsUnits = data.smsUnits;
|
||||
return Promise.resolve({ ...accountState });
|
||||
}),
|
||||
},
|
||||
@@ -68,38 +62,33 @@ describe('BillingService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('checks balance, credit, and package units before sending', async () => {
|
||||
it('checks only the cash balance before sending', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1100, smsUnits: 20 })).resolves.toEqual(
|
||||
expect.objectContaining({ availableAmount: 1200, availableSmsUnits: 20, canSend: true }),
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 5 })).resolves.toEqual(
|
||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
||||
);
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1300, smsUnits: 20 })).resolves.toEqual(
|
||||
expect.objectContaining({ canSend: false }),
|
||||
);
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 21 })).resolves.toEqual(
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
|
||||
expect.objectContaining({ canSend: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates recharge orders and account transactions from plans', async () => {
|
||||
it('creates cash recharge orders and account transactions without plans', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.billingPlan.findUnique.mockResolvedValue({ id: 'plan-1', priceCents: 500, smsUnits: 100 });
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
const order = await service.createRechargeOrder({ tenantId: 'tenant-1', planId: 'plan-1', remark: 'manual top up' });
|
||||
const order = await service.createRechargeOrder({ tenantId: 'tenant-1', amountCents: 500, remark: 'manual top up' });
|
||||
|
||||
expect(order).toEqual(expect.objectContaining({ amountCents: 500, smsUnits: 100, status: 'paid' }));
|
||||
expect(order).toEqual(expect.objectContaining({ amountCents: 500, status: 'paid' }));
|
||||
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1' },
|
||||
data: { balanceCents: 1500, smsUnits: 120 },
|
||||
data: { balanceCents: 1500 },
|
||||
});
|
||||
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
transactionType: 'recharge',
|
||||
amountCents: 500,
|
||||
smsUnits: 100,
|
||||
balanceAfter: 1500,
|
||||
relatedType: 'recharge_order',
|
||||
relatedId: 'order-1',
|
||||
@@ -114,7 +103,6 @@ describe('BillingService', () => {
|
||||
const order = await service.createManualRecharge({
|
||||
tenantId: 'tenant-1',
|
||||
amountCents: 2000,
|
||||
smsUnits: 0,
|
||||
operatorId: 'admin-1',
|
||||
remark: '线下转账人工充值',
|
||||
});
|
||||
@@ -175,7 +163,6 @@ describe('BillingService', () => {
|
||||
const order = await service.createManualRecharge({
|
||||
tenantId: 'tenant-1',
|
||||
amountCents: -300,
|
||||
smsUnits: 0,
|
||||
operatorId: 'admin-1',
|
||||
remark: '人工冲正',
|
||||
});
|
||||
@@ -183,7 +170,7 @@ describe('BillingService', () => {
|
||||
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
|
||||
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1' },
|
||||
data: { balanceCents: 700, smsUnits: 20 },
|
||||
data: { balanceCents: 700 },
|
||||
});
|
||||
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
@@ -205,11 +192,11 @@ describe('BillingService', () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
await service.freeze({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 2, relatedType: 'sms_batch_task', relatedId: 'task-1' });
|
||||
await service.charge({ tenantId: 'tenant-1', amountCents: 50, smsUnits: 1, relatedType: 'sms_message_record', relatedId: 'msg-1' });
|
||||
await service.release({ tenantId: 'tenant-1', amountCents: 25, smsUnits: 1 });
|
||||
await service.refund({ tenantId: 'tenant-1', amountCents: 10, smsUnits: 1 });
|
||||
await service.adjust({ tenantId: 'tenant-1', amountCents: 5, smsUnits: 0 });
|
||||
await service.freeze({ tenantId: 'tenant-1', amountCents: 100, relatedType: 'sms_batch_task', relatedId: 'task-1' });
|
||||
await service.charge({ tenantId: 'tenant-1', amountCents: 50, relatedType: 'sms_message_record', relatedId: 'msg-1' });
|
||||
await service.release({ tenantId: 'tenant-1', amountCents: 25 });
|
||||
await service.refund({ tenantId: 'tenant-1', amountCents: 10 });
|
||||
await service.adjust({ tenantId: 'tenant-1', amountCents: 5 });
|
||||
|
||||
expect(prisma.accountTransaction.create.mock.calls.map(([arg]) => arg.data.transactionType)).toEqual([
|
||||
'frozen',
|
||||
@@ -218,7 +205,7 @@ describe('BillingService', () => {
|
||||
'refunded',
|
||||
'adjusted',
|
||||
]);
|
||||
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890, smsUnits: 19 }));
|
||||
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
|
||||
});
|
||||
|
||||
it('creates SMS billing records linked to message and task identifiers', async () => {
|
||||
|
||||
@@ -2,20 +2,9 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateBillingPlanDto {
|
||||
name: string;
|
||||
priceCents: number;
|
||||
smsUnits: number;
|
||||
validDays: number;
|
||||
status?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateTenantAccountDto {
|
||||
tenantId: string;
|
||||
balanceCents?: number;
|
||||
smsUnits?: number;
|
||||
creditCents?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -23,7 +12,6 @@ export interface CreateAccountTransactionDto {
|
||||
tenantId: string;
|
||||
transactionType: string;
|
||||
amountCents?: number;
|
||||
smsUnits?: number;
|
||||
balanceAfter?: number;
|
||||
relatedType?: string;
|
||||
relatedId?: string;
|
||||
@@ -40,9 +28,7 @@ export interface CreateBillingRuleDto {
|
||||
|
||||
export interface CreateRechargeOrderDto {
|
||||
tenantId: string;
|
||||
planId?: string;
|
||||
amountCents?: number;
|
||||
smsUnits?: number;
|
||||
amountCents: number;
|
||||
payMethod?: string;
|
||||
operatorId?: string;
|
||||
remark?: string;
|
||||
@@ -51,7 +37,6 @@ export interface CreateRechargeOrderDto {
|
||||
export interface CreateManualRechargeDto {
|
||||
tenantId: string;
|
||||
amountCents: number;
|
||||
smsUnits?: number;
|
||||
operatorId?: string;
|
||||
remark?: string;
|
||||
}
|
||||
@@ -68,7 +53,6 @@ export interface EstimateSmsCostDto {
|
||||
export interface BillingActionDto {
|
||||
tenantId: string;
|
||||
amountCents?: number;
|
||||
smsUnits?: number;
|
||||
relatedType?: string;
|
||||
relatedId?: string;
|
||||
remark?: string;
|
||||
@@ -88,23 +72,6 @@ export interface CreateSmsBillingRecordDto {
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listPlans() {
|
||||
return this.prisma.billingPlan.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
}
|
||||
|
||||
createPlan(data: CreateBillingPlanDto) {
|
||||
return this.prisma.billingPlan.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
priceCents: data.priceCents,
|
||||
smsUnits: data.smsUnits,
|
||||
validDays: data.validDays,
|
||||
status: data.status ?? 'active',
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
listAccounts() {
|
||||
return this.prisma.tenantAccount.findMany({
|
||||
include: { tenant: true },
|
||||
@@ -116,8 +83,6 @@ export class BillingService {
|
||||
const createData: Prisma.TenantAccountUncheckedCreateInput = {
|
||||
tenantId: data.tenantId,
|
||||
balanceCents: data.balanceCents ?? 0,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
creditCents: data.creditCents ?? 0,
|
||||
status: data.status ?? 'active',
|
||||
};
|
||||
return this.prisma.tenantAccount.create({ data: createData });
|
||||
@@ -126,7 +91,6 @@ export class BillingService {
|
||||
listRechargeOrders(tenantId?: string) {
|
||||
return this.prisma.rechargeOrder.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { plan: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
@@ -137,7 +101,6 @@ export class BillingService {
|
||||
tenantId,
|
||||
payMethod: 'manual_topup',
|
||||
},
|
||||
include: { plan: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const orderIds = orders.map((order) => order.id);
|
||||
@@ -161,16 +124,12 @@ export class BillingService {
|
||||
}
|
||||
|
||||
async createRechargeOrder(data: CreateRechargeOrderDto) {
|
||||
const plan = data.planId ? await this.prisma.billingPlan.findUnique({ where: { id: data.planId } }) : null;
|
||||
const amountCents = data.amountCents ?? plan?.priceCents ?? 0;
|
||||
const smsUnits = data.smsUnits ?? plan?.smsUnits ?? 0;
|
||||
const amountCents = data.amountCents;
|
||||
const order = await this.prisma.rechargeOrder.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
planId: data.planId,
|
||||
orderNo: `R${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
|
||||
amountCents,
|
||||
smsUnits,
|
||||
status: 'paid',
|
||||
payMethod: data.payMethod ?? 'manual',
|
||||
paidAt: new Date(),
|
||||
@@ -183,7 +142,6 @@ export class BillingService {
|
||||
tenantId: data.tenantId,
|
||||
transactionType: 'recharge',
|
||||
amountCents,
|
||||
smsUnits,
|
||||
relatedType: 'recharge_order',
|
||||
relatedId: order.id,
|
||||
remark: data.remark,
|
||||
@@ -196,7 +154,6 @@ export class BillingService {
|
||||
const order = await this.createRechargeOrder({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: data.amountCents,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
payMethod: 'manual_topup',
|
||||
operatorId: data.operatorId,
|
||||
remark: data.remark,
|
||||
@@ -210,7 +167,6 @@ export class BillingService {
|
||||
resourceId: order.id,
|
||||
detail: {
|
||||
amountCents: data.amountCents,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
orderNo: order.orderNo,
|
||||
remark: data.remark,
|
||||
} as Prisma.InputJsonValue,
|
||||
@@ -239,15 +195,12 @@ export class BillingService {
|
||||
async checkAccount(data: BillingActionDto) {
|
||||
const account = await this.getAccountOrCreate(data.tenantId);
|
||||
const requiredAmount = data.amountCents ?? 0;
|
||||
const requiredUnits = data.smsUnits ?? 0;
|
||||
const availableAmount = account.balanceCents + account.creditCents;
|
||||
const availableAmount = account.balanceCents;
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
requiredAmount,
|
||||
requiredUnits,
|
||||
availableAmount,
|
||||
availableSmsUnits: account.smsUnits,
|
||||
canSend: availableAmount >= requiredAmount && account.smsUnits >= requiredUnits,
|
||||
canSend: availableAmount >= requiredAmount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,7 +209,6 @@ export class BillingService {
|
||||
...data,
|
||||
transactionType: 'frozen',
|
||||
amountCents: -(data.amountCents ?? 0),
|
||||
smsUnits: -(data.smsUnits ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,7 +217,6 @@ export class BillingService {
|
||||
...data,
|
||||
transactionType: 'charged',
|
||||
amountCents: -(data.amountCents ?? 0),
|
||||
smsUnits: -(data.smsUnits ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,7 +225,6 @@ export class BillingService {
|
||||
...data,
|
||||
transactionType: 'released',
|
||||
amountCents: data.amountCents ?? 0,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,7 +233,6 @@ export class BillingService {
|
||||
...data,
|
||||
transactionType: 'refunded',
|
||||
amountCents: data.amountCents ?? 0,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -292,7 +241,6 @@ export class BillingService {
|
||||
...data,
|
||||
transactionType: 'adjusted',
|
||||
amountCents: data.amountCents ?? 0,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -348,19 +296,17 @@ export class BillingService {
|
||||
return this.prisma.tenantAccount.upsert({
|
||||
where: { tenantId },
|
||||
update: {},
|
||||
create: { tenantId, balanceCents: 0, smsUnits: 0, creditCents: 0, status: 'active' },
|
||||
create: { tenantId, balanceCents: 0, status: 'active' },
|
||||
});
|
||||
}
|
||||
|
||||
private async applyAccountDelta(data: CreateAccountTransactionDto) {
|
||||
const account = await this.getAccountOrCreate(data.tenantId);
|
||||
const nextBalance = account.balanceCents + (data.amountCents ?? 0);
|
||||
const nextUnits = account.smsUnits + (data.smsUnits ?? 0);
|
||||
await this.prisma.tenantAccount.update({
|
||||
where: { tenantId: data.tenantId },
|
||||
data: {
|
||||
balanceCents: nextBalance,
|
||||
smsUnits: nextUnits,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -369,7 +315,6 @@ export class BillingService {
|
||||
tenantId: data.tenantId,
|
||||
transactionType: data.transactionType,
|
||||
amountCents: data.amountCents ?? 0,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
balanceAfter: nextBalance,
|
||||
relatedType: data.relatedType,
|
||||
relatedId: data.relatedId,
|
||||
|
||||
Reference in New Issue
Block a user