/** * 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); });