fix(ledger): an order's debt follows its status, as legacy recomputed it (ACC-03)

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>
This commit is contained in:
s.esmaeili
2026-10-08 10:57:38 +03:30
co-authored by Claude Opus 5.5
parent 5d386bf752
commit fb25b2251b
6 changed files with 169 additions and 59 deletions
@@ -0,0 +1,94 @@
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);
});
});
@@ -1,10 +1,15 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma, PrismaClient } from '@prisma/client';
import { LedgerCurrency, LedgerDirection, LedgerSourceType, OrderStatus, Prisma, PrismaClient } from '@prisma/client';
import { GRAMS_PER_MESGHAL, STANDARD_KARAT_PER_MILLE } from '@gold-bank/shared-types';
import { PrismaService } from '../../prisma/prisma.service';
type Tx = Prisma.TransactionClient | PrismaClient;
const ZERO = new Prisma.Decimal(0);
/** Legacy `sts > 4`: delivered to the expert (5) or to the customer (6). */
const OWING_STATUSES: OrderStatus[] = [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER];
export interface PostEntryInput {
customerId: string;
currency: LedgerCurrency;
@@ -103,42 +108,56 @@ export class LedgerService {
}
/**
* Posts the dual-currency debt for a stock order once it reaches
* DELIVERED_TO_AGENT: GOLD_GRAM debt = order.totalGoldWeightGrams, RIAL debt
* = order.flatRialCharge. Both entries are self-confirming — the status
* transition itself is the confirming event (mirrors legacy sts>4, which
* first becomes true at sts=5 "تحویل مسئول فروش" / delivered to agent, not
* sts=6 delivered to customer).
* Brings an order's debt in line with its status — legacy's rule exactly.
*
* Legacy never "posted" order debt; it RECOMPUTED it
* (CustomerController::updateRemain): Σ orders.input WHERE sts > 4, i.e. an
* order owes its full weight (and its rial charge) while it is delivered —
* to the expert (5) or to the customer (6) — and nothing otherwise. So:
* - moving into a delivered status twice never counts twice (ACC-03 /
* MON-04: the old guard on `deliveredAt` let a second DELIVERED_TO_AGENT
* post the debt again);
* - going straight to DELIVERED_TO_CUSTOMER still creates the debt (the
* old trigger fired only on DELIVERED_TO_AGENT, so that order owed nothing);
* - moving back below delivered, or cancelling, takes it off again.
*
* It compares what the order SHOULD owe with the confirmed ORDER_STOCK
* entries already on it and posts only the difference, as a confirmed
* DEBIT or CREDIT — so it is idempotent and nothing is ever edited or
* deleted (the ledger stays append-only). Entries imported from legacy carry
* their orderId, so imported orders reconcile the same way.
*/
async postOrderDebt(orderId: string, tx: Tx = this.prisma) {
async syncOrderDebt(orderId: string, tx: Tx = this.prisma) {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException('Order not found');
await this.postEntry(
{
customerId: order.customerId,
currency: LedgerCurrency.GOLD_GRAM,
direction: LedgerDirection.DEBIT,
sourceType: LedgerSourceType.ORDER_STOCK,
amount: order.totalGoldWeightGrams,
orderId: order.id,
confirmed: true,
note: 'Gold-weight debt on delivery',
},
tx,
);
const owes = OWING_STATUSES.includes(order.status);
const targets: [LedgerCurrency, Prisma.Decimal, string][] = [
[LedgerCurrency.GOLD_GRAM, owes ? new Prisma.Decimal(order.totalGoldWeightGrams) : ZERO, 'Gold-weight debt'],
[LedgerCurrency.RIAL, owes ? new Prisma.Decimal(order.flatRialCharge) : ZERO, 'Flat Rial charge (e.g. stones)'],
];
for (const [currency, should, label] of targets) {
const posted = await tx.ledgerEntry.groupBy({
by: ['direction'],
where: { orderId, currency, sourceType: LedgerSourceType.ORDER_STOCK, confirmed: true },
_sum: { amount: true },
});
const sum = (d: LedgerDirection) => posted.find((p) => p.direction === d)?._sum.amount ?? ZERO;
const current = new Prisma.Decimal(sum(LedgerDirection.DEBIT)).sub(sum(LedgerDirection.CREDIT));
const diff = should.sub(current);
if (diff.isZero()) continue;
if (new Prisma.Decimal(order.flatRialCharge).gt(0)) {
await this.postEntry(
{
customerId: order.customerId,
currency: LedgerCurrency.RIAL,
direction: LedgerDirection.DEBIT,
currency,
direction: diff.gt(0) ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
sourceType: LedgerSourceType.ORDER_STOCK,
amount: order.flatRialCharge,
amount: diff.abs(),
orderId: order.id,
confirmed: true,
note: 'Flat Rial charge (e.g. stones) on delivery',
note: owes ? `${label} — order delivered (${order.status})` : `${label} reversed — order moved to ${order.status}`,
},
tx,
);