- 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>
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
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');
|
||
});
|
||
});
|