import { Prisma } from '@prisma/client'; import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http'; import { formatJalali, toJalali } from '../jalali'; import { AccountingNotSupportedError, AccountingProvider, AccountingProviderError, ExternalCustomer, ProviderSettings, StatementRow, ZERO, dec, normalizeMobile, str, } from './accounting-provider'; /** * Tahesab (ته‌حساب) — https://tahesab.ir/APiDoc/ (OpenAPI 3.1, API 10.1405.x). * * Wire format, confirmed against the public test server on 2026-10-05: * POST / body {"": [args by position]} * headers Authorization: Bearer , DBname: TahesabDB (SQL Server) | DB (Access) * The server dispatches on the body key; the path in the docs is cosmetic. * Errors come back as {"ERROR": "..."} with HTTP 400/404. * * Behaviour measured on the test server (plan §9), which this driver relies on: * * Balance sign: an exit (خروج) LOWERS MandeyeVazni — negative = the * customer owes, as henza_server's `* -1` assumed. Flipped here. * * A temporary (موقت) document does not move the balance until the * accountant finalises it. * * The gold a document moves is Vazn × Ayar / 750. Darsad_Talayee and * Ojrat are stored but do NOT move the balance, so the making charge is sent * as a raised Ayar (750 at 10% → 825); Vazn stays the physical weight. * * PoolSang does not move the rial balance; MablaghKol does. * * Factor_Code is stored lowercased; re-sending a row with the same * Factor_Code + Radif_Number is rejected as a duplicate key — which is what * makes a retry safe. */ const CUSTOMER_PAGE = 500; /** Consecutive empty code windows before the account list is considered done. */ const EMPTY_WINDOWS_TO_STOP = 4; const MAX_CUSTOMER_CODE = 2_000_000; const BALANCE_CHUNK = 100; const RECENT_DOCUMENTS = 1000; const CHANGE_WINDOW = 200; function baseUrl(s: ProviderSettings) { return s.baseUrl.replace(/\/+$/, ''); } function headers(s: ProviderSettings) { return { Authorization: `Bearer ${s.token}`, DBname: s.dbName || 'TahesabDB', 'Content-Type': 'application/json', Accept: 'application/json', }; } function isNotFoundMessage(msg: string) { return /یافت نشد|Not_Found|not found/i.test(msg); } async function call(s: ProviderSettings, method: string, args: unknown[]): Promise { let res; try { res = await httpRequest({ method: 'POST', url: `${baseUrl(s)}/`, headers: headers(s), body: JSON.stringify({ [method]: args }), timeoutMs: s.timeoutMs, ca: s.caCert, }); } catch (err) { if (err instanceof AccountingHttpError) { throw new AccountingProviderError(`ته‌حساب (${method}): ${err.message}`, err.ambiguous); } throw err; } let json: unknown; try { json = parseLooseJson(res.body); } catch { throw new AccountingProviderError( `ته‌حساب (${method}): پاسخ نامعتبر (HTTP ${res.status})`, res.status >= 200 && res.status < 300, res.body.slice(0, 500), ); } const error = json && typeof json === 'object' && !Array.isArray(json) ? (json as { ERROR?: unknown }).ERROR : undefined; if (error !== undefined || res.status < 200 || res.status >= 300) { const msg = typeof error === 'string' ? error : `HTTP ${res.status}`; throw new AccountingProviderError( `ته‌حساب (${method}): ${msg.split('\r\n')[0]}`, false, trimDiagnostics(json), res.status === 404 || isNotFoundMessage(msg), ); } return json; } /** Tahesab error bodies carry ~30 diagnostic fields; keep only the message. */ function trimDiagnostics(json: unknown) { if (json && typeof json === 'object' && 'ERROR' in (json as object)) { return { ERROR: (json as { ERROR: unknown }).ERROR }; } return json; } /** A method that answers "nothing found" with an ERROR, as an empty result. */ async function callList(s: ProviderSettings, method: string, args: unknown[]) { try { return await call(s, method, args); } catch (err) { if (err instanceof AccountingProviderError && err.notFound) return null; throw err; } } /** Tahesab keys row objects by id: {"1": {...}, "2": {...}}. */ function rowsOf(json: unknown): Record[] { if (!json) return []; if (Array.isArray(json)) return json as Record[]; if (typeof json === 'object') { return Object.values(json as Record).filter( (v): v is Record => !!v && typeof v === 'object', ); } return []; } function toCustomer(r: Record): ExternalCustomer { return { code: String(r.Code), name: str(r.Name) ?? '', mobile: normalizeMobile(r.Mobile), phone: null, nationalCode: str(r.CodeMelli), address: str(r.Address), groupId: str(r.GID), groupName: str(r.GoroupName), }; } const neg = (d: Prisma.Decimal | null) => (d ? d.negated() : null); const METALS: Record = { '0': 'طلا', '1': 'پلاتین', '2': 'نقره' }; /** * Rounds the raised karat to 3 decimals, which is what the desktop app keeps. * 750 × (1 + 10/100) = 825. */ export function karatWithWage(karat: number, wagePercent: Prisma.Decimal) { return new Prisma.Decimal(karat).mul(new Prisma.Decimal(1).add(wagePercent.div(100))).toDecimalPlaces(3); } export const tahesabProvider: AccountingProvider = { name: 'tahesab', label: 'ته‌حساب', capabilities: { createKarSakhte: false, documentInquiry: true, movementScope: 'recent-documents', changeFeed: true }, async health(s) { const path = baseUrl(s).startsWith('https:') ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL'; let info: Record = {}; try { const res = await httpRequest({ method: 'GET', url: `${baseUrl(s)}/${path}`, headers: headers(s), timeoutMs: s.timeoutMs, ca: s.caCert, }); const json = parseLooseJson(res.body) as Record; info = { status: json.Status, api: json.Api_Status, db: json.DB_Status, version: json.TahesabVersion, dbType: json.DBType, dbName: json.DBName, }; } catch (err) { if (err instanceof AccountingHttpError) throw new AccountingProviderError(`ته‌حساب: ${err.message}`); throw new AccountingProviderError('ته‌حساب: پاسخ CheckHealth نامعتبر است'); } // CheckHealth ignores the token and DBname; a real call proves both. const groups = rowsOf(await call(s, 'DoListGorooh', [])); return { ...info, customerGroups: groups.length }; }, async listCustomerGroups(s) { const groups = rowsOf(await call(s, 'DoListGorooh', [])).map((g) => ({ id: String(g.GID), name: str(g.Name) ?? String(g.GID), })); // Accounts outside every defined group carry GID 0 (seen on the test // server as «مشتري»); offer it so it can be picked for import. if (!groups.some((g) => g.id === '0')) groups.push({ id: '0', name: 'بدون گروه (GID 0)' }); return groups; }, async listCustomers(s, cursor) { let [start, empty] = (cursor ?? '1:0').split(':').map(Number); if (!Number.isFinite(start) || start < 1) start = 1; if (!Number.isFinite(empty)) empty = 0; const json = await callList(s, 'Dolistmoshtari', [start, start + CUSTOMER_PAGE - 1]); const items = rowsOf(json).map(toCustomer); empty = items.length === 0 ? empty + 1 : 0; const nextStart = start + CUSTOMER_PAGE; const done = empty >= EMPTY_WINDOWS_TO_STOP || nextStart > MAX_CUSTOMER_CODE; return { items, next: done ? null : `${nextStart}:${empty}` }; }, async findCustomersByMobile(s, mobile) { const target = normalizeMobile(mobile); if (!target) return []; // The account's Mobile is free text: some rows hold 9121234567, others // 09121234567, and the lookup matches the stored form. Ask both ways. const found = new Map(); for (const form of [Number(target.slice(1)), target]) { for (const r of rowsOf(await callList(s, 'DoListMoshtari', [form]))) { const c = toCustomer(r); if (c.mobile === target) found.set(c.code, c); } } return [...found.values()]; }, async createCustomer(s, p) { const json = (await call(s, 'DoNewMoshtari', [ p.fullName, p.group.name ?? '', p.mobile, p.address ?? '', p.nationalCode ?? '', '', '', '-1', -1, // Moshtari_Code -1: Tahesab assigns the code 0, // Jens_Felez: gold ])) as { OK?: unknown }; if (json?.OK === undefined || json.OK === null) { throw new AccountingProviderError('ته‌حساب: کد حساب جدید برنگشت', false, json); } return { code: String(json.OK), response: json }; }, async updateCustomer(s, code, p) { // "-1" leaves a field unchanged (DoEditMoshtari example in the docs). const json = await call(s, 'DoEditMoshtari', [ p.fullName, p.group.name ?? '-1', p.mobile, p.address ?? '-1', p.nationalCode ?? '-1', '-1', '-1', '-1', Number(code), ]); return { response: json }; }, async getBalances(s, codes) { const out = []; for (let i = 0; i < codes.length; i += BALANCE_CHUNK) { const chunk = codes.slice(i, i + BALANCE_CHUNK); // Unknown codes are simply left out of MandeHesab. const json = (await callList(s, 'getmandehesabbycode', chunk)) as { MandeHesab?: unknown } | null; for (const r of rowsOf(json?.MandeHesab)) { out.push({ code: String(r.Code), goldGrams: (dec(r.MandeyeVazni) ?? ZERO).negated(), rial: (dec(r.MandeyeMali) ?? ZERO).negated(), }); } } return out; }, async getChanges(s, cursor) { // DoListUpdatedAsnad lists the newest documents by registration/edit time. // Cursor "maxId|latestEdit": a row is news when its ID is higher, or it // carries a later ZamanEdit. Nothing older than the window is visible, so // a window with no already-seen row means "too many changes: refresh all". const rows = rowsOf(await callList(s, 'DoListUpdatedAsnad', [CHANGE_WINDOW])); let maxId = 0; let latestEdit = ''; for (const r of rows) { maxId = Math.max(maxId, Number(r.ID) || 0); const edited = String(r.ZamanEdit ?? ''); if (edited && edited !== 'null' && edited > latestEdit) latestEdit = edited; } const next = `${maxId}|${latestEdit}`; if (cursor === null) return { cursor: next, accountCodes: [], itemNames: [], overflow: false }; const [seenId, seenEdit] = [Number(cursor.split('|')[0]) || 0, cursor.split('|')[1] ?? '']; const fresh = rows.filter((r) => { const edited = String(r.ZamanEdit ?? ''); return (Number(r.ID) || 0) > seenId || (!!edited && edited !== 'null' && edited > seenEdit); }); const accounts = new Set(); const items = new Set(); for (const r of fresh) { const code = str(r.MCode); if (code) accounts.add(code); const kar = str(r.No_Kar); if (kar && /کار/.test(String(r.NO ?? ''))) items.add(kar); } return { cursor: next, accountCodes: [...accounts], itemNames: [...items], overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length, }; }, async getStatement(s, q) { // Count_Last = -1 asks for the running balance on every row. const json = await callList(s, 'DoListAsnad', [ -1, Number(q.code), formatJalali(q.from), formatJalali(q.to), '', 0, 'IRR', ]); return rowsOf(json).map((r) => toStatementRow(r)); }, async listKarSakhteGroups() { return []; }, async listKarSakhteTypes() { return []; }, async listKarSakhte(s) { // Only items that already have a document appear here (Tahesab has no // item master in its API). Names that arrive as "????" lost their // encoding upstream and cannot be matched back; they are skipped. return rowsOf(await callList(s, 'DoListNameKarsakhte', [])) .map((r) => str(r.Name)) .filter((name): name is string => !!name && !name.includes('??')) .map((name) => ({ code: name, name, groupId: null, groupName: null, typeId: null, typeName: null, saleWagePercent: null, stockWeight: null, stockCount: null, })); }, async getKarSakhteStock(s, item) { const json = (await callList(s, 'GetMojoodiKarSakhte', [item.name, 0])) as Record | null; return { weight: dec(json?.VaznMojood), count: dec(json?.TedadMojood) }; }, async getKarSakhteMovement(s, from, to) { // Tahesab cannot list documents across all accounts by date; the closest // is the most recent N documents, filtered here. const lo = formatJalali(from, '/'); const hi = formatJalali(to, '/'); return rowsOf(await callList(s, 'DoListUpdatedAsnad', [RECENT_DOCUMENTS])) .filter((r) => /کار/.test(String(r.NO ?? ''))) .filter((r) => { const d = String(r.Tarikh ?? ''); return d >= lo && d <= hi; }) .map((r) => ({ itemCode: str(r.No_Kar), itemName: str(r.No_Kar) ?? '—', date: String(r.Tarikh ?? ''), description: [str(r.NO), str(r.Sharh1)].filter(Boolean).join(' — ') || null, weight: dec(r.Vazn), amount: dec(r.Mali), count: dec(r.Count), })); }, async createKarSakhte() { throw new AccountingNotSupportedError('ساخت کارساخته'); }, async postOrderDocument(s, doc, progress, onProgress) { const d = toJalali(doc.date); const multi = doc.lines.length > 1 ? 1 : -1; 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 args = [ doc.temporary ? 0 : 1, // Sabte_Kol_Or_Movaghat: 0 = temporary Number(doc.customerCode), -1, // Factor_Number: assigned by Tahesab i + 1, // Radif_Number d.jy, d.jm, d.jd, Number(line.weightGrams.toFixed(3)), Number(karatWithWage(line.karat, line.wagePercent).toString()), line.count, line.itemName, 1, // IsVoroodOrKhorooj: 1 = exit (goods to the customer) 1, // OjratTedadiOrGerami: per gram Number(line.flatRial.toFixed(0)), // MablaghKol — the only rial field that moves the balance 0, 0, 0, // Darsad_Talayee: the charge is already in Ayar; never count it twice 0, '', 0, 0, // PoolSang: does not move the balance; the stones are in MablaghKol line.description, doc.factorCode, -1, multi, 0, 0, ]; try { last = await call(s, 'DoNewSanadVKHKar', args); } catch (err) { // The row is already there (an earlier attempt landed but its reply // was lost): Factor_Code + Radif_Number is a unique key in Tahesab. if (!(err instanceof AccountingProviderError && /duplicate key/i.test(err.message + JSON.stringify(err.response ?? '')))) { throw err; } last = { duplicate: true }; } done = i + 1; await onProgress({ doneLines: done }); } const ref = String((last as { Factor_Code?: unknown })?.Factor_Code ?? doc.factorCode).toLowerCase(); return { ref, progress: { doneLines: done }, response: last }; }, async documentExists(s, doc) { try { await call(s, 'DoNewSanadInquiry', [doc.factorCode]); return true; } catch (err) { if (err instanceof AccountingProviderError && err.notFound) return false; // A multi-row document answers {"ERROR":"Duplicate"}: found, more than // one row under the code (seen on the test server). if (err instanceof AccountingProviderError && /:\s*Duplicate$/i.test(err.message)) return true; throw err; } }, async deleteDocument(s, doc) { try { return { response: await call(s, 'DoDeleteSanad', [doc.factorCode]) }; } catch (err) { // Already gone — the goal is reached. if (err instanceof AccountingProviderError && err.notFound) return { response: { alreadyDeleted: true } }; throw err; } }, }; function toStatementRow(r: Record): StatementRow { const metal = String(r.Jens_Felez ?? '0'); const temporary = r.isSanadMovaghat === true; const isGold = metal === '0'; const isRial = !r.Arz || String(r.Arz).toUpperCase() === 'IRR'; // Positive TabdilVazn / Mali = in the customer's favour (they gave us gold // or money); negative = they took goods or money from us. const tabdil = isGold ? dec(r.TabdilVazn) ?? ZERO : ZERO; const mali = isRial ? dec(r.Mali) ?? ZERO : ZERO; return { key: `${r.ID}-${r.Radif ?? 1}`, documentRef: String(r.Factor_Code ?? r.ID).toLowerCase(), accountCode: String(r.MCode ?? ''), documentNo: String(r.Sh_Factor ?? ''), date: String(r.Tarikh ?? ''), time: str(r.Tarikh_Time), title: str(r.NO) ?? '', description: [str(r.Sharh1), str(r.Sharh2), str(r.Baabat)].filter(Boolean).join(' — ') || null, itemName: str(r.No_Kar), weight: dec(r.Vazn), karat: dec(r.Ayar)?.toNumber() ?? null, count: dec(r.Count), unitPrice: dec(r.Fi) ?? dec(r.Mazaneh), wagePercent: dec(r.DarsadSood), goldIn: tabdil.gt(0) ? tabdil : ZERO, goldOut: tabdil.lt(0) ? tabdil.negated() : ZERO, rialIn: mali.gt(0) ? mali : ZERO, rialOut: mali.lt(0) ? mali.negated() : ZERO, // A temporary row reports TahesabVazni/Mali = 0 because it moves nothing // yet; null lets the report carry the previous balance forward instead. goldBalance: temporary ? null : neg(dec(r.TahesabVazni)), rialBalance: temporary ? null : neg(dec(r.TahesabMali)), metal: METALS[metal] ?? metal, temporary, }; }