import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client'; import { LedgerService, repaymentToGrams } from './ledger.service'; /** * The repayment conversions, against the reference's CashController::store * worked by hand: * type 1 value = original_value × ayar / 750 * type 4 value = price / (price_per_gram / 4.331802) */ describe('repaymentToGrams', () => { it('melted gold: 20 g at 900 is 24 g of 750', () => { expect(repaymentToGrams('MELTED_GOLD', '20', 900).toString()).toBe('24'); expect(repaymentToGrams('MELTED_GOLD', '10', 750).toString()).toBe('10'); expect(repaymentToGrams('MELTED_GOLD', '12.5', 916).toDecimalPlaces(4).toString()).toBe('15.2667'); }); it('cash: 50,000,000 at 20,000,000 per mesghal is 10.8295 g', () => { // 50e6 × 4.331802 / 20e6 expect(repaymentToGrams('CASH', '50000000', undefined, '20000000').toDecimalPlaces(6).toString()).toBe('10.829505'); }); it('transfer and cheque are the grams as typed', () => { expect(repaymentToGrams('TRANSFER', '7.25').toString()).toBe('7.25'); expect(repaymentToGrams('CHEQUE', '3').toString()).toBe('3'); }); it('refuses a melted-gold figure without a karat and a cash figure without a price', () => { expect(() => repaymentToGrams('MELTED_GOLD', '20')).toThrow('karatPerMille'); expect(() => repaymentToGrams('CASH', '100')).toThrow('pricePerGramRial'); expect(() => repaymentToGrams('CASH', '100', undefined, '0')).toThrow('pricePerGramRial'); }); }); /** * 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); }); }); /** * `confirmed` on a repayment: an admin's own document counts at once (the * reference's store sets confirmed = 1); the default stays pending. */ describe('LedgerService.postGoldRepayment — confirmed', () => { function build() { const balance = { gold: new Prisma.Decimal(10) }; const created: any[] = []; const tx = { ledgerEntry: { create: jest.fn(async ({ data }: any) => { created.push(data); return { id: 'e1', ...data }; }), }, customer: { update: jest.fn(async ({ data }: any) => { balance.gold = balance.gold.add(data.goldDebtGrams.increment); }), }, }; return { service: new LedgerService(tx as never), balance, created, tx }; } it('confirmed: the debt drops at once by the converted grams', async () => { const t = build(); await t.service.postGoldRepayment( { customerId: 'c1', instrument: 'MELTED_GOLD', rawValue: '20', karatPerMille: 900, confirmed: true }, t.tx as never, ); expect(t.balance.gold.toString()).toBe('-14'); // 10 − 24 expect(t.created[0]).toMatchObject({ confirmed: true }); }); it('by default: pending, the debt does not move', async () => { const t = build(); await t.service.postGoldRepayment({ customerId: 'c1', instrument: 'TRANSFER', rawValue: '3' }, t.tx as never); expect(t.balance.gold.toString()).toBe('10'); expect(t.created[0]).toMatchObject({ confirmed: false }); }); }); /** * Confirming / un-confirming is a flag flip plus a move of the cached balance. * Check-then-update let N simultaneous requests (a double-click, two admins) all * pass the check and all move the balance, leaving the cache N times off the book * (found by firing 10 toggles at one document: book 8.04 g, cache 71.44 g). * The flip is now a compare-and-swap and only its winner moves the balance. */ describe('LedgerService — simultaneous confirm / un-confirm', () => { const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v); const tick = () => new Promise((r) => setImmediate(r)); function book(confirmed: boolean) { const row: Record = { id: 'e1', customerId: 'c1', currency: LedgerCurrency.GOLD_GRAM, direction: LedgerDirection.CREDIT, amount: D(7), confirmed, confirmedAt: null, postedByAdminId: null, }; const balance = { gold: D(confirmed ? -7 : 0) }; // Each call yields first, so concurrent callers interleave exactly as separate requests do. // `updateMany` checks and sets with nothing awaited between them: the row lock of a real UPDATE ... WHERE. const db: any = { ledgerEntry: { findUnique: jest.fn(async () => (await tick(), { ...row })), update: jest.fn(async ({ data }: any) => (await tick(), Object.assign(row, data), { ...row })), updateMany: jest.fn(async ({ where, data }: any) => { await tick(); if (row.id !== where.id || row.confirmed !== where.confirmed) return { count: 0 }; Object.assign(row, data); return { count: 1 }; }), }, customer: { update: jest.fn(async ({ data }: any) => { await tick(); balance.gold = balance.gold.add(data.goldDebtGrams.increment); return {}; }), }, }; db.$transaction = jest.fn(async (fn: any) => fn({ ...db })); // a real transaction client is not the root client return { service: new LedgerService(db as never), row, balance, db }; } it('ten simultaneous confirms of a pending credit take 7 g off the debt once', async () => { const { service, balance, row } = book(false); await Promise.all(Array.from({ length: 10 }, () => service.confirmEntry('e1', 'admin1'))); expect(row.confirmed).toBe(true); expect(balance.gold.toString()).toBe('-7'); }); it('ten simultaneous un-confirms of a confirmed credit put the 7 g back once', async () => { const { service, balance, row } = book(true); const results = await Promise.all(Array.from({ length: 10 }, () => service.setConfirmed('e1', false))); expect(row.confirmed).toBe(false); expect(balance.gold.toString()).toBe('0'); expect(results.filter((r) => r.changed)).toHaveLength(1); }); it('confirming something already confirmed changes nothing', async () => { const { service, balance } = book(true); const r = await service.setConfirmed('e1', true); expect(r.changed).toBe(false); expect(balance.gold.toString()).toBe('-7'); }); it('a confirm then an un-confirm leaves the balance exactly where it started', async () => { const { service, balance } = book(false); await service.setConfirmed('e1', true); await service.setConfirmed('e1', false); expect(balance.gold.toString()).toBe('0'); }); it('an unknown entry is still a 404', async () => { const { service, db } = book(false); db.ledgerEntry.findUnique.mockResolvedValueOnce(null); await expect(service.confirmEntry('nope')).rejects.toThrow('not found'); }); });