import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client'; import { LedgerService } from './ledger.service'; /** * ACC-03 / MON-04 — an order's debt follows its status, exactly as legacy's * recomputed `Σ orders.input WHERE sts > 4`: owed once while delivered (to the * expert or the customer), nothing otherwise, never twice. */ describe('LedgerService.syncOrderDebt', () => { type Entry = { orderId: string; currency: LedgerCurrency; direction: LedgerDirection; amount: Prisma.Decimal; confirmed: boolean }; function build(order: { status: OrderStatus; totalGoldWeightGrams: string; flatRialCharge: string }) { const entries: Entry[] = []; const balance = { gold: new Prisma.Decimal(0), rial: new Prisma.Decimal(0) }; const row = { id: 'o1', customerId: 'c1', ...order }; const tx = { order: { findUnique: jest.fn(async () => row) }, ledgerEntry: { create: jest.fn(async ({ data }: { data: Entry }) => { entries.push(data); return data; }), groupBy: jest.fn(async ({ where }: { where: { orderId: string; currency: LedgerCurrency } }) => { const out: { direction: LedgerDirection; _sum: { amount: Prisma.Decimal } }[] = []; for (const d of [LedgerDirection.DEBIT, LedgerDirection.CREDIT]) { const hit = entries.filter((e) => e.orderId === where.orderId && e.currency === where.currency && e.direction === d && e.confirmed); if (hit.length) out.push({ direction: d, _sum: { amount: hit.reduce((s, e) => s.add(e.amount), new Prisma.Decimal(0)) } }); } return out; }), }, customer: { update: jest.fn(async ({ data }: { data: { goldDebtGrams?: { increment: Prisma.Decimal }; fiatDebtRial?: { increment: Prisma.Decimal } } }) => { if (data.goldDebtGrams) balance.gold = balance.gold.add(data.goldDebtGrams.increment); if (data.fiatDebtRial) balance.rial = balance.rial.add(data.fiatDebtRial.increment); }), }, }; const service = new LedgerService(tx as never); const move = async (status: OrderStatus) => { row.status = status; await service.syncOrderDebt('o1', tx as never); }; return { move, entries, balance }; } it('posts the gold and rial debt once on delivery to the expert', async () => { const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '10.5', flatRialCharge: '2000' }); await t.move(OrderStatus.DELIVERED_TO_AGENT); expect(t.balance.gold.toString()).toBe('10.5'); expect(t.balance.rial.toString()).toBe('2000'); expect(t.entries).toHaveLength(2); }); it('never posts twice when the order reaches delivered again (the ACC-03 bug)', async () => { const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '10.5', flatRialCharge: '0' }); await t.move(OrderStatus.DELIVERED_TO_AGENT); await t.move(OrderStatus.DELIVERED_TO_AGENT); await t.move(OrderStatus.DELIVERED_TO_CUSTOMER); expect(t.balance.gold.toString()).toBe('10.5'); expect(t.entries).toHaveLength(1); }); it('takes the debt off when the order goes back, and puts it back once', async () => { const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '10.5', flatRialCharge: '0' }); await t.move(OrderStatus.DELIVERED_TO_AGENT); await t.move(OrderStatus.PREPARING); expect(t.balance.gold.toString()).toBe('0'); expect(t.entries[1].direction).toBe(LedgerDirection.CREDIT); await t.move(OrderStatus.DELIVERED_TO_AGENT); expect(t.balance.gold.toString()).toBe('10.5'); }); it('posts the debt when an order goes straight to delivered-to-customer', async () => { const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '3', flatRialCharge: '0' }); await t.move(OrderStatus.DELIVERED_TO_CUSTOMER); expect(t.balance.gold.toString()).toBe('3'); }); it('a cancelled delivered order owes nothing', async () => { const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '3', flatRialCharge: '500' }); await t.move(OrderStatus.DELIVERED_TO_AGENT); await t.move(OrderStatus.CANCELLED); expect(t.balance.gold.toString()).toBe('0'); expect(t.balance.rial.toString()).toBe('0'); }); it('posts nothing for an order that was never delivered', async () => { const t = build({ status: OrderStatus.PLACED, totalGoldWeightGrams: '3', flatRialCharge: '500' }); await t.move(OrderStatus.ADMIN_APPROVED); await t.move(OrderStatus.PREPARING); expect(t.entries).toHaveLength(0); }); });