From fb25b2251b9485b493a64c5bd157a7f19e93e92e Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Thu, 8 Oct 2026 10:57:38 +0330 Subject: [PATCH] fix(ledger): an order's debt follows its status, as legacy recomputed it (ACC-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../modules/accounting/ledger.service.spec.ts | 94 +++++++++++++++++++ .../src/modules/accounting/ledger.service.ts | 71 +++++++++----- .../admin-orders/admin-orders.module.ts | 3 +- .../admin-orders/admin-orders.service.ts | 42 ++++----- apps/api/src/modules/orders/orders.service.ts | 14 +-- docs/parity/ISSUES.md | 4 +- 6 files changed, 169 insertions(+), 59 deletions(-) create mode 100644 apps/api/src/modules/accounting/ledger.service.spec.ts diff --git a/apps/api/src/modules/accounting/ledger.service.spec.ts b/apps/api/src/modules/accounting/ledger.service.spec.ts new file mode 100644 index 0000000..7d92f7a --- /dev/null +++ b/apps/api/src/modules/accounting/ledger.service.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/accounting/ledger.service.ts b/apps/api/src/modules/accounting/ledger.service.ts index 8bf68b8..d9df811 100644 --- a/apps/api/src/modules/accounting/ledger.service.ts +++ b/apps/api/src/modules/accounting/ledger.service.ts @@ -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, ); diff --git a/apps/api/src/modules/admin-orders/admin-orders.module.ts b/apps/api/src/modules/admin-orders/admin-orders.module.ts index bf089ed..ded9abb 100644 --- a/apps/api/src/modules/admin-orders/admin-orders.module.ts +++ b/apps/api/src/modules/admin-orders/admin-orders.module.ts @@ -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], }) diff --git a/apps/api/src/modules/admin-orders/admin-orders.service.ts b/apps/api/src/modules/admin-orders/admin-orders.service.ts index 5dd2aef..f42950a 100644 --- a/apps/api/src/modules/admin-orders/admin-orders.service.ts +++ b/apps/api/src/modules/admin-orders/admin-orders.service.ts @@ -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; diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index d37896b..bbd6541 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -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. diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index ab90727..f583e12 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -46,7 +46,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | MON-01 | ~~Critical~~ **FIXED (verified 2026-10-08)** | TGT `apps/user-app-v2/src/stores/cartStore.ts` | v2 has **no `capQty` / `maxQty` stock ceiling**, which `user-app` does have (`apps/user-app/src/stores/cartStore.ts:62,65`; `components/product/CartItem.vue:82-83`). A v2 user can order more units than exist. | Verified by grep across both apps — zero hits in v2. | Port the ceiling to v2 (this is also a **Phase 2 parity gap**, row to be added to `02-APPS-MATRIX.md`). | | MON-02 | High | REF `henza_server` wallet package | The wallet `balance` column **mixes grams and toman in one field**, and money operations are not wrapped in DB transactions. | `_inv-ref-server.md` §7. | Separate the units; wrap mutations in transactions. | | MON-03 | ~~High~~ **NO EFFECT (verified 2026-10-08)** — `Prisma.Decimal(4.331802)` converts through the number's shortest decimal string, i.e. exactly `4.331802`; nothing is pre-rounded | TGT `apps/api/src/modules/accounting/ledger.service.ts:189` | `GRAMS_PER_MESGHAL = 4.331802` is a JS **float** used inside an otherwise-`Prisma.Decimal` CASH repayment formula, so the divisor is pre-rounded before the Decimal division. | Read at `ledger.service.ts:182-190`; the source comment already flags the formula for stakeholder review. | Make the constant a `Decimal` (or a string literal passed to `Decimal`). | -| MON-04 | High | TGT `apps/api/src/modules/orders/orders.service.ts` | Debt is recognised at `DELIVERED_TO_AGENT`, but the idempotency guard checks `deliveredAt`, which is only set by `DELIVERED_TO_CUSTOMER` — so the debt trigger is not reliably idempotent. There is also **no status-transition validation** anywhere. | api inventory §8. | Guard on the same field the trigger fires on; add an explicit transition table. | +| MON-04 | ~~High~~ **FIXED 2026-10-08 — see ACC-03** | TGT `apps/api/src/modules/orders/orders.service.ts` | Debt is recognised at `DELIVERED_TO_AGENT`, but the idempotency guard checks `deliveredAt`, which is only set by `DELIVERED_TO_CUSTOMER` — so the debt trigger is not reliably idempotent. There is also **no status-transition validation** anywhere. | api inventory §8. | Guard on the same field the trigger fires on; add an explicit transition table. | | MON-05 | Medium | TGT `apps/api` | `canPlaceOrders` and `verificationStep` are stored and toggled by the admin panel but **never enforced** on any order path. | api inventory §8. | Enforce at order creation, or remove the toggles. | | MON-06 | Medium | TGT `apps/api` | `Setting` rows are never seeded — `wallet.config`, `wallet.birthday`, `promotion.auto`, `mobile_main_slider` only materialise on first write, so a fresh install has undefined behaviour until an admin saves each one. | api inventory §7. | Seed the defaults. | | MON-07 | Medium | REF `henza_server/app/Models/ProductVariant.php:183-190` vs `SingleCustomerOrders.php:67`, `Order.php:66` | Two **divergent order-status enums** coexist (0–6 and 1–7) for what is conceptually one flow. | Read both models. | Unify, or document the mapping explicitly. | @@ -179,7 +179,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` |---|---|---|---|---|---| | **ACC-01** | Medium (unverified) | TGT `accounting-sync/providers/zarrin.provider.ts` | The Zarrin driver follows the published docs only — there is no Zarrin installation to test on. Open points: which sign of `listMandeHesabha` means "owes" (a setting, default negative), whether stones belong in `mablagh` or `mablaghSang` on `foroushSakhte` (sent in `mablagh`), and the exact response envelopes. | Owner: «نه، فعلاً نداریم» (no test install). Unit tests use the docs' sample outputs. | Before real use: a trial on temporary documents on a real install (owner's decision 11), check one balance and one document against the Zarrin UI. | | **ACC-02** | Low (by design) | TGT D27 | Order documents are sent **temporary** (owner ruling), and a temporary Tahesab document does **not** move the balance until the accountant finalises it. Until then the balance the customer sees from the accounting software does not include those orders. | Measured on the Tahesab test server: balance delta 0 for a temporary `VKHKar`, −Vazn×Ayar/750 once final. | Accountant finalises documents promptly; the report marks temporary rows «موقت — هنوز در مانده اثر ندارد». | -| **ACC-03** | **High** (pre-existing, NOT fixed — ledger is protected) | TGT `orders/orders.service.ts` `updateStatus()` | The debt guard is `DELIVERED_TO_AGENT && !order.deliveredAt`, but `deliveredAt` is only set at `DELIVERED_TO_CUSTOMER`. An order moved into DELIVERED_TO_AGENT a second time (e.g. PREPARING → DELIVERED_TO_AGENT again via «تغییر وضعیت») posts the gold and rial debt **twice**. | Read at `orders.service.ts:135-160`. The new accounting hook is not affected (it dedupes on the existing document row). | Guard on an existing `ORDER_STOCK` ledger entry for the order instead of `deliveredAt`; needs owner approval (PROTECTED area). | +| **ACC-03** | ~~**High**~~ **FIXED 2026-10-08 (owner-approved)** — `LedgerService.syncOrderDebt`: on every status change the order's ORDER_STOCK entries are brought to what legacy's recomputed `sts > 4` gives (full weight + rial while DELIVERED_TO_AGENT/CUSTOMER, else 0), posting only the difference as a confirmed DEBIT/CREDIT. Also fixes: straight-to-DELIVERED_TO_CUSTOMER owed nothing; rollback/cancel left the debt. Removing the «تحویل به نماینده» status is allowed again, as in legacy. Existing data: 0 orders out of line in dev and the imported copy. | TGT `orders/orders.service.ts` `updateStatus()` | The debt guard is `DELIVERED_TO_AGENT && !order.deliveredAt`, but `deliveredAt` is only set at `DELIVERED_TO_CUSTOMER`. An order moved into DELIVERED_TO_AGENT a second time (e.g. PREPARING → DELIVERED_TO_AGENT again via «تغییر وضعیت») posts the gold and rial debt **twice**. | Read at `orders.service.ts:135-160`. The new accounting hook is not affected (it dedupes on the existing document row). | Guard on an existing `ORDER_STOCK` ledger entry for the order instead of `deliveredAt`; needs owner approval (PROTECTED area). | | **ACC-04** | Low (pre-existing) | TGT `src/prisma/migrate.ts` | On a completely empty database `runMigrations` fails: `0002_admin_schema.sql` sorts before `20260910113720_first` and alters tables that do not exist yet. Existing databases are unaffected. | Reproduced while preparing the D27 throwaway test database (`relation "City" does not exist`). | Rename to sort after `first`, with a `LEGACY_NAME_ALIASES` entry so applied databases do not re-run it. | | **ACC-05** | Low (behaviour change) | TGT `catalog.service.ts` | D27 rule: a product with **no image** is no longer listed in the app nor orderable — for every product, not only those from the accounting software. | Dev DB on 2026-10-05: 0 of 5 active products affected. | Before release run `select count(*) from "Product" p where p."isActive" and not exists (select 1 from "ProductImage" i where i."productId" = p.id);` on production and photograph those first. | | **ACC-06** | Medium (fixed 2026-10-05) | TGT `accounting-sync/accounting-http.ts` | A real Tahesab install reached by IP could not connect. Tahesab serves its own self-signed certificate issued for `localhost` / `127.0.0.1`, so verification failed (`DEPTH_ZERO_SELF_SIGNED_CERT`), and pasting it as a CA still failed the name check (`ERR_TLS_CERT_ALTNAME_INVALID`). «تست اتصال» also tested only *saved* settings. | Owner's server (`https://:4040`, DBname `tahesabDB5`): token and DB were fine (raw `CheckHealth` = OK). The same server also answers to the default `TahesabDB` — a different database (40 customer groups instead of 19). | Fixed: a pasted certificate is now also a **pin** — exactly that certificate is accepted under any name; the chain check stays on, any other certificate is still name-checked, and verification is never switched off. New «دریافت گواهی از سرور» button shows the SHA-256 fingerprint to compare before pinning; «تست اتصال» uses the form as typed. Verified against the owner's server; `accounting-http.spec.ts`. Admins must check the DB name in the test result. |