import { Prisma } from '@prisma/client'; import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http'; 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, ExternalCustomer, ProviderSettings, StatementRow, ZERO, dec, normalizeMobile, splitName, str, } from './accounting-provider'; /** * Zarrin (زرین) — https://zarrin.ir/api-docs/ (the page renders * /wp-content/themes/Hirad/data/api.json, 82 entries). * * Wire format: GET ?kind=&token=&, where is * the installation's `http://host:8000/ZarrinService/accounting`. The token * travels in the query string, so a URL built here must never be logged. * * 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. Writes verified * the same day on test account 953 (addAccount, edithesab, foroushSakhte, * hazfRadifSanad); only addFelezsakhteModel is untested. Re-test with * scripts/test-accounting-zarrin.ts; the full list is in plan §13. * * Differences from Tahesab that shape the sync: * * No idempotency key on foroushSakhte. A timed-out document call cannot be * retried blindly — the worker parks it as NEEDS_REVIEW. * * foroushSakhte does not return the row id needed to delete a document; * deleteDocument looks it up from the account's ledger (parvande). * * کارساخته can be created (addFelezsakhteModel) but never edited. */ 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; function endpoint(s: ProviderSettings) { return s.baseUrl.trim(); } class ZarrinHttpError extends AccountingProviderError { constructor( message: string, body: string, readonly status: number, ) { super(message, false, body, status === 404); } } /** * Installations lack some documented kinds (v228 has no Get100Account, * goroohSakhte or getinfoByPhone) and answer them with a bare 400. */ function isMissingKind(err: unknown) { return err instanceof ZarrinHttpError && err.status === 400; } /** * What the server said on a non-2xx reply, short enough for the admin screen. * Error pages may echo the request URL, so the token is scrubbed. */ function errorBodyText(body: string, token: string) { let text = body; try { const j = parseLooseJson(body) as any; const o = typeof j === 'string' && /^\s*[{[]/.test(j) ? parseLooseJson(j) : j; if (o && typeof o === 'object') text = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? str(o.Message) ?? str(o.title) ?? JSON.stringify(o); else if (typeof o === 'string') text = o; } catch { text = body.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' '); } if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***'); text = text.replace(/\s+/g, ' ').trim(); 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 { url = new URL(endpoint(s)); } catch { throw new AccountingProviderError('زرین: آدرس سرور نامعتبر است'); } url.searchParams.set('kind', kind); url.searchParams.set('token', s.token); for (const [k, v] of Object.entries(params)) { if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, String(v)); } let res; try { 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); } throw err; } 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(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 { throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, res.body.slice(0, 500)); } if (!json || typeof json !== 'object') { throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, json); } const o = json as Record; if (o.state === false || o.status === false) { const msg = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? 'خطای نامشخص'; throw new AccountingProviderError(`زرین (${kind}): ${msg}`, false, json, /یافت نشد|موجود نیست/.test(msg)); } 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[]; if (json && typeof json === 'object') { const o = json as Record; for (const key of ['data', 'date', 'mande']) { if (Array.isArray(o[key])) return o[key] as Record[]; } if ('id' in o || 'code' in o) return [o]; } return []; } function dataOf(json: unknown): Record { const o = (json ?? {}) as Record; const d = o.data; if (d && typeof d === 'object' && !Array.isArray(d)) return d as Record; return o; } function joinName(first: unknown, last: unknown) { return [str(first), str(last)].filter(Boolean).join(' ').trim(); } /** Zarrin prints dates as yy/mm/dd of the 1400s: "04/05/29" → "1404/05/29". */ function fullJalali(v: unknown): string { const s = str(v) ?? ''; const m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/); if (!m) return s; 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), // 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), address: str(r.address), groupId: null, groupName: str(r.gorooh), })); } /** 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) { // v228: negative = owes (the default). Kept a setting in case another install differs. return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v; } export const zarrinProvider: AccountingProvider = { name: 'zarrin', label: 'زرین', capabilities: { createKarSakhte: true, documentInquiry: false, movementScope: 'all', changeFeed: true }, async health(s) { const v = (await call(s, 'version')) as Record; // `version` may not check the token; a group listing does. const groups = listOf(await call(s, 'listGorooheHesabha')); return { version: v.version, message: v.message, customerGroups: groups.length }; }, async listCustomerGroups(s) { return listOf(await call(s, 'listGorooheHesabha')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id), })); }, async listCustomers(s) { // listhesabha carries everything (group, mobile, address) and returns the // whole code span in one reply (~865 accounts in under 2 s). return { items: await allAccounts(s), next: null }; }, async findCustomersByMobile(s, mobile) { const target = normalizeMobile(mobile); if (!target) return []; let rows: Record[]; try { // Matches on name or mobile, possibly partially; filtered exactly below. rows = listOf(await call(s, 'searchAccount', { search: target })); } catch (err) { if (err instanceof AccountingProviderError && err.notFound) return []; if (!isMissingKind(err)) throw err; return (await allAccounts(s)).filter((c) => c.mobile === target); } return rows .filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target) .map((r) => ({ code: String(r.code), name: joinName(r.name, r.namKhanevadegi), mobile: target, phone: null, nationalCode: null, address: null, groupId: null, groupName: null, })); }, async createCustomer(s, p) { const json = (await call(s, 'addAccount', { firstName: p.firstName || p.fullName, lastName: p.lastName || '-', fkNoe: p.group.id, mobile: p.mobile, telephone: p.phone, address: p.address, codeMelli: p.nationalCode, })) as Record; const id = json.id ?? dataOf(json).id; if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد حساب جدید برنگشت', false, json); return { code: String(id), response: json }; }, async updateCustomer(s, code, p) { const json = await call(s, 'edithesab', { id: code, name: p.firstName || p.fullName, family: p.lastName, mobile: p.mobile, tel: p.phone, address: p.address, codeMeli: p.nationalCode, // Without nameG Zarrin moves the account into its customers group, so the // current group is always sent back. nameG: p.group.name, }); return { response: json }; }, async getBalances(s, codes) { if (codes.length === 0) return []; const wanted = new Set(codes); const rows = codes.length === 1 ? listOf(await call(s, 'listMandeHesabha', { id: codes[0], isRial: 0 })) : listOf(await call(s, 'listMandeHesabha', { isRial: 0 })); return rows .filter((r) => wanted.has(String(r.id))) .map((r) => ({ code: String(r.id), goldGrams: signed(s, dec(r.geram) ?? ZERO), rial: signed(s, dec(r.pool) ?? ZERO), })); }, async getChanges(s, cursor) { // parLastTenRadif without an account is the newest rows of the whole // journal; each row names its account (idHesab) and has an ascending idRadif. // Seen working on v228 (plan §13). const rows = listOf(await call(s, 'parLastTenRadif', { limit: CHANGE_WINDOW })); let maxId = 0; for (const r of rows) maxId = Math.max(maxId, Number(r.idRadif) || 0); if (cursor === null) return { cursor: String(maxId), accountCodes: [], itemNames: [], overflow: false }; const seen = Number(cursor) || 0; const fresh = rows.filter((r) => (Number(r.idRadif) || 0) > seen); const accounts = new Set(); for (const r of fresh) { const code = str(r.idHesab); if (code) accounts.add(code); } return { cursor: String(Math.max(maxId, seen)), accountCodes: [...accounts], itemNames: [], overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length, }; }, async getStatement(s, q) { 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); } return rows.map((r): StatementRow => { const goldIn = dec(r.vaznTarazooDaryaft) ?? ZERO; const goldOut = dec(r.vaznTarazooPardakht) ?? ZERO; const rialIn = dec(r.poolDaryaft) ?? ZERO; const rialOut = dec(r.poolPardakht) ?? ZERO; gold = gold.add(goldOut).sub(goldIn); rial = rial.add(rialOut).sub(rialIn); const factor = str(r.idFactor); return { key: String(r.idRadif ?? ''), documentRef: factor && factor !== '0' ? factor : '', accountCode: String(r.idHesab ?? q.code), documentNo: factor && factor !== '0' ? factor : '', date: fullJalali(r.tarikh), time: null, title: str(r.noeRadif) ?? str(r.noeFactor) ?? str(r.sharh) ?? '', description: str(r.sharh), itemName: str(r.namFelez), weight: dec(r.vaznDaryaft)?.gt(0) ? dec(r.vaznDaryaft) : dec(r.vaznPardakht), karat: dec(r.ayar)?.toNumber() || null, count: null, unitPrice: null, wagePercent: null, goldIn, goldOut, rialIn, rialOut, goldBalance: gold, rialBalance: rial, metal: null, // 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) ?? ''}`), }; }); }, async listKarSakhteGroups(s) { try { return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) })); } catch (err) { if (!isMissingKind(err)) throw err; } // Without goroohSakhte the only way is one infogorooh per id. Ids are // sparse and unknown ids fail (some with a 500), so failures are skipped. const found: { id: string; name: string }[] = []; let next = 1; const worker = async () => { while (next <= GROUP_SCAN_MAX) { const id = next++; try { for (const g of listOf(await call(s, 'infogorooh', { id }))) { const name = str(g.nam); if (name) found.push({ id: String(g.code ?? id), name }); } } catch { // not a group } } }; await Promise.all(Array.from({ length: GROUP_SCAN_CONCURRENCY }, worker)); return found.sort((a, b) => Number(a.id) - Number(b.id)); }, async listKarSakhteTypes(s) { const types = new Map(); 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 (await allKarSakhte(s)) .filter((r) => r.id !== undefined && str(r.namFelezsakhthe)) .map((r) => ({ code: String(r.id), name: str(r.namFelezsakhthe) as string, // listKarSakhte carries the item's type (گرمی/تعدادی…) but not its // group (النگو/انگشتر…); category stays the app's call (D27). groupId: null, groupName: null, typeId: str(r.fkNoe), typeName: str(r.NoeName), saleWagePercent: dec(r.darsadForoosh), stockWeight: dec(r.vazn), stockCount: dec(r.tedad), })); }, async getKarSakhteStock(s, item) { const d = dataOf(await call(s, 'mojoodiKala', { id: item.code, dateTa: toIsoDateTehran(new Date()) })); const qty = dec(d.mojoodi); const byWeight = /گرم/.test(String(d.noeName ?? 'گرمی')); return { weight: byWeight ? qty : null, count: byWeight ? null : qty }; }, async getKarSakhteMovement(s, from, to) { 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) ?? '—', // 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), count: null, }), ); }, async createKarSakhte(s, item) { const json = (await call(s, 'addFelezsakhteModel', { nameKar: item.name, noeKar: item.typeId, gorooheKar: item.groupId, })) as Record; const id = json.idKar ?? dataOf(json).idKar; if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد کارساخته‌ی جدید برنگشت', false, json); return { code: String(id), response: json }; }, async postOrderDocument(s, doc, progress, onProgress) { let factorId = progress.factorId; let done = progress.doneLines ?? 0; let last: unknown = null; for (let i = done; i < doc.lines.length; i += 1) { const line = doc.lines[i]; const isLast = i === doc.lines.length - 1; last = await call(s, 'foroushSakhte', { // The first call opens the invoice for the account; the rest append. ...(factorId ? { idFactor: factorId } : { idHesab: doc.customerCode }), idSakhte: line.itemCode, // Bonak mode: mablagh is the rial charge, darsad the per-gram making charge. mablagh: line.flatRial.toFixed(0), sharh: line.description, vazn: line.weightGrams.toFixed(3), vaznSang: 0, mablaghSang: 0, pd: -1, // پرداخت: goods leave to the customer darsad: line.wagePercent.toString(), // The invoice has to stay open while lines are still to come; the last // line closes it unless the document is to stay temporary (D27: it is). movaghat: doc.temporary || !isLast ? 1 : 0, bonak: 1, }); const id = (dataOf(last).idFactor ?? (last as Record).idFactor) as unknown; if (!factorId) { if (id === undefined || id === null) throw new AccountingProviderError('زرین: شماره فاکتور برنگشت', true, last); factorId = String(id); } done = i + 1; await onProgress({ factorId, doneLines: done }); } return { ref: String(factorId ?? ''), progress: { factorId, doneLines: done }, response: last }; }, async documentExists() { // No lookup by our own key exists in Zarrin's API. return false; }, async deleteDocument(s, doc) { if (!doc.ref) throw new AccountingProviderError('زرین: شماره فاکتور سند معلوم نیست'); // hazfRadifSanad needs a row id, which foroushSakhte never returned; find // 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 = 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', { pkRadifSanad: String(row.idRadif), kol: 1, commentUser: 'لغو سفارش در اپ', }); return { response: json }; }, }; /** Exposed for tests. */ export const __zarrinInternals = { listOf, fullJalali, repairZarrinJson, accountName };