Files
tehran_taj/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts
T
s.esmaeiliandClaude Sonnet 4.6 8b74c85a2a fix(accounting): verify and fix Zarrin driver on real v228 install
Tested 20/20 on the owner's live Zarrin v228 (870 accounts, 656 items):

Reads fixed:
- listKarSakhte: now pages by 100 via pageNum (was returning only first 100 of 656)
- goroohSakhte: HTTP 400 with a valid body is now accepted (was treated as missing)
- parvande «مانده از قبل» row is not a balance; skip it and anchor the running
  balance on the live listMandeHesabha balance, walked back through movements
- infogardeshsakhte: hand-printed JSON repaired, timeout raised to 90s
- document delete: now searches all ledger pages, not just the first one
- listhesabha names are «first * last»; the «*» is now stripped
- keep-alive ECONNRESET: a socket hang-up on a reused connection is retried once
  on a fresh connection (server closes idle connections)

Writes fixed (test account 953 «تست اپ گلدبانک»):
- temporary flag: set from shomareSanad («موقت»), not noeFactor
- noeFactor «فاکتور موقت» also flags a document left open in Zarrin's own UI

New: test script apps/api/scripts/test-accounting-zarrin.ts (13 read + 7 write checks)
Docs: §13 added to ACCOUNTING-INTEGRATION-PLAN.md; ACC-01 in ISSUES.md updated
New: docs/FRESH-SERVER-DEPLOY-AUDIT.md (fresh-server deploy checklist)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-12 15:23:24 +03:30

378 lines
18 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 { httpRequest } from '../accounting-http';
import { OrderDocumentInput, ProviderSettings } from './accounting-provider';
import { __zarrinInternals, zarrinProvider as z } from './zarrin.provider';
jest.mock('../accounting-http', () => ({
...jest.requireActual('../accounting-http'),
httpRequest: jest.fn(),
}));
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
const s: ProviderSettings = {
baseUrl: 'http://zarrin.local:8000/ZarrinService/accounting',
token: 'SECRET',
caCert: null,
timeoutMs: 5000,
balanceOwesWhen: 'negative',
};
const reply = (body: unknown, status = 200) =>
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
const url = (n = 0) => new URL(http.mock.calls[n][0].url);
beforeEach(() => http.mockReset());
describe('wire format', () => {
it('sends GET ?kind=…&token=… with the parameters, and nothing blank', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '4', name: 'مشتری (خریدار)' }] }));
const groups = await z.listCustomerGroups(s);
const u = url();
expect(http.mock.calls[0][0].method).toBe('GET');
expect(u.pathname).toBe('/ZarrinService/accounting');
expect(u.searchParams.get('kind')).toBe('listGorooheHesabha');
expect(u.searchParams.get('token')).toBe('SECRET');
expect(groups).toEqual([{ id: '4', name: 'مشتری (خریدار)' }]);
});
it('unwraps the JSON-in-a-string replies the documentation shows', async () => {
const inner = JSON.stringify({ state: true, msg: 'حساب با موفقیت ایجاد شد', id: 132 });
http.mockReturnValueOnce(reply(JSON.stringify(inner)));
const res = await z.createCustomer(s, {
firstName: 'تست',
lastName: 'تست',
fullName: 'تست تست',
mobile: '09121112233',
phone: null,
address: null,
nationalCode: null,
group: { id: '4', name: 'مشتری' },
});
expect(res.code).toBe('132');
expect(url().searchParams.get('fkNoe')).toBe('4');
expect(url().searchParams.has('telephone')).toBe(false);
});
it('turns state:false into an error carrying the message', async () => {
http.mockReturnValueOnce(reply({ state: false, msg: 'دسترسی مجاز نیست' }));
await expect(z.listCustomerGroups(s)).rejects.toThrow('دسترسی مجاز نیست');
});
it('shows the server reason on a non-2xx reply, without the token', async () => {
http.mockReturnValueOnce(reply('<html><body><h1>Server Error</h1><p>kind=listGorooheHesabha&token=SECRET failed</p></body></html>', 500));
const err = await z.listCustomerGroups(s).catch((e) => e);
expect(err.message).toContain('HTTP 500 — Server Error');
expect(err.message).toContain('failed');
expect(err.message).not.toContain('SECRET');
http.mockReturnValueOnce(reply({ state: false, msg: 'شناسه نامعتبر' }, 400));
await expect(z.listCustomerGroups(s)).rejects.toThrow('HTTP 400 — شناسه نامعتبر');
});
it('lists the different envelopes Zarrin uses', () => {
const { listOf, fullJalali } = __zarrinInternals;
expect(listOf({ data: [1] })).toEqual([1]);
expect(listOf({ date: [2] })).toEqual([2]); // Get100Account's typo
expect(listOf([3])).toEqual([3]);
expect(listOf({ id: '84', nam: 'x' })).toEqual([{ id: '84', nam: 'x' }]);
expect(fullJalali('04/05/29')).toBe('1404/05/29');
});
});
describe('customers', () => {
it('lists every account with its group from one listhesabha call', async () => {
http.mockReturnValueOnce(
reply({
data: [
{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' },
{ id: '82', nam: 'آرین یوسفی', gorooh: 'سازنده (تامین کننده)', tell: '', mobile: '', codeMeli: '' },
],
}),
);
const res = await z.listCustomers(s, null);
expect(http).toHaveBeenCalledTimes(1);
expect(url(0).searchParams.get('kind')).toBe('listhesabha');
expect(url(0).searchParams.get('az')).toBe('1');
expect(res.items).toHaveLength(2);
expect(res.items[0]).toMatchObject({ code: '81', name: 'حسین اصغری', mobile: '09121112233', groupName: 'مشتری (خریدار)', phone: '021' });
expect(res.items[1].mobile).toBeNull();
expect(res.next).toBeNull();
});
it('finds by mobile with searchAccount, keeping exact matches only', async () => {
http.mockReturnValueOnce(
reply({
state: true,
msg: '',
date: [
{ code: 84, name: 'سعید', namKhanevadegi: 'رجایی', mobile: '09121995971' },
{ code: 90, name: 'x', namKhanevadegi: 'y', mobile: '09121995972' },
],
}),
);
const found = await z.findCustomersByMobile(s, '09121995971');
expect(url(0).searchParams.get('kind')).toBe('searchAccount');
expect(found.map((c) => c.code)).toEqual(['84']);
expect(found[0].name).toBe('سعید رجایی');
});
it('falls back to listhesabha when searchAccount is missing (bare 400)', async () => {
http
.mockReturnValueOnce(reply('', 400))
.mockReturnValueOnce(reply({ data: [{ id: '84', nam: 'رجایی * سعید', gorooh: 'مشتری (خریدار)', mobile: '09121995971' }, { id: '85', nam: 'z', mobile: '' }] }));
const found = await z.findCustomersByMobile(s, '09121995971');
expect(url(1).searchParams.get('kind')).toBe('listhesabha');
expect(found.map((c) => c.code)).toEqual(['84']);
});
it('does not hide other errors behind the fallback', async () => {
http.mockReturnValueOnce(reply('', 500));
await expect(z.findCustomersByMobile(s, '09121995971')).rejects.toThrow('HTTP 500');
});
it('lists karsakhte groups with goroohSakhte when the installation has it', async () => {
http.mockReturnValueOnce(reply({ state: true, detail: 'OK', data: [{ id: '26', name: 'النگو' }] }));
expect(await z.listKarSakhteGroups!(s)).toEqual([{ id: '26', name: 'النگو' }]);
expect(http).toHaveBeenCalledTimes(1);
});
it('scans karsakhte groups with infogorooh when goroohSakhte is missing, skipping ids that fail', async () => {
http.mockImplementation((req) => {
const u = new URL(req.url);
if (u.searchParams.get('kind') === 'goroohSakhte') return reply('', 400);
const id = u.searchParams.get('id');
if (id === '26') return reply({ data: [{ nam: 'النگو', code: 26 }] });
if (id === '115') return reply({ data: [{ nam: 'سکه', code: 115 }] });
if (id === '3') return reply('', 500);
return reply('{\n "data": \n ]\n}');
});
expect(await z.listKarSakhteGroups!(s)).toEqual([
{ id: '26', name: 'النگو' },
{ id: '115', name: 'سکه' },
]);
});
it('keeps the account in its group on edit', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'ویرایش اطلاعات با موفقیت انجام شد' }));
await z.updateCustomer(s, '101', {
firstName: 'حسین',
lastName: 'اصغری',
fullName: 'حسین اصغری',
mobile: '09121427233',
phone: null,
address: 'خیابان مولوی',
nationalCode: null,
group: { id: null, name: 'واسطه' },
});
expect(url().searchParams.get('nameG')).toBe('واسطه');
expect(url().searchParams.get('id')).toBe('101');
});
});
describe('balances and statement', () => {
it('applies the configured sign', async () => {
http.mockReturnValue(reply({ state: true, msg: 'OK', data: [{ id: '108', name: 'x', geram: '-75.409', pool: '-3.211960271E9' }, { id: '9', geram: '1', pool: '1' }] }));
const neg = await z.getBalances(s, ['108', '5']);
expect(neg).toHaveLength(1);
expect(neg[0].goldGrams.toString()).toBe('75.409');
expect(neg[0].rial.toString()).toBe('3211960271');
const pos = await z.getBalances({ ...s, balanceOwesWhen: 'positive' }, ['108', '9']);
expect(pos[0].goldGrams.toString()).toBe('-75.409');
});
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.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(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');
});
});
describe('order documents', () => {
const line = (code: string, wage: number) => ({
itemCode: code,
itemName: code,
weightGrams: new Prisma.Decimal('10'),
count: 1,
wagePercent: new Prisma.Decimal(wage),
flatRial: new Prisma.Decimal(0),
karat: 750,
description: 'd',
});
const doc: OrderDocumentInput = {
factorCode: 'gbordx00000000000000000',
customerCode: '81',
date: new Date(),
description: 'سفارش',
temporary: true,
lines: [line('19', 12), line('20', 8)],
};
it('opens the invoice with the account, appends with the invoice id, in bonak mode', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } })).mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } }));
const progress: unknown[] = [];
const res = await z.postOrderDocument(s, doc, {}, async (p) => {
progress.push(p);
});
const a = url(0).searchParams;
const b = url(1).searchParams;
expect(a.get('kind')).toBe('foroushSakhte');
expect(a.get('idHesab')).toBe('81');
expect(a.has('idFactor')).toBe(false);
expect(a.get('idSakhte')).toBe('19');
expect(a.get('darsad')).toBe('12');
expect(a.get('vazn')).toBe('10.000');
expect(a.get('pd')).toBe('-1');
expect(a.get('bonak')).toBe('1');
expect(a.get('movaghat')).toBe('1');
expect(b.get('idFactor')).toBe('117764');
expect(b.has('idHesab')).toBe(false);
expect(b.get('movaghat')).toBe('1'); // temporary, as the owner ruled
expect(progress).toEqual([
{ factorId: '117764', doneLines: 1 },
{ factorId: '117764', doneLines: 2 },
]);
expect(res.ref).toBe('117764');
});
it('resumes into the same invoice', async () => {
http.mockReturnValueOnce(reply({ state: true, data: { idFactor: 5 } }));
await z.postOrderDocument(s, doc, { factorId: '5', doneLines: 1 }, async () => undefined);
expect(http).toHaveBeenCalledTimes(1);
expect(url().searchParams.get('idFactor')).toBe('5');
expect(url().searchParams.get('idSakhte')).toBe('20');
});
it('finds the row to delete in the ledger, since foroushSakhte never returned it', async () => {
http
.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '0', idFactor: '0' }, { idRadif: '731', idFactor: '390' }] }))
.mockReturnValueOnce(reply({ state: true, msg: 'حذف با موفقیت انجام شد :)' }));
await z.deleteDocument(s, { factorCode: 'x', ref: '390', customerCode: '81' });
expect(url(1).searchParams.get('kind')).toBe('hazfRadifSanad');
expect(url(1).searchParams.get('pkRadifSanad')).toBe('731');
expect(url(1).searchParams.get('kol')).toBe('1');
});
});
describe('change feed (real-time sync)', () => {
it('reads the newest journal rows and reports accounts above the cursor', async () => {
http.mockReturnValueOnce(
reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }, { idRadif: '733', idHesab: '40' }, { idRadif: '734', idHesab: '40' }] }),
);
const r = await z.getChanges(s, '731');
expect(url().searchParams.get('kind')).toBe('parLastTenRadif');
expect(url().searchParams.has('idHesab')).toBe(false);
expect(r).toMatchObject({ cursor: '734', accountCodes: ['40'], overflow: false });
});
it('first poll only returns the cursor', async () => {
http.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }] }));
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());
}