408 lines
17 KiB
TypeScript
408 lines
17 KiB
TypeScript
import { BillingService } from './billing.service';
|
|
|
|
function createPrismaMock() {
|
|
const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0, updatedAt: new Date('2026-07-21T10:00:00.000Z') };
|
|
const prisma = {
|
|
accountState,
|
|
tenant: {
|
|
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
|
},
|
|
user: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
},
|
|
tenantAccount: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
|
findUniqueOrThrow: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
|
create: jest.fn(),
|
|
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
|
updateMany: jest.fn().mockImplementation(({ data }) => {
|
|
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
|
|
else if (data.balanceCents?.decrement !== undefined) accountState.balanceCents -= data.balanceCents.decrement;
|
|
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
|
|
return Promise.resolve({ count: 1 });
|
|
}),
|
|
update: jest.fn().mockImplementation(({ data }) => {
|
|
if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment;
|
|
else if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
|
|
if (data.creditCents !== undefined) accountState.creditCents = data.creditCents;
|
|
return Promise.resolve({ ...accountState });
|
|
}),
|
|
},
|
|
accountTransaction: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
findFirst: jest.fn(),
|
|
findUnique: jest.fn().mockResolvedValue(null),
|
|
findUniqueOrThrow: jest.fn().mockImplementation(({ where }) => Promise.resolve({ id: 'tx-charged', idempotencyKey: where.idempotencyKey })),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
|
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
|
},
|
|
rechargeOrder: {
|
|
findMany: jest.fn(),
|
|
findUnique: jest.fn(),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'order-1', ...data })),
|
|
},
|
|
smsBillingRecord: {
|
|
findMany: jest.fn(),
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'bill-1', ...data })),
|
|
},
|
|
billingRule: {
|
|
findMany: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
operationLog: {
|
|
findFirst: jest.fn().mockResolvedValue(null),
|
|
create: jest.fn().mockResolvedValue({ id: 'operation-1' }),
|
|
},
|
|
$executeRaw: jest.fn(),
|
|
$queryRaw: jest.fn(),
|
|
};
|
|
return Object.assign(prisma, {
|
|
$transaction: jest.fn((callback: (client: typeof prisma) => unknown) => callback(prisma)),
|
|
});
|
|
}
|
|
|
|
describe('BillingService', () => {
|
|
it('estimates SMS cost using 70/67 billing units', () => {
|
|
const service = new BillingService(createPrismaMock() as never);
|
|
|
|
expect(
|
|
service.estimateSmsCost({ tenantId: 'tenant-1', content: 'a'.repeat(70), phoneCount: 3, unitPrice: 5 }),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
contentLength: 70,
|
|
billingUnitsPerMessage: 1,
|
|
totalBillingUnits: 3,
|
|
amountCents: 15,
|
|
}),
|
|
);
|
|
|
|
expect(
|
|
service.estimateSmsCost({ tenantId: 'tenant-1', content: 'a'.repeat(71), phoneCount: 2, unitPrice: 5 }),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
contentLength: 71,
|
|
billingUnitsPerMessage: 2,
|
|
totalBillingUnits: 4,
|
|
amountCents: 20,
|
|
}),
|
|
);
|
|
|
|
expect(
|
|
service.estimateSmsCost({ tenantId: 'tenant-1', content: '四位小数单价', phoneCount: 2, unitPrice: 325 }),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
unitPrice: 325,
|
|
totalBillingUnits: 2,
|
|
amountCents: 650,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('allows sending only when cash balance plus credit covers the required amount', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new BillingService(prisma as never);
|
|
|
|
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: 1001 })).resolves.toEqual(
|
|
expect.objectContaining({ availableAmount: 1000, canSend: false }),
|
|
);
|
|
await service.updateCreditLimit('tenant-1', { creditCents: -1000, operatorId: 'admin-1' });
|
|
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1 })).resolves.toEqual(
|
|
expect.objectContaining({ availableAmount: 0, balanceCents: 1000, creditCents: -1000, canSend: false }),
|
|
);
|
|
await service.updateCreditLimit('tenant-1', { creditCents: 500, operatorId: 'admin-1' });
|
|
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual(
|
|
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: false }),
|
|
);
|
|
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位');
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'billing.credit_limit_updated',
|
|
detail: expect.objectContaining({ previousCreditCents: 0, creditCents: -1000 }),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('creates cash recharge orders and account transactions without plans', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new BillingService(prisma as never);
|
|
|
|
const order = await service.createRechargeOrder({ tenantId: 'tenant-1', amountCents: 500, remark: 'manual top up' });
|
|
|
|
expect(order).toEqual(expect.objectContaining({ amountCents: 500, status: 'paid' }));
|
|
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
|
|
where: { tenantId: 'tenant-1' },
|
|
data: { balanceCents: { increment: 500 } },
|
|
});
|
|
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
transactionType: 'recharge',
|
|
amountCents: 500,
|
|
balanceAfter: 1500,
|
|
relatedType: 'recharge_order',
|
|
relatedId: 'order-1',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('creates manual recharge records for operator top-ups', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new BillingService(prisma as never);
|
|
|
|
const order = await service.createManualRecharge({
|
|
tenantId: 'tenant-1',
|
|
amountCents: 2000,
|
|
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
|
|
idempotencyKey: 'manual-recharge-1',
|
|
operatorId: 'admin-1',
|
|
remark: '线下转账人工充值',
|
|
});
|
|
|
|
expect(order).toEqual(expect.objectContaining({ amountCents: 2000, payMethod: 'manual_topup', status: 'paid' }));
|
|
expect(prisma.rechargeOrder.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
payMethod: 'manual_topup',
|
|
operatorId: 'admin-1',
|
|
remark: '线下转账人工充值',
|
|
}),
|
|
});
|
|
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
transactionType: 'recharge',
|
|
amountCents: 2000,
|
|
relatedType: 'recharge_order',
|
|
}),
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'billing.manual_recharge',
|
|
resource: 'recharge_order',
|
|
resourceId: 'order-1',
|
|
detail: expect.objectContaining({ idempotencyKey: 'manual-recharge-1', previousBalanceCents: 1000, balanceAfterCents: 3000 }),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('returns the historical balance after each manual recharge', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.rechargeOrder.findMany.mockResolvedValue([
|
|
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000, operatorId: 'admin-1' },
|
|
{ id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 },
|
|
]);
|
|
prisma.user.findMany.mockResolvedValue([{ id: 'admin-1', displayName: '运营人员张三', username: 'admin' }]);
|
|
prisma.accountTransaction.findMany.mockResolvedValue([
|
|
{ relatedId: 'order-1', balanceAfter: 3000 },
|
|
{ relatedId: 'order-2', balanceAfter: 2700 },
|
|
]);
|
|
const service = new BillingService(prisma as never);
|
|
|
|
await expect(service.listManualRechargeRecords()).resolves.toEqual([
|
|
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000, operatorName: '运营人员张三' }),
|
|
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700, operatorName: null }),
|
|
]);
|
|
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
relatedType: 'recharge_order',
|
|
relatedId: { in: ['order-1', 'order-2'] },
|
|
},
|
|
select: { relatedId: true, balanceAfter: true },
|
|
});
|
|
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
|
where: { id: { in: ['admin-1'] } },
|
|
select: { id: true, displayName: true, username: true },
|
|
});
|
|
});
|
|
|
|
it('allows negative manual recharge amounts for balance correction', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new BillingService(prisma as never);
|
|
|
|
const order = await service.createManualRecharge({
|
|
tenantId: 'tenant-1',
|
|
amountCents: -300,
|
|
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
|
|
idempotencyKey: 'manual-correction-1',
|
|
operatorId: 'admin-1',
|
|
remark: '人工冲正',
|
|
});
|
|
|
|
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
|
|
expect(prisma.tenantAccount.updateMany).toHaveBeenCalledWith({
|
|
where: { tenantId: 'tenant-1', updatedAt: new Date('2026-07-21T10:00:00.000Z') },
|
|
data: { balanceCents: { increment: -300 } },
|
|
});
|
|
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
transactionType: 'recharge',
|
|
amountCents: -300,
|
|
balanceAfter: 700,
|
|
relatedType: 'recharge_order',
|
|
}),
|
|
});
|
|
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: 'billing.manual_recharge',
|
|
detail: expect.objectContaining({ amountCents: -300, remark: '人工冲正' }),
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('preflights a manual balance correction with the persisted account version and predicted balance', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new BillingService(prisma as never);
|
|
|
|
await expect(service.manualRechargePreflight({ tenantId: 'tenant-1', amountCents: -250 })).resolves.toEqual(
|
|
expect.objectContaining({
|
|
tenant: { id: 'tenant-1', name: '示例企业', code: 'TENANT-1' },
|
|
expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z',
|
|
balanceCents: 1000,
|
|
amountCents: -250,
|
|
balanceAfterCents: 750,
|
|
direction: 'correction',
|
|
allowedActions: ['confirm'],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('replays a manual recharge by idempotency key without creating another order', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.operationLog.findFirst.mockResolvedValue({
|
|
id: 'operation-existing', tenantId: 'tenant-1', resourceId: 'order-existing',
|
|
detail: { idempotencyKey: 'manual-recharge-replay', amountCents: 2000 },
|
|
});
|
|
prisma.rechargeOrder.findUnique.mockResolvedValue({ id: 'order-existing', tenantId: 'tenant-1', amountCents: 2000 });
|
|
prisma.accountTransaction.findFirst.mockResolvedValue({ balanceAfter: 3000 });
|
|
const service = new BillingService(prisma as never);
|
|
|
|
await expect(service.createManualRecharge({
|
|
tenantId: 'tenant-1', amountCents: 2000, expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z', idempotencyKey: 'manual-recharge-replay',
|
|
})).resolves.toEqual(expect.objectContaining({ operationId: 'operation-existing', replayed: true, balanceAfterCents: 3000 }));
|
|
expect(prisma.rechargeOrder.create).not.toHaveBeenCalled();
|
|
expect(prisma.tenantAccount.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects an outdated account version before creating financial records', async () => {
|
|
const prisma = createPrismaMock();
|
|
prisma.tenantAccount.updateMany.mockResolvedValue({ count: 0 });
|
|
const service = new BillingService(prisma as never);
|
|
|
|
await expect(service.createManualRecharge({
|
|
tenantId: 'tenant-1', amountCents: 2000, expectedAccountUpdatedAt: '2026-07-21T09:59:00.000Z', idempotencyKey: 'manual-recharge-stale',
|
|
})).rejects.toThrow('企业余额已变化,请重新核对后再充值');
|
|
expect(prisma.rechargeOrder.create).not.toHaveBeenCalled();
|
|
expect(prisma.accountTransaction.create).not.toHaveBeenCalled();
|
|
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new BillingService(prisma as never);
|
|
|
|
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',
|
|
'charged',
|
|
'released',
|
|
'refunded',
|
|
'adjusted',
|
|
]);
|
|
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 }));
|
|
});
|
|
|
|
it('settles a frozen SMS charge with one account lock and an idempotent ledger pair', async () => {
|
|
const prisma = createPrismaMock();
|
|
const rows = new Map<string, Record<string, unknown>>();
|
|
prisma.accountTransaction.findMany.mockImplementation(() => Promise.resolve([...rows.values()]));
|
|
prisma.$queryRaw.mockImplementation(() => {
|
|
const charged = { id: 'tx-charged', idempotencyKey: 'sms-charge:msg-paid-1', transactionType: 'charged', amountCents: -325 };
|
|
rows.set(String(charged.idempotencyKey), charged);
|
|
return Promise.resolve([charged]);
|
|
});
|
|
const service = new BillingService(prisma as never);
|
|
const input = { tenantId: 'tenant-1', amountCents: 325, messageId: 'msg-paid-1', taskId: 'task-paid-1' };
|
|
|
|
const first = await service.settleFrozenCharge(input);
|
|
const replay = await service.settleFrozenCharge(input);
|
|
|
|
expect(first).toEqual(expect.objectContaining({ transactionType: 'charged', amountCents: -325 }));
|
|
expect(replay).toEqual(first);
|
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
|
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
|
expect(prisma.tenantAccount.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('serializes and replays concurrent refunds with one balance mutation', async () => {
|
|
const prisma = createPrismaMock();
|
|
let transactionChain = Promise.resolve<unknown>(undefined);
|
|
let persistedTransaction: Record<string, unknown> | null = null;
|
|
prisma.$transaction.mockImplementation((callback) => {
|
|
const run = transactionChain.then(() => callback(prisma));
|
|
transactionChain = run.then(() => undefined, () => undefined);
|
|
return run;
|
|
});
|
|
prisma.accountTransaction.findUnique.mockImplementation(() => Promise.resolve(persistedTransaction));
|
|
prisma.accountTransaction.create.mockImplementation(({ data }) => {
|
|
persistedTransaction = { id: 'tx-refund-once', ...data };
|
|
return Promise.resolve(persistedTransaction);
|
|
});
|
|
const service = new BillingService(prisma as never);
|
|
const refund = {
|
|
tenantId: 'tenant-1',
|
|
amountCents: 1053,
|
|
idempotencyKey: 'sms-refund:MSG-LONG-RACE',
|
|
relatedType: 'sms_message_record',
|
|
relatedId: 'MSG-LONG-RACE',
|
|
remark: '最终失败退款',
|
|
};
|
|
|
|
const results = await Promise.all([
|
|
service.refund(refund),
|
|
service.refund(refund),
|
|
service.refund(refund),
|
|
]);
|
|
|
|
expect(results.map((result) => result.id)).toEqual([
|
|
'tx-refund-once',
|
|
'tx-refund-once',
|
|
'tx-refund-once',
|
|
]);
|
|
expect(prisma.tenantAccount.update).toHaveBeenCalledTimes(1);
|
|
expect(prisma.accountTransaction.create).toHaveBeenCalledTimes(1);
|
|
expect(prisma.accountState.balanceCents).toBe(2053);
|
|
});
|
|
|
|
it('creates SMS billing records linked to message and task identifiers', async () => {
|
|
const prisma = createPrismaMock();
|
|
const service = new BillingService(prisma as never);
|
|
|
|
const record = await service.createSmsBillingRecord({
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
taskId: 'task-1',
|
|
messageId: 'msg-1',
|
|
phoneNumber: '13800000001',
|
|
content: 'a'.repeat(134),
|
|
unitPrice: 4,
|
|
});
|
|
|
|
expect(record).toEqual(
|
|
expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
taskId: 'task-1',
|
|
messageId: 'msg-1',
|
|
billingUnits: 2,
|
|
amountCents: 8,
|
|
billingStatus: 'estimated',
|
|
}),
|
|
);
|
|
});
|
|
});
|