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:
mahmood
2026-10-29 18:09:02 +03:30
co-authored by Claude Sonnet 5.5
parent 959a272227
commit 6514b05d18
321 changed files with 6984 additions and 2170 deletions
@@ -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');
});
});