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:
co-authored by
Claude Opus 5.5
parent
5d386bf752
commit
fb25b2251b
@@ -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,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { OrdersModule } from '../orders/orders.module';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
import { AdminOrdersController } from './admin-orders.controller';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, OrdersModule],
|
||||
imports: [PrismaModule, OrdersModule, AccountingModule],
|
||||
controllers: [AdminOrdersController],
|
||||
providers: [AdminOrdersService],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import type { UpdateOrderStatusDto } from './update-status.dto';
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,7 @@ export class AdminOrdersService {
|
||||
private readonly table: TableService,
|
||||
private readonly orders: OrdersService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
private readonly ledger: LedgerService,
|
||||
) {}
|
||||
|
||||
/** Fields the XTable filter bar and sorter may reference. */
|
||||
@@ -371,8 +373,8 @@ export class AdminOrdersService {
|
||||
* Legacy `POST single/order-sts/edit` — advance or remove a status.
|
||||
*
|
||||
* Setting a status delegates to OrdersService.updateStatus rather than writing
|
||||
* the row here, because that method also posts the gold debt to the ledger at
|
||||
* DELIVERED_TO_AGENT. Duplicating the update would silently skip that.
|
||||
* the row here, because that method also keeps the order's debt in line with its status
|
||||
* (syncOrderDebt). Duplicating the update would silently skip that.
|
||||
*/
|
||||
async setStatus(dto: UpdateOrderStatusDto, actorAdminUserId: string) {
|
||||
if (dto.remove) {
|
||||
@@ -384,35 +386,33 @@ export class AdminOrdersService {
|
||||
/**
|
||||
* Legacy's `remove: true` branch: drop a recorded status event.
|
||||
*
|
||||
* Deliberately refuses to remove DELIVERED_TO_AGENT. In legacy, deleting that
|
||||
* row left the posted debt ledger entries behind, so the customer stayed in
|
||||
* debt for an order no longer marked delivered. Rather than reproduce a
|
||||
* data-corrupting path, this rejects and explains — recorded in
|
||||
* docs/ADMIN-PORT-PLAN.md.
|
||||
* Any status may be removed, as in legacy (MajorOrderController.php:180-203),
|
||||
* which stepped `sts` back and then RECOMPUTED the debt (updateRemain). The
|
||||
* rollback below does the same through syncOrderDebt, so taking an order
|
||||
* back below «تحویل به نماینده» takes its debt off, and nothing is left
|
||||
* behind. (This used to be refused, on the belief that legacy left the debt
|
||||
* in place; legacy recomputed it.)
|
||||
*/
|
||||
private async removeStatusEvent(orderId: string, status: OrderStatus) {
|
||||
if (status === OrderStatus.DELIVERED_TO_AGENT) {
|
||||
throw new BadRequestException(
|
||||
'حذف وضعیت «تحویل به نماینده» ممکن نیست، چون بدهی طلا در همین مرحله ثبت شده است.',
|
||||
);
|
||||
}
|
||||
|
||||
const event = await this.prisma.orderStatusEvent.findFirst({
|
||||
where: { orderId, toStatus: status },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد');
|
||||
|
||||
await this.prisma.orderStatusEvent.delete({ where: { id: event.id } });
|
||||
|
||||
// Roll the order back to whatever status is now the latest recorded one.
|
||||
const latest = await this.prisma.orderStatusEvent.findFirst({
|
||||
where: { orderId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const rolledBackTo = latest?.toStatus ?? OrderStatus.PLACED;
|
||||
// One transaction: the event, the status and the debt move together or not at all.
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.orderStatusEvent.delete({ where: { id: event.id } });
|
||||
|
||||
// Roll the order back to whatever status is now the latest recorded one.
|
||||
const latest = await tx.orderStatusEvent.findFirst({
|
||||
where: { orderId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const rolledBackTo = latest?.toStatus ?? OrderStatus.PLACED;
|
||||
const updated = await tx.order.update({ where: { id: orderId }, data: { status: rolledBackTo } });
|
||||
// Legacy updateRemain after the step back: the debt follows the status.
|
||||
await this.ledger.syncOrderDebt(orderId, tx);
|
||||
// D27 — rolled back below the document rule: the پیشفاکتور is withdrawn.
|
||||
await this.accounting.orderStatusReached(tx, orderId, rolledBackTo);
|
||||
return updated;
|
||||
|
||||
@@ -31,7 +31,7 @@ export class OrdersService {
|
||||
* Places a stock order. Per line item:
|
||||
* w = rashWeightGrams * (1 + (wagePercent - discountPercent) / 100)
|
||||
* mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here —
|
||||
* only once the order reaches DELIVERED_TO_AGENT (see updateStatus).
|
||||
* only while the order is delivered (see updateStatus / syncOrderDebt).
|
||||
*/
|
||||
/**
|
||||
* @param customerId taken from the caller's token by the controller, NEVER
|
||||
@@ -152,14 +152,10 @@ export class OrdersService {
|
||||
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminUserId },
|
||||
});
|
||||
|
||||
// Debt recognition point — mirrors legacy sts>4, which first becomes true
|
||||
// at sts=5 ("تحویل مسئول فروش" / delivered to agent), not sts=6 (delivered
|
||||
// to customer). Only fires once: a second transition into this status
|
||||
// (e.g. a data-fix re-save) would double-post debt, so guard on the order
|
||||
// not already having deliveredAt.
|
||||
if (newStatus === OrderStatus.DELIVERED_TO_AGENT && !order.deliveredAt) {
|
||||
await this.ledger.postOrderDebt(orderId, tx);
|
||||
}
|
||||
// Debt follows the status, as legacy's recomputed `sts > 4` did: owed
|
||||
// while delivered (to the expert or the customer), not otherwise, never
|
||||
// twice. See LedgerService.syncOrderDebt (ACC-03 / MON-04).
|
||||
await this.ledger.syncOrderDebt(orderId, tx);
|
||||
|
||||
// D27 — the accounting document (پیشفاکتور) follows the owner's rule on
|
||||
// which status posts it, queued in this same transaction.
|
||||
|
||||
Reference in New Issue
Block a user