Files
tehran_taj/apps/api/src/modules/accounting/ledger.service.spec.ts
T
mahmoodandClaude Sonnet 5.5 6514b05d18 fix(money,orders): wage/gram-debt parity, atomic document confirm, expert edit, made-to-order, QA fixes
- Rial wage and gram-debt arithmetic matched to the reference; order corrections keep debt in sync
- Document confirm/un-confirm is a compare-and-swap (no double-applied cache under concurrency)
- Settlement document and expert inputs validated (400 instead of 500)
- Expert: edit / activate-deactivate own customers, adjust raw weights only
- Made-to-order flow in the app, PreOrder.quantity migration
- Order grids: customer column, sort keys; panel select search; pre-order quantity save
- Rebuilt release artifacts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:09:02 +03:30

251 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});