feat: harden platform workflows and UI governance
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0 };
|
||||
return {
|
||||
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' }),
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: 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;
|
||||
accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1);
|
||||
return Promise.resolve({ count: 1 });
|
||||
}),
|
||||
update: jest.fn().mockImplementation(({ data }) => {
|
||||
if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
|
||||
if (data.creditCents !== undefined) accountState.creditCents = data.creditCents;
|
||||
@@ -16,10 +25,12 @@ function createPrismaMock() {
|
||||
},
|
||||
accountTransaction: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
|
||||
},
|
||||
rechargeOrder: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'order-1', ...data })),
|
||||
},
|
||||
smsBillingRecord: {
|
||||
@@ -31,9 +42,14 @@ function createPrismaMock() {
|
||||
create: jest.fn(),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
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', () => {
|
||||
@@ -129,6 +145,8 @@ describe('BillingService', () => {
|
||||
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: '线下转账人工充值',
|
||||
});
|
||||
@@ -153,6 +171,7 @@ describe('BillingService', () => {
|
||||
action: 'billing.manual_recharge',
|
||||
resource: 'recharge_order',
|
||||
resourceId: 'order-1',
|
||||
detail: expect.objectContaining({ idempotencyKey: 'manual-recharge-1', previousBalanceCents: 1000, balanceAfterCents: 3000 }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -189,14 +208,16 @@ describe('BillingService', () => {
|
||||
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.update).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1' },
|
||||
data: { balanceCents: 700 },
|
||||
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({
|
||||
@@ -214,6 +235,53 @@ describe('BillingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user