diff --git a/apps/api/scripts/test-accounting-zarrin.ts b/apps/api/scripts/test-accounting-zarrin.ts new file mode 100644 index 0000000..b161f03 --- /dev/null +++ b/apps/api/scripts/test-accounting-zarrin.ts @@ -0,0 +1,226 @@ +/** + * Accounting integration (D27) — the Zarrin driver against a REAL Zarrin + * installation. No database, no Nest: it calls the driver itself, so it can + * run against the owner's production install without touching the app. + * + * READ-ONLY by default (the install holds real customer data): + * 1. version + account groups (the token is checked by the group listing) + * 2. accounts: no «*» left in names (listhesabha prints «first * last») + * 3. one account found again by its mobile (searchAccount) + * 4. balances: one and many + * 5. statement: its last running balance equals the account's real balance, + * and a range ending in the past agrees with one ending today + * 6. کارساخته: every page of listKarSakhte (100 per page), groups, stock + * 7. change feed (parLastTenRadif): first poll = cursor only + * 8. item movement (infogardeshsakhte, hand-printed JSON) — Zarrin itself + * sometimes answers 500 here; reported, not failed + * + * WRITE test only with ZARRIN_WRITE_TEST=1 and ZARRIN_TEST_ACCOUNT set — on + * the owner's install that is account 953 «تست اپ گلدبانک» (09990001111), + * created for this on 2026-10-12. Zarrin has no API to delete an account or + * a کارساخته, so this script never creates either: + * 9. edit the test account (its group must survive) + * 10. post a TEMPORARY 2-line document (1 g each, 10 %): the balance must move + * by exactly 2.2 g and the rows read as temporary + * 11. delete it: no rows left, balance and stock back where they were + * + * Usage: + * ZARRIN_URL=http://host:port/ZarrinService/accounting ZARRIN_TOKEN=… \ + * [ZARRIN_ACCOUNT=499] [ZARRIN_WRITE_TEST=1 ZARRIN_TEST_ACCOUNT=953] \ + * npx ts-node --transpile-only scripts/test-accounting-zarrin.ts + * + * Requests are paced: the owner's install refused connections once when hit + * back to back. The token is never printed. + */ +import { Prisma } from '@prisma/client'; +import { toJalali } from '../src/modules/accounting-sync/jalali'; +import { ProviderSettings } from '../src/modules/accounting-sync/providers/accounting-provider'; +import { zarrinProvider as z } from '../src/modules/accounting-sync/providers/zarrin.provider'; + +const URL_ = process.env.ZARRIN_URL ?? ''; +const TOKEN = process.env.ZARRIN_TOKEN ?? ''; +const ACCOUNT = process.env.ZARRIN_ACCOUNT ?? ''; +const WRITE = process.env.ZARRIN_WRITE_TEST === '1'; +const TEST_ACCOUNT = process.env.ZARRIN_TEST_ACCOUNT ?? ''; +if (!URL_ || !TOKEN) { + console.error('ZARRIN_URL and ZARRIN_TOKEN are required.'); + process.exit(2); +} +if (WRITE && !TEST_ACCOUNT) { + console.error('ZARRIN_WRITE_TEST=1 needs ZARRIN_TEST_ACCOUNT (a test account the owner approved, e.g. 953).'); + process.exit(2); +} + +const s: ProviderSettings = { baseUrl: URL_, token: TOKEN, caCert: null, timeoutMs: 60_000, balanceOwesWhen: 'negative' }; +const D = (v: string | number) => new Prisma.Decimal(v); +const pause = (ms = 1200) => new Promise((r) => setTimeout(r, ms)); +const day = (n: number) => toJalali(new Date(Date.now() - n * 864e5)); + +let pass = 0; +let fail = 0; +const chk = (c: unknown, m: string) => { + if (c) pass += 1; + else fail += 1; + console.log(` ${c ? 'PASS' : 'FAIL'} ${m}`); +}; +const note = (m: string) => console.log(` NOTE ${m}`); +const step = (t: string) => console.log(`\n${t}`); + +async function main() { + step('1. connection'); + const h = await z.health(s); + chk(h.version !== undefined && Number(h.customerGroups) > 0, `version ${h.version}, ${h.customerGroups} account groups`); + await pause(); + + step('2. accounts'); + const all = (await z.listCustomers(s, null)).items; + chk(all.length > 0, `${all.length} accounts, ${all.filter((c) => c.mobile).length} with a mobile`); + chk(all.every((c) => !c.name.includes('*')), 'no «*» left in any name'); + await pause(); + + const probe = all.find((c) => (ACCOUNT ? c.code === ACCOUNT : !!c.mobile)) ?? all[0]; + const code = probe.code; + step(`3. search by mobile (account ${code})`); + if (probe.mobile) { + const found = await z.findCustomersByMobile(s, probe.mobile); + chk(found.some((c) => c.code === code), `found account ${code} by its mobile`); + } else note('account has no mobile; skipped'); + await pause(); + + step('4. balances'); + const [one] = await z.getBalances(s, [code]); + chk(!!one, `balance of ${code}: ${one?.goldGrams} g, ${one?.rial} rial (app sign: positive = owes)`); + await pause(); + const many = await z.getBalances(s, all.slice(0, 3).map((c) => c.code)); + chk(many.length === 3, `3 balances in one call (${many.length})`); + await pause(); + + step(`5. statement of ${code}`); + const toToday = await z.getStatement(s, { code, from: day(60), to: day(0) }); + await pause(); + const [now] = await z.getBalances(s, [code]); + const last = toToday[toToday.length - 1]; + if (last) { + chk(String(last.goldBalance) === String(now?.goldGrams), `last running balance ${last.goldBalance} = real balance ${now?.goldGrams}`); + await pause(); + const past = await z.getStatement(s, { code, from: day(60), to: day(5) }); + const byKey = new Map(toToday.map((r) => [r.key, String(r.goldBalance)])); + const shared = past.filter((r) => byKey.has(r.key)); + chk(shared.every((r) => byKey.get(r.key) === String(r.goldBalance)), `a range ending 5 days ago agrees on ${shared.length} shared rows`); + note(`${toToday.length} rows in 60 days, ${toToday.filter((r) => r.temporary).length} temporary`); + } else note('no rows in the last 60 days'); + await pause(); + + step('6. کارساخته'); + const items = await z.listKarSakhte(s); + chk(items.length > 0, `${items.length} items (pages of 100 read until the last)`); + await pause(); + const groups = await z.listKarSakhteGroups(s); + chk(groups.length > 0, `${groups.length} item groups`); + await pause(); + const stocked = items.find((i) => i.stockWeight?.gt(0)); + if (stocked) { + const st = await z.getKarSakhteStock(s, stocked); + chk(st.weight !== null || st.count !== null, `stock of «${stocked.name}»: ${st.weight ?? st.count}`); + } + await pause(); + + step('7. change feed'); + const first = await z.getChanges(s, null); + chk(!!first.cursor && first.accountCodes.length === 0, `first poll returns only the cursor (${first.cursor})`); + await pause(); + + step('8. item movement (slow; Zarrin sometimes fails here itself)'); + try { + const mv = await z.getKarSakhteMovement(s, day(3), day(0)); + chk(true, `${mv.length} rows`); + } catch (err) { + note(`not available right now: ${(err as Error).message.slice(0, 160)}`); + } + + if (!WRITE) { + console.log('\nWrite test skipped (set ZARRIN_WRITE_TEST=1 and ZARRIN_TEST_ACCOUNT to run it).'); + return; + } + + step(`9. edit test account ${TEST_ACCOUNT}`); + const acct = all.find((c) => c.code === TEST_ACCOUNT); + if (!acct) throw new Error(`test account ${TEST_ACCOUNT} not found`); + const parts = acct.name.split(' '); + await z.updateCustomer(s, TEST_ACCOUNT, { + firstName: parts.slice(0, -1).join(' ') || acct.name, + lastName: parts.slice(-1)[0] ?? '', + fullName: acct.name, + mobile: acct.mobile ?? '', + phone: acct.phone, + address: `حساب آزمایشی اتصال اپ — آخرین تست ${new Date().toISOString().slice(0, 10)}`, + nationalCode: null, + group: { id: null, name: acct.groupName }, + }); + await pause(); + const after = (await z.listCustomers(s, null)).items.find((c) => c.code === TEST_ACCOUNT); + chk(after?.groupName === acct.groupName, `group kept: ${after?.groupName}`); + await pause(); + + step('10–11. temporary document, then delete'); + const [b0] = await z.getBalances(s, [TEST_ACCOUNT]); + const sell = items.filter((i) => i.typeName === 'گرمی' && i.stockWeight?.gte(2)).slice(0, 2); + if (sell.length < 2) throw new Error('need two gram items with stock ≥ 2 g'); + const stock0: string[] = []; + for (const i of sell) { + await pause(); + stock0.push(String((await z.getKarSakhteStock(s, i)).weight)); + } + let ref = ''; + try { + await pause(); + const res = await z.postOrderDocument( + s, + { + factorCode: 'zarrinwritetest', + customerCode: TEST_ACCOUNT, + date: new Date(), + description: 'تست اتصال اپ', + temporary: true, + lines: sell.map((i) => ({ itemCode: i.code, itemName: i.name, weightGrams: D(1), count: 1, wagePercent: D(10), flatRial: D(0), karat: 750, description: `تست اتصال اپ — ${i.name}` })), + }, + {}, + async (p) => { + if (p.factorId) ref = p.factorId; + }, + ); + ref = res.ref; + await pause(2000); + const [b1] = await z.getBalances(s, [TEST_ACCOUNT]); + chk(b1 && b0 && b1.goldGrams.sub(b0.goldGrams).eq('2.2'), `invoice ${ref}: balance moved ${b1 && b0 ? b1.goldGrams.sub(b0.goldGrams) : '?'} g (expected 2.2)`); + await pause(); + const rows = (await z.getStatement(s, { code: TEST_ACCOUNT, from: day(0), to: day(0) })).filter((r) => r.documentRef === ref); + chk(rows.length > 0 && rows.every((r) => r.temporary), `${rows.length} rows, all temporary`); + } finally { + if (ref) { + await pause(1500); + await z.deleteDocument(s, { factorCode: '', ref, customerCode: TEST_ACCOUNT }); + await pause(2000); + const [b2] = await z.getBalances(s, [TEST_ACCOUNT]); + chk(b2 && b0 && b2.goldGrams.eq(b0.goldGrams), `deleted: balance back to ${b2?.goldGrams}`); + await pause(); + const left = (await z.getStatement(s, { code: TEST_ACCOUNT, from: day(0), to: day(0) })).filter((r) => r.documentRef === ref); + chk(left.length === 0, `no rows of invoice ${ref} left`); + for (const [n, i] of sell.entries()) { + await pause(); + const w = String((await z.getKarSakhteStock(s, i)).weight); + chk(w === stock0[n], `stock of «${i.name}» back to ${stock0[n]} (${w})`); + } + } + } +} + +main() + .catch((err) => { + fail += 1; + console.error(`\nABORTED: ${(err as Error).message}`); + }) + .finally(() => { + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); + }); diff --git a/apps/api/src/modules/accounting-sync/accounting-http.ts b/apps/api/src/modules/accounting-sync/accounting-http.ts index 470a6c5..d7bcc00 100644 --- a/apps/api/src/modules/accounting-sync/accounting-http.ts +++ b/apps/api/src/modules/accounting-sync/accounting-http.ts @@ -86,6 +86,16 @@ function tlsErrorMessage(err: NodeJS.ErrnoException, pinned: boolean): string { } export function httpRequest(req: HttpRequest): Promise { + return send(req, false); +} + +/** + * One attempt. `fresh` = on a new connection: a server that closes idle + * kept-alive connections (Zarrin v228 does) makes the next request on a pooled + * one fail with ECONNRESET / "socket hang up" before it was received. That one + * case is repeated once on a fresh connection; nothing else is ever retried. + */ +function send(req: HttpRequest, fresh: boolean): Promise { let url: URL; try { url = new URL(req.url); @@ -106,6 +116,7 @@ export function httpRequest(req: HttpRequest): Promise { port: url.port || (isHttps ? 443 : 80), path: `${url.pathname}${url.search}`, headers, + ...(fresh ? { agent: false } : {}), }; const pins = isHttps ? pinnedFingerprints(req.ca) : new Set(); if (isHttps) { @@ -176,6 +187,10 @@ export function httpRequest(req: HttpRequest): Promise { }, req.timeoutMs); request.on('error', (err: NodeJS.ErrnoException) => { + if (!fresh && request.reusedSocket && err.code === 'ECONNRESET') { + finish(() => resolve(send(req, true))); + return; + } // Node's own text for these suggests flags that do not apply here. const text = isHttps && err.code && TLS_CERT_ERRORS.test(err.code) ? tlsErrorMessage(err, pins.size > 0) : err.message; finish(() => reject(new AccountingHttpError(text, connected, err.code))); diff --git a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts index fc86815..1c084ab 100644 --- a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts +++ b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts @@ -181,23 +181,37 @@ describe('balances and statement', () => { expect(pos[0].goldGrams.toString()).toBe('-75.409'); }); - it('folds a running balance from «مانده از قبل»', async () => { + it('anchors the running balance on the real balance; «مانده از قبل» is not a movement', async () => { + // 1) the range, opening row first (seen on v228: its figures are not a balance) http.mockReturnValueOnce( reply({ state: true, detail: 'OK', data: [ - { idRadif: '0', idFactor: '0', idHesab: '81', sharh: 'مانده از قبل', vaznTarazooPardakht: '10.0', vaznTarazooDaryaft: '0.0', poolPardakht: '0', poolDaryaft: '0' }, - { idRadif: '470', idFactor: '221', idHesab: '81', noeRadif: 'چک', tarikh: '04/05/29', vaznTarazooPardakht: '0.0', vaznTarazooDaryaft: '2.5', poolPardakht: '810000000', poolDaryaft: '0', ayar: '0' }, + { idRadif: '0', idFactor: '0', idHesab: '81', sharh: 'مانده از قبل', vaznTarazooPardakht: '10.14', vaznTarazooDaryaft: '10.24', poolPardakht: '5', poolDaryaft: '5' }, + { idRadif: '470', idFactor: '221', idHesab: '81', noeRadif: 'چک', noeFactor: 'سند دریافت', tarikh: '04/05/29', vaznTarazooPardakht: '0.0', vaznTarazooDaryaft: '2.5', poolPardakht: '810000000', poolDaryaft: '0', ayar: '0' }, + { idRadif: '471', idFactor: '222', idHesab: '81', noeRadif: 'کار ساخته', noeFactor: 'فاکتور موقت', tarikh: '04/05/30', vaznTarazooPardakht: '1.0', vaznTarazooDaryaft: '0.0', poolPardakht: '0', poolDaryaft: '0', ayar: '750' }, ], }), ); + // 2) the account's balance now: −9.5 g in Zarrin = owes 9.5 + http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '81', geram: '-9.5', pool: '-810000000' }] })); + // 3) what happened after the range + http.mockReturnValueOnce( + reply({ state: true, detail: 'OK', data: [{ idRadif: '0', sharh: 'مانده از قبل' }, { idRadif: '500', idHesab: '81', vaznTarazooPardakht: '1.0', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' }] }), + ); + const rows = await z.getStatement(s, { code: '81', from: { jy: 1404, jm: 5, jd: 1 }, to: { jy: 1404, jm: 5, jd: 31 } }); expect(url().searchParams.get('dateAz')).toBe('2025-07-23'); - expect(rows[0].goldBalance?.toString()).toBe('10'); - expect(rows[1]).toMatchObject({ documentRef: '221', date: '1404/05/29', title: 'چک' }); - expect(rows[1].goldBalance?.toString()).toBe('7.5'); - expect(rows[1].rialBalance?.toString()).toBe('810000000'); + expect(url(1).searchParams.get('kind')).toBe('listMandeHesabha'); + expect(url(2).searchParams.get('dateAz')).toBe('2025-08-23'); // the day after `to` + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ documentRef: '221', date: '1404/05/29', title: 'چک', temporary: false }); + // closing(to) = 9.5 − 1.0 after = 8.5; opening = 8.5 − (−2.5 + 1.0) = 10 + expect(rows[0].goldBalance?.toString()).toBe('7.5'); + expect(rows[0].rialBalance?.toString()).toBe('810000000'); + expect(rows[1]).toMatchObject({ temporary: true }); + expect(rows[1].goldBalance?.toString()).toBe('8.5'); }); }); @@ -283,3 +297,81 @@ describe('change feed (real-time sync)', () => { expect(await z.getChanges(s, null)).toEqual({ cursor: '731', accountCodes: [], itemNames: [], overflow: false }); }); }); + +describe('v228 quirks', () => { + it('reads every page of listKarSakhte (100 per page)', async () => { + const page = (from: number, n: number) => reply({ state: true, msg: 'ok', data: Array.from({ length: n }, (_, i) => ({ id: String(from + i), namFelezsakhthe: `کار ${from + i}`, fkNoe: '16', NoeName: 'گرمی' })) }); + http.mockReturnValueOnce(page(1, 100)).mockReturnValueOnce(page(101, 100)).mockReturnValueOnce(page(201, 7)); + const items = await z.listKarSakhte(s); + expect(items).toHaveLength(207); + expect(url(1).searchParams.get('pageNum')).toBe('2'); + }); + + it('stops when the server ignores pageNum and repeats page 1', async () => { + const page1 = () => reply({ state: true, msg: 'ok', data: Array.from({ length: 100 }, (_, i) => ({ id: String(i + 1), namFelezsakhthe: 'x' })) }); + http.mockReturnValueOnce(page1()).mockReturnValueOnce(page1()); + expect(await z.listKarSakhte(s)).toHaveLength(100); + expect(http).toHaveBeenCalledTimes(2); + }); + + it('accepts an HTTP 400 that carries a good reply (goroohSakhte on v228)', async () => { + http.mockReturnValueOnce(reply({ state: true, detail: 'OK', data: [{ id: '26', name: 'النگو' }] }, 400)); + expect(await z.listKarSakhteGroups(s)).toEqual([{ id: '26', name: 'النگو' }]); + }); + + it('repairs the hand-printed JSON of infogardeshsakhte', async () => { + // Verbatim shape of a v228 reply. + const body = [ + '{', + ' "data": [', + ' {', + ' "code_kar": 19,', + ' "nam_kar": دستبند آروشا,', + ' "tarikh_akharin_gardesh": 05/07/15,', + ' "sharh_akharin_gardesh": دستبند آروشا,', + ' "vazn_akharin_gardesh": -15.12,', + ' "mablagh_akharin_gardesh": 0.0,', + ' },', + ' ]', + '}', + ].join('\n'); + http.mockReturnValueOnce(reply(body)); + const rows = await z.getKarSakhteMovement(s, { jy: 1405, jm: 7, jd: 1 }, { jy: 1405, jm: 7, jd: 20 }); + expect(rows).toEqual([ + expect.objectContaining({ itemCode: '19', itemName: 'دستبند آروشا', date: '1405/07/15', weight: new Prisma.Decimal('-15.12') }), + ]); + }); + + it('drops the «*» listhesabha puts between first and last name', async () => { + const { accountName } = __zarrinInternals; + expect(accountName('آرین * یوسفی')).toBe('آرین یوسفی'); + expect(accountName(' * هزینه هدایا (پیامک گروهی)')).toBe('هزینه هدایا (پیامک گروهی)'); + expect(accountName(' * ')).toBeNull(); + http.mockReturnValueOnce(reply({ data: [{ id: '953', nam: 'تست اپ * گلدبانک', gorooh: 'متفرقه', mobile: '09990001111' }] })); + const { items } = await z.listCustomers(s, null); + expect(items[0]).toMatchObject({ code: '953', name: 'تست اپ گلدبانک', groupName: 'متفرقه', mobile: '09990001111' }); + }); + + it('sees a document posted with movaghat=1 as temporary (shomareSanad «موقت»)', async () => { + http.mockReturnValueOnce( + reply({ + state: true, + data: [ + { idRadif: '71962', idFactor: '16792', idHesab: '953', noeFactor: 'فاکتور فروش', shomareSanad: 'موقت', noeRadif: 'کار ساخته', vaznTarazooPardakht: '1', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' }, + { idRadif: '71710', idFactor: '16722', idHesab: '953', noeFactor: 'فاکتور موقت', shomareSanad: 'باز', vaznTarazooPardakht: '0.1', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' }, + { idRadif: '52961', idFactor: '12282', idHesab: '953', noeFactor: 'فاکتور فروش', shomareSanad: '12282', vaznTarazooPardakht: '1', vaznTarazooDaryaft: '0', poolPardakht: '0', poolDaryaft: '0' }, + ], + }), + ); + http.mockReturnValueOnce(reply({ state: true, data: [{ id: '953', geram: '-2.1', pool: '0' }] })); + const today = toJalaliToday(); + const rows = await z.getStatement(s, { code: '953', from: today, to: today }); + expect(rows.map((r) => r.temporary)).toEqual([true, true, false]); + }); +}); + +function toJalaliToday() { + // Today in Tehran, so the statement needs no "after the range" call. + const { toJalali } = jest.requireActual('../jalali'); + return toJalali(new Date()); +} diff --git a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts index 209de7d..0aa6025 100644 --- a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts +++ b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts @@ -1,6 +1,12 @@ import { Prisma } from '@prisma/client'; import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http'; -import { jalaliToIsoDate, toIsoDateTehran } from '../jalali'; +import { JalaliDate, jalaliToGregorian, jalaliToIsoDate, toIsoDateTehran, toJalali } from '../jalali'; + +const jalaliKey = (d: JalaliDate) => d.jy * 10000 + d.jm * 100 + d.jd; +function nextDay(d: JalaliDate): JalaliDate { + const g = jalaliToGregorian(d.jy, d.jm, d.jd); + return toJalali(new Date(Date.UTC(g.gy, g.gm - 1, g.gd + 1, 12))); +} import { AccountingProvider, AccountingProviderError, @@ -22,12 +28,13 @@ import { * the installation's `http://host:8000/ZarrinService/accounting`. The token * travels in the query string, so a URL built here must never be logged. * - * NOT verified against a live installation — the owner has none to test on - * (plan, decision 11). Everything below follows the documentation and its - * sample outputs, and is covered by unit tests built from those samples. The - * open points (balance sign, which rial field the stones belong in) are - * configurable or flagged, and the integration is to be switched on for real - * only after a trial period on temporary documents. + * Reads verified on a real installation (v228, 870 accounts) on 2026-10-12, + * ACC-01: negative balance = owes; a «فاکتور موقت» moves the balance at once + * (unlike Tahesab); listKarSakhte pages by 100 (pageNum); parvande opens with + * a «مانده از قبل» row that is not a movement; goroohSakhte answers HTTP 400 + * with a good body; infogardeshsakhte answers malformed JSON. The writing + * calls (addAccount, edithesab, foroushSakhte, hazfRadifSanad, + * addFelezsakhteModel) are still unverified. * * Differences from Tahesab that shape the sync: * * No idempotency key on foroushSakhte. A timed-out document call cannot be @@ -40,6 +47,12 @@ import { const STATEMENT_PAGE = 250; const CHANGE_WINDOW = 50; const MAX_STATEMENT_PAGES = 20; +/** listKarSakhte returns this many per page (pageNum=1,2,…). */ +const ITEM_PAGE = 100; +const MAX_ITEM_PAGES = 200; +/** infogardeshsakhte scans every item server-side; seen at 5–33 s. */ +const MOVEMENT_TIMEOUT_MS = 90_000; +const OPENING_ROW = 'مانده از قبل'; const ALL_CODES = { az: 1, ta: 999999 }; const GROUP_SCAN_MAX = 300; const GROUP_SCAN_CONCURRENCY = 10; @@ -85,10 +98,27 @@ function errorBodyText(body: string, token: string) { return text.length > 200 ? `${text.slice(0, 200)}…` : text; } +/** + * infogardeshsakhte prints its JSON by hand: string values unquoted and a + * comma before every closing brace. Its layout is one "key": value per line, + * so each bare value is quoted and the dangling commas dropped. + */ +export function repairZarrinJson(body: string): string { + const lines = body.split(/\r?\n/).map((line) => { + const m = line.match(/^(\s*"[^"]+"\s*:\s*)(.*?)(,?)\s*$/); + if (!m) return line; + const value = m[2].trim(); + if (value === '' || /^(-?\d+(\.\d+)?|true|false|null)$/.test(value) || /^["{[]/.test(value)) return line; + return `${m[1]}${JSON.stringify(value)}${m[3]}`; + }); + return lines.join('\n').replace(/,(\s*[}\]])/g, '$1'); +} + async function call( s: ProviderSettings, kind: string, params: Record = {}, + opts: { timeoutMs?: number; repair?: (body: string) => string } = {}, ): Promise | unknown[]> { let url: URL; try { @@ -104,7 +134,7 @@ async function call( let res; try { - res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: s.timeoutMs, ca: s.caCert }); + res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: opts.timeoutMs ?? s.timeoutMs, ca: s.caCert }); } catch (err) { if (err instanceof AccountingHttpError) { throw new AccountingProviderError(`زرین (${kind}): ${err.message}`, err.ambiguous); @@ -113,13 +143,16 @@ async function call( } if (res.status < 200 || res.status >= 300) { + // v228 answers goroohSakhte with HTTP 400 and a perfectly good body. + const good = goodReply(res.body); + if (good) return good; const reason = errorBodyText(res.body, s.token); throw new ZarrinHttpError(`زرین (${kind}): HTTP ${res.status}${reason ? ` — ${reason}` : ''}`, res.body.slice(0, 500), res.status); } let json: unknown; try { - json = parseLooseJson(res.body); + json = parseLooseJson(opts.repair ? opts.repair(res.body) : res.body); // The documented outputs are JSON-encoded strings of JSON ("{\"state\":…}"). if (typeof json === 'string' && /^\s*[{[]/.test(json)) json = parseLooseJson(json); } catch { @@ -137,6 +170,17 @@ async function call( return json as Record | unknown[]; } +function goodReply(body: string): Record | null { + try { + let j = parseLooseJson(body) as unknown; + if (typeof j === 'string' && /^\s*[{[]/.test(j)) j = parseLooseJson(j); + const o = j as Record | null; + return o && typeof o === 'object' && (o.state === true || o.status === true) && Array.isArray(o.data) ? o : null; + } catch { + return null; + } +} + /** Zarrin wraps lists as `data`, sometimes as `date` (searchAccount), or not at all. */ function listOf(json: unknown): Record[] { if (Array.isArray(json)) return json as Record[]; @@ -169,12 +213,18 @@ function fullJalali(v: unknown): string { return `14${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`; } +function accountName(v: unknown): string | null { + const n = (str(v) ?? '').replace(/\s*\*\s*/g, ' ').replace(/\s+/g, ' ').trim(); + return n || null; +} + async function allAccounts(s: ProviderSettings): Promise { return listOf(await call(s, 'listhesabha', ALL_CODES)) .filter((r) => r.id !== undefined) .map((r) => ({ code: String(r.id), - name: str(r.nam) ?? String(r.id), + // listhesabha prints «first * last» (and « * name» without a first name). + name: accountName(r.nam) ?? String(r.id), mobile: normalizeMobile(r.mobile), phone: str(r.tell), nationalCode: str(r.codeMeli), @@ -184,6 +234,41 @@ async function allAccounts(s: ProviderSettings): Promise { })); } +/** Every کارساخته: listKarSakhte pages by 100 (pageNum), undocumented. */ +async function allKarSakhte(s: ProviderSettings): Promise[]> { + const out: Record[] = []; + const seen = new Set(); + for (let page = 1; page <= MAX_ITEM_PAGES; page += 1) { + const batch = listOf(await call(s, 'listKarSakhte', { pageNum: page })); + // An installation that ignores pageNum repeats page 1: stop there. + const fresh = batch.filter((r) => !seen.has(String(r.id))); + for (const r of fresh) seen.add(String(r.id)); + out.push(...fresh); + if (batch.length < ITEM_PAGE || fresh.length === 0) break; + } + return out; +} + +/** An account's ledger rows (parvande) in a date range, every page, without the opening row. */ +async function ledgerRows(s: ProviderSettings, code: string, from: string, to: string): Promise[]> { + const rows: Record[] = []; + for (let page = 1; page <= MAX_STATEMENT_PAGES; page += 1) { + const batch = listOf(await call(s, 'parvande', { id: code, dateAz: from, dateTa: to, pageNum: page, detailCount: STATEMENT_PAGE })); + rows.push(...batch); + if (batch.length < STATEMENT_PAGE) break; + } + // «مانده از قبل» (idRadif 0) is a summary, not a movement. + return rows.filter((r) => String(r.idRadif ?? '') !== '0' && str(r.sharh) !== OPENING_ROW); +} + +/** What a row adds to what the customer owes (app convention: positive = owes). */ +function owesDelta(r: Record) { + return { + gold: (dec(r.vaznTarazooPardakht) ?? ZERO).sub(dec(r.vaznTarazooDaryaft) ?? ZERO), + rial: (dec(r.poolPardakht) ?? ZERO).sub(dec(r.poolDaryaft) ?? ZERO), + }; +} + function signed(s: ProviderSettings, v: Prisma.Decimal) { // Until checked on a real installation (plan §9) the sign is a setting. return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v; @@ -311,25 +396,30 @@ export const zarrinProvider: AccountingProvider = { }, async getStatement(s, q) { - const rows: Record[] = []; - for (let page = 1; page <= MAX_STATEMENT_PAGES; page += 1) { - const batch = listOf( - await call(s, 'parvande', { - id: q.code, - dateAz: jalaliToIsoDate(q.from), - dateTa: jalaliToIsoDate(q.to), - pageNum: page, - detailCount: STATEMENT_PAGE, - }), - ); - rows.push(...batch); - if (batch.length < STATEMENT_PAGE) break; + const rows = await ledgerRows(s, q.code, jalaliToIsoDate(q.from), jalaliToIsoDate(q.to)); + + // parvande has no running balance and its «مانده از قبل» row cannot be + // read as one, so the balance is anchored on the account's real balance + // now and walked back: closing(to) = now − movements after `to`. + const [now] = await zarrinProvider.getBalances(s, [q.code]); + let closingGold = now?.goldGrams ?? ZERO; + let closingRial = now?.rial ?? ZERO; + const today = toJalali(new Date()); + if (jalaliKey(q.to) < jalaliKey(today)) { + for (const r of await ledgerRows(s, q.code, jalaliToIsoDate(nextDay(q.to)), jalaliToIsoDate(today))) { + const d = owesDelta(r); + closingGold = closingGold.sub(d.gold); + closingRial = closingRial.sub(d.rial); + } + } + let gold = closingGold; + let rial = closingRial; + for (const r of rows) { + const d = owesDelta(r); + gold = gold.sub(d.gold); + rial = rial.sub(d.rial); } - // parvande has no running balance; it opens with «مانده از قبل» and the - // balance is folded from there. پرداخت = we gave = the customer owes more. - let gold = ZERO; - let rial = ZERO; return rows.map((r): StatementRow => { const goldIn = dec(r.vaznTarazooDaryaft) ?? ZERO; const goldOut = dec(r.vaznTarazooPardakht) ?? ZERO; @@ -360,7 +450,9 @@ export const zarrinProvider: AccountingProvider = { goldBalance: gold, rialBalance: rial, metal: null, - temporary: false, + // A document posted with movaghat=1 says so in shomareSanad («موقت»); + // one left open in Zarrin's own UI is a «فاکتور موقت». + temporary: /موقت/.test(`${str(r.noeFactor) ?? ''} ${str(r.shomareSanad) ?? ''}`), }; }); }, @@ -394,14 +486,14 @@ export const zarrinProvider: AccountingProvider = { async listKarSakhteTypes(s) { const types = new Map(); - for (const r of listOf(await call(s, 'listKarSakhte'))) { + for (const r of await allKarSakhte(s)) { if (r.fkNoe !== undefined) types.set(String(r.fkNoe), str(r.NoeName) ?? String(r.fkNoe)); } return [...types].map(([id, name]) => ({ id, name })); }, async listKarSakhte(s) { - return listOf(await call(s, 'listKarSakhte')) + return (await allKarSakhte(s)) .filter((r) => r.id !== undefined && str(r.namFelezsakhthe)) .map((r) => ({ code: String(r.id), @@ -426,11 +518,18 @@ export const zarrinProvider: AccountingProvider = { }, async getKarSakhteMovement(s, from, to) { - return listOf(await call(s, 'infogardeshsakhte', { az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) })).map( + const json = await call( + s, + 'infogardeshsakhte', + { az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) }, + { timeoutMs: Math.max(s.timeoutMs, MOVEMENT_TIMEOUT_MS), repair: repairZarrinJson }, + ); + return listOf(json).map( (r) => ({ itemCode: str(r.code_kar), itemName: str(r.nam_kar) ?? '—', - date: str(r.tarikh_akharin_gardesh) ?? '', + // One row per item: its LAST movement, which may predate the range. + date: fullJalali(r.tarikh_akharin_gardesh), description: str(r.sharh_akharin_gardesh), weight: dec(r.vazn_akharin_gardesh), amount: dec(r.mablagh_akharin_gardesh), @@ -498,15 +597,7 @@ export const zarrinProvider: AccountingProvider = { // one in the account's ledger for the last year. const today = new Date(); const yearAgo = new Date(today.getTime() - 366 * 24 * 3600 * 1000); - const rows = listOf( - await call(s, 'parvande', { - id: doc.customerCode, - dateAz: toIsoDateTehran(yearAgo), - dateTa: toIsoDateTehran(today), - pageNum: 1, - detailCount: STATEMENT_PAGE, - }), - ); + const rows = await ledgerRows(s, doc.customerCode, toIsoDateTehran(yearAgo), toIsoDateTehran(today)); const row = rows.find((r) => String(r.idFactor) === doc.ref && str(r.idRadif) && String(r.idRadif) !== '0'); if (!row) return { response: { alreadyDeleted: true } }; const json = await call(s, 'hazfRadifSanad', { @@ -519,4 +610,4 @@ export const zarrinProvider: AccountingProvider = { }; /** Exposed for tests. */ -export const __zarrinInternals = { listOf, fullJalali }; +export const __zarrinInternals = { listOf, fullJalali, repairZarrinJson, accountName }; diff --git a/docs/ACCOUNTING-INTEGRATION-PLAN.md b/docs/ACCOUNTING-INTEGRATION-PLAN.md index 38f6f1a..e1ce1f2 100644 --- a/docs/ACCOUNTING-INTEGRATION-PLAN.md +++ b/docs/ACCOUNTING-INTEGRATION-PLAN.md @@ -217,3 +217,42 @@ | ثبت سند سفارش | قانون `documents.triggerStatus` در تب «قابلیت‌ها و زمان‌بندی»، کنار کلید ثبت سند: سند **موقت (پیش‌فاکتور)** وقتی سفارش به این وضعیت **یا بعد از آن** برسد ثبت می‌شود (ثبت سفارش، تأیید مدیر، تأمین، آماده‌سازی، تحویل مسئول فروش [پیش‌فرض = رفتار قبلی]، تحویل مشتری). ارسال بلافاصله پس از commit | جزئیات قانون سند: `SUSPENDED` هیچ‌وقت ماشه نیست؛ برگشت وضعیت به زیر قانون (برداشتن وضعیت در ادمین) یا لغو یا **حذف سفارش از ادمین** سند موقت را پس می‌گیرد (حذف فقط اگر هنوز موقت است). در ته‌حساب رسیدن دوباره به قانون سند تازه می‌فرستد (ترتیب صف: اول حذف)؛ در زرین هرگز دو بار (کلید ضد تکرار ندارد). کار حذف سند دیگر به ردیف سفارش نیاز ندارد (کد حساب از درخواست خود سند خوانده می‌شود). ثبت بدهی دفتر داخلی (`postOrderDebt`) دست نخورد. + +## ۱۳. زرین روی نصب واقعی (۱۴۰۵/۰۷/۲۰ — برای بررسی‌های بعدی) + +نصب مالک: زرین نسخه‌ی **۲۲۸**، حدود ۸۷۰ حساب، ۶۵۸ کارساخته. آدرس و توکن نزد مالک است و در مخزن نوشته نمی‌شود. + +### تست دوباره +اسکریپت [apps/api/scripts/test-accounting-zarrin.ts](../apps/api/scripts/test-accounting-zarrin.ts) خودِ درایور را روی نصب واقعی اجرا می‌کند. دیتابیس اپ را دست نمی‌زند. + +``` +ZARRIN_URL=http://:/ZarrinService/accounting ZARRIN_TOKEN= ZARRIN_ACCOUNT=499 \ + npx ts-node --transpile-only scripts/test-accounting-zarrin.ts # فقط خواندن (۱۳ بررسی) +# + ZARRIN_WRITE_TEST=1 ZARRIN_TEST_ACCOUNT=953 # همراه نوشتن (۲۰ بررسی) +``` + +- **حساب آزمایشی ۹۵۳** «تست اپ گلدبانک» (موبایل 09990001111، گروه «متفرقه») با تأیید مالک برای همین تست ساخته شد. نوشتن **فقط روی همین حساب** مجاز است. زرین API حذف حساب و حذف کارساخته ندارد، پس اسکریپت هیچ‌وقت حساب یا کارساخته نمی‌سازد. +- درخواست‌ها با فاصله فرستاده شوند: سرور یک بار وقتی پشت سر هم درخواست گرفت، اتصال را قبول نکرد. +- نتیجه‌ی آخرین اجرا (۱۴۰۵/۰۷/۲۰): ۲۰ از ۲۰. + +### رفتارهایی که در داکیومنت زرین نیست +| موضوع | واقعیت | در درایور | +|---|---|---| +| علامت مانده | منفی = بدهکار | پیش‌فرض صفحه‌ی اتصال درست است | +| سند موقت | **همان لحظه روی مانده اثر دارد** (برخلاف ته‌حساب) | — ؛ قانون ثبت سند را با این در نظر بگیرید | +| موقت بودن سند API | در `shomareSanad` = «موقت»؛ سندی که در خود زرین باز مانده: `noeFactor` = «فاکتور موقت» | هر دو خوانده می‌شود | +| `listKarSakhte` | صفحه‌های ۱۰۰تایی با `pageNum` | همه‌ی صفحه‌ها خوانده می‌شود | +| `listhesabha` | اسم‌ها «نام \* نام‌خانوادگی» | «\*» حذف می‌شود | +| `parvande` | ردیف اول «مانده از قبل» مانده نیست | کنار گذاشته می‌شود؛ مانده‌ی جاری از مانده‌ی واقعی (`listMandeHesabha`) به عقب حساب می‌شود | +| `parLastTenRadif.mandeG` | مانده‌ی **فعلی** حساب روی همه‌ی ردیف‌ها، نه مانده‌ی هر ردیف | برای مقایسه‌ی ردیف‌به‌ردیف قابل استفاده نیست | +| `goroohSakhte` | جواب درست با کد HTTP 400 | پذیرفته می‌شود | +| `infogardeshsakhte` | JSON دست‌نویس معیوب، کند (۵ تا ۳۳ ثانیه)، گاهی خطای ۵۰۰ از خود زرین | تعمیر JSON، مهلت ۹۰ ثانیه | +| اتصال‌های keep-alive | سرور اتصال بیکار را می‌بندد و درخواست بعدی `socket hang up` می‌گیرد | یک بار تکرار روی اتصال تازه | +| `addAccount` با موبایل تکراری | حساب نمی‌سازد | درایور قبل از ساخت با موبایل جستجو می‌کند | + +### تست‌نشده +- ساخت کارساخته از ادمین (`addFelezsakhteModel`): مالک نخواست کارساخته‌ی آزمایشی دائمی ساخته شود. +- سنگ: هزینه‌ی سنگ در `mablagh` فرستاده می‌شود؛ با سندی که سنگ دارد بررسی نشده. + +### امنیت +نصب مالک روی **http بدون رمزنگاری** و روی اینترنت باز است، با توکن ساده. توصیه: توکن قوی و https یا VPN. diff --git a/docs/FRESH-SERVER-DEPLOY-AUDIT.md b/docs/FRESH-SERVER-DEPLOY-AUDIT.md new file mode 100644 index 0000000..765951c --- /dev/null +++ b/docs/FRESH-SERVER-DEPLOY-AUDIT.md @@ -0,0 +1,61 @@ +# بررسی راه‌اندازی سرور با دیتابیس خالی + +> وضعیت: **بررسی فقط خواندنی** انجام شد. هیچ کدی تغییر نکرده و هیچ داده‌ای نوشته یا منتقل نشده است. +> تاریخ: ۲۰۲۶-۱۰-۱۲ + +## هدف + +روی سرور تولید فقط **یک** کاربر super admin باشد و هیچ داده‌ی دیگری (مشتری، محصول، سفارش، بدهی، نقش اضافه، حساب تست) وارد نشود. دیتابیس henza قدیمی در این مرحله منتقل نمی‌شود. + +## یافته‌ها + +### ۱. حساب ثابت در کد (مهم‌ترین مورد) +- در [auth.service.ts:21](../apps/api/src/modules/auth/auth.service.ts#L21) شماره `09128081392` ثابت شده و در [خط ۴۹–۵۳](../apps/api/src/modules/auth/auth.service.ts#L49) اگر شماره همین باشد و رمز هم برابر خود شماره باشد، `ensureStaticAdmin` اجرا می‌شود. +- [خط ۲۳۰–۲۴۵](../apps/api/src/modules/auth/auth.service.ts#L230) یک `SUPER_ADMIN` با نام «مدیر موقت» می‌سازد یا فعال می‌کند. +- این شرط محیطی ندارد، پس روی سرور تولید هم فعال است. +- همین شماره در `seed.ts` به‌عنوان «کاربر تست توسعه» هم آمده است. + +### ۲. داده‌ی demo در seed +- [seed.ts](../apps/api/prisma/seed.ts) بخش demo را فقط وقتی اجرا می‌کند که `NODE_ENV` برابر `production` نباشد یا `SEED_DEMO_DATA=true` باشد. +- بخش demo شامل شهرها، شعبه‌ها، کارگاه، ادمین‌های تست، مشتری، محصول، سفارش و بدهی است. +- [bootstrap.ts](../apps/api/src/prisma/bootstrap.ts) در اولین boot، اگر `RUN_SEED=false` نباشد، seed را اجرا می‌کند. + +### ۳. نقش‌ها و permissionها +- `seed.ts` همیشه کاربرتایپ‌های ۸۵ تا ۸۹ و permissionهایشان را می‌سازد. +- گاردها فقط بر اساس rid کار می‌کنند و برای `SUPER_ADMIN` بای‌پس ندارند ([guards.ts:17](../apps/api/src/modules/auth/guards.ts#L17)). پس نقش ۸۵ با تمام دسترسی‌ها **لازم** است و نقش‌های ۸۶ تا ۸۹ **لازم نیستند**. + +### ۴. migrationهای تغییردهنده‌ی داده +روی دیتابیس خالی بدون اثر هستند (بر اساس خواندن SQL، هنوز روی دیتابیس اجرا نشده‌اند): + +| migration | کار | روی دیتابیس خالی | +|---|---|---| +| `20260922120000_customer_name_parts` | ستون و trigger نام | فقط ساختار | +| `20260922180000_sales_expert_profile_and_admin_name_parts` | `INSERT` از `AdminUser` | no-op | +| `20261005120000_accounting_integration` | permission فقط اگر نقش ۸۵ باشد | no-op | +| `20261008090000_customer_identity_cleanup` | `UPDATE` روی `Customer` و `AdminUser` | no-op | +| `20261008120000_order_sales_agent` | `UPDATE` روی `Order` | no-op | +| `20261008150000_wage_formula_permission` | permission فقط اگر نقش ۸۵ باشد | no-op | +| `20261008160000_dashboard_permission_merge` | `INSERT` و `DELETE` روی permission | no-op | +| `20261008170000_city_tree` | ساخت استان از ردیف‌های موجود | no-op | + +### ۵. شماره‌های ثابت در سرویس حسابداری +`09121234567` و `09891212345` فقط مثال در docstring و تابع نرمال‌سازی هستند ([accounting-provider.ts:272](../apps/api/src/modules/accounting-sync/providers/accounting-provider.ts#L272)، [tahesab.provider.ts:224](../apps/api/src/modules/accounting-sync/providers/tahesab.provider.ts#L224)). داده نیستند. + +## کارهای پیشنهادی (هنوز انجام نشده) + +1. seed فقط نقش ۸۵ با `ALL_RIDS` بسازد؛ نقش‌های ۸۶ تا ۸۹ حذف شوند. +2. حساب ثابت `09128081392` و `ensureStaticAdmin` از `auth.service.ts` حذف شود. +3. روی سرور: `NODE_ENV=production` و `RUN_SEED=false`، و `SEED_DEMO_DATA` ست نشود. +4. یک اسکریپت یک‌بار اجرا برای ساخت super admin: شماره و نام و رمز را کاربر وارد می‌کند؛ داخل کد یا seed ذخیره نمی‌شود. +5. روی یک دیتابیس خالی تست شود: migrationها از صفر اجرا شوند و فقط یک کاربر و نقش ۸۵ باقی بماند. + +## تصمیم‌های باز + +- شماره و نام super admin واقعی. +- نقش‌های ۸۶ تا ۸۹ حذف شوند یا بمانند؟ +- حذف حساب ثابت از کد تأیید شود. + +## بررسی‌نشده + +- سطر به سطر بررسی نشده که migrationها روی دیتابیس واقعی خالی هیچ خطایی ندهند. این را باید با یک اجرای تست روی دیتابیس خالی تأیید کرد. +- ساختار `docs/SERVER-SETUP-CHECKLIST.md` برای تنظیم سرور هنوز مرور نشده است. diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index c167ade..f5f2e28 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -184,7 +184,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | ID | Severity | Where | Description | Evidence | Suggested fix | |---|---|---|---|---|---| -| **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-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. **Update 2026-10-12 — reads verified on the owner's install (v228, 870 accounts, 656 items):** negative balance = owes (default is right); a «فاکتور موقت» moves the balance at once (unlike Tahesab, ACC-02); fixed: `listKarSakhte` pages by 100 via `pageNum` (only 100 of 656 were read), `goroohSakhte` answers HTTP 400 with a good body (was treated as missing), the statement now skips «مانده از قبل» and is anchored on the live balance (its last row now equals Zarrin's balance; ranges agree on shared rows), temporary rows flagged, `infogardeshsakhte`'s hand-printed JSON repaired (+90 s timeout; Zarrin itself sometimes answers 500), document delete searches every ledger page, and a request on a kept-alive connection the server already closed is retried once on a fresh one ("socket hang up"). **Writes verified 2026-10-12** on owner-approved test account 953 «تست اپ گلدبانک» (09990001111, group متفرقه — Zarrin has no API to delete it, so it stays): addAccount, searchAccount finds it, edithesab keeps the group; foroushSakhte (bonak, 2 lines × 1 g, 10 %, movaghat=1) moved the balance by exactly 2.2 g = our acceptance rule, stock fell 1 g per item; hazfRadifSanad (kol=1) removed every row, balance and stock back to start. Fixed from that test: a movaghat=1 document is marked in `shomareSanad` («موقت»), not `noeFactor`; listhesabha names are «first * last» (all 873) — the «*» is now dropped. addFelezsakhteModel NOT tested (owner: no permanent test item). Security: the install is plain http on the internet with token `123456`. | | **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**~~ **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. |