Legacy never posted order debt; updateRemain recomputed it as SUM(orders.input) WHERE sts > 4. The port posted it once on entering DELIVERED_TO_AGENT, guarded on deliveredAt, which: - posted it twice when an order reached that status again - posted nothing for an order sent straight to DELIVERED_TO_CUSTOMER - kept it after a rollback or cancellation syncOrderDebt compares what the order should owe with the confirmed ORDER_STOCK entries on it and posts only the difference (append-only, idempotent). Status removal runs it too, so removing «تحویل به نماینده» is allowed again, as in legacy. Existing data: 0 orders out of line. Owner approval: 2026-10-08. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
95 lines
4.5 KiB
TypeScript
95 lines
4.5 KiB
TypeScript
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
|
|
import { LedgerService } from './ledger.service';
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
});
|