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(), 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(), 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(), }; 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>(); prisma.accountTransaction.findUnique.mockImplementation(({ where }) => Promise.resolve(rows.get(where.idempotencyKey) ?? null)); prisma.accountTransaction.findUniqueOrThrow.mockImplementation(({ where }) => Promise.resolve(rows.get(where.idempotencyKey))); prisma.accountTransaction.createMany.mockImplementation(({ data }) => { data.forEach((row: Record) => rows.set(String(row.idempotencyKey), { id: `tx-${row.transactionType}`, ...row })); return Promise.resolve({ count: data.length }); }); 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.accountTransaction.createMany).toHaveBeenCalledTimes(1); expect(prisma.tenantAccount.update).not.toHaveBeenCalled(); }); it('serializes and replays concurrent refunds with one balance mutation', async () => { const prisma = createPrismaMock(); let transactionChain = Promise.resolve(undefined); let persistedTransaction: Record | 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', }), ); }); });