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>
378 lines
18 KiB
TypeScript
378 lines
18 KiB
TypeScript
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());
|
||
}
|