Files
tehran_taj/apps/api/src/modules/accounting/ledger.service.spec.ts
T

251 lines
11 KiB
TypeScript
Raw Normal View History

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<void>((r) => setImmediate(r));
function book(confirmed: boolean) {
const row: Record<string, any> = {
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');
});
});