Files
tehran_taj/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts
T
s.esmaeiliandClaude Haiku 5.5 ea987b708f fix(accounting): zarrin copy and sign notes match verified install
Zarrin v228 was tested on the real install (test account 953): negative
balance = owes, and temporary documents move the balance at once. Update
the admin copy and provider comments to match what was verified.

پنل: متن راهنمای زرین به‌روز شد: علامت مانده روی نصب واقعی بررسی شده است، و هشدار مربوط به ثبت سند موقت در «ثبت سفارش» اضافه شد.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-13 19:19:38 +03:30

615 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <base>?kind=<api>&token=<token>&<params>, where <base> 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<string, string | number | null | undefined> = {},
opts: { timeoutMs?: number; repair?: (body: string) => string } = {},
): Promise<Record<string, unknown> | 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<string, unknown>;
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<string, unknown> | unknown[];
}
function goodReply(body: string): Record<string, unknown> | null {
try {
let j = parseLooseJson(body) as unknown;
if (typeof j === 'string' && /^\s*[{[]/.test(j)) j = parseLooseJson(j);
const o = j as Record<string, unknown> | 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<string, unknown>[] {
if (Array.isArray(json)) return json as Record<string, unknown>[];
if (json && typeof json === 'object') {
const o = json as Record<string, unknown>;
for (const key of ['data', 'date', 'mande']) {
if (Array.isArray(o[key])) return o[key] as Record<string, unknown>[];
}
if ('id' in o || 'code' in o) return [o];
}
return [];
}
function dataOf(json: unknown): Record<string, unknown> {
const o = (json ?? {}) as Record<string, unknown>;
const d = o.data;
if (d && typeof d === 'object' && !Array.isArray(d)) return d as Record<string, unknown>;
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<ExternalCustomer[]> {
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<Record<string, unknown>[]> {
const out: Record<string, unknown>[] = [];
const seen = new Set<string>();
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<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = [];
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<string, unknown>) {
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<string, unknown>;
// `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<string, unknown>[];
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<string, unknown>;
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<string>();
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<string, string>();
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<string, unknown>;
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<string, unknown>).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 };