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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
959a272227
commit
6514b05d18
@@ -1,5 +1,35 @@
|
||||
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { LedgerService } from './ledger.service';
|
||||
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
|
||||
@@ -92,3 +122,129 @@ describe('LedgerService.syncOrderDebt', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user