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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
9fc0886a57
commit
8b74c85a2a
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Accounting integration (D27) — the Zarrin driver against a REAL Zarrin
|
||||
* installation. No database, no Nest: it calls the driver itself, so it can
|
||||
* run against the owner's production install without touching the app.
|
||||
*
|
||||
* READ-ONLY by default (the install holds real customer data):
|
||||
* 1. version + account groups (the token is checked by the group listing)
|
||||
* 2. accounts: no «*» left in names (listhesabha prints «first * last»)
|
||||
* 3. one account found again by its mobile (searchAccount)
|
||||
* 4. balances: one and many
|
||||
* 5. statement: its last running balance equals the account's real balance,
|
||||
* and a range ending in the past agrees with one ending today
|
||||
* 6. کارساخته: every page of listKarSakhte (100 per page), groups, stock
|
||||
* 7. change feed (parLastTenRadif): first poll = cursor only
|
||||
* 8. item movement (infogardeshsakhte, hand-printed JSON) — Zarrin itself
|
||||
* sometimes answers 500 here; reported, not failed
|
||||
*
|
||||
* WRITE test only with ZARRIN_WRITE_TEST=1 and ZARRIN_TEST_ACCOUNT set — on
|
||||
* the owner's install that is account 953 «تست اپ گلدبانک» (09990001111),
|
||||
* created for this on 2026-10-12. Zarrin has no API to delete an account or
|
||||
* a کارساخته, so this script never creates either:
|
||||
* 9. edit the test account (its group must survive)
|
||||
* 10. post a TEMPORARY 2-line document (1 g each, 10 %): the balance must move
|
||||
* by exactly 2.2 g and the rows read as temporary
|
||||
* 11. delete it: no rows left, balance and stock back where they were
|
||||
*
|
||||
* Usage:
|
||||
* ZARRIN_URL=http://host:port/ZarrinService/accounting ZARRIN_TOKEN=… \
|
||||
* [ZARRIN_ACCOUNT=499] [ZARRIN_WRITE_TEST=1 ZARRIN_TEST_ACCOUNT=953] \
|
||||
* npx ts-node --transpile-only scripts/test-accounting-zarrin.ts
|
||||
*
|
||||
* Requests are paced: the owner's install refused connections once when hit
|
||||
* back to back. The token is never printed.
|
||||
*/
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { toJalali } from '../src/modules/accounting-sync/jalali';
|
||||
import { ProviderSettings } from '../src/modules/accounting-sync/providers/accounting-provider';
|
||||
import { zarrinProvider as z } from '../src/modules/accounting-sync/providers/zarrin.provider';
|
||||
|
||||
const URL_ = process.env.ZARRIN_URL ?? '';
|
||||
const TOKEN = process.env.ZARRIN_TOKEN ?? '';
|
||||
const ACCOUNT = process.env.ZARRIN_ACCOUNT ?? '';
|
||||
const WRITE = process.env.ZARRIN_WRITE_TEST === '1';
|
||||
const TEST_ACCOUNT = process.env.ZARRIN_TEST_ACCOUNT ?? '';
|
||||
if (!URL_ || !TOKEN) {
|
||||
console.error('ZARRIN_URL and ZARRIN_TOKEN are required.');
|
||||
process.exit(2);
|
||||
}
|
||||
if (WRITE && !TEST_ACCOUNT) {
|
||||
console.error('ZARRIN_WRITE_TEST=1 needs ZARRIN_TEST_ACCOUNT (a test account the owner approved, e.g. 953).');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const s: ProviderSettings = { baseUrl: URL_, token: TOKEN, caCert: null, timeoutMs: 60_000, balanceOwesWhen: 'negative' };
|
||||
const D = (v: string | number) => new Prisma.Decimal(v);
|
||||
const pause = (ms = 1200) => new Promise((r) => setTimeout(r, ms));
|
||||
const day = (n: number) => toJalali(new Date(Date.now() - n * 864e5));
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const chk = (c: unknown, m: string) => {
|
||||
if (c) pass += 1;
|
||||
else fail += 1;
|
||||
console.log(` ${c ? 'PASS' : 'FAIL'} ${m}`);
|
||||
};
|
||||
const note = (m: string) => console.log(` NOTE ${m}`);
|
||||
const step = (t: string) => console.log(`\n${t}`);
|
||||
|
||||
async function main() {
|
||||
step('1. connection');
|
||||
const h = await z.health(s);
|
||||
chk(h.version !== undefined && Number(h.customerGroups) > 0, `version ${h.version}, ${h.customerGroups} account groups`);
|
||||
await pause();
|
||||
|
||||
step('2. accounts');
|
||||
const all = (await z.listCustomers(s, null)).items;
|
||||
chk(all.length > 0, `${all.length} accounts, ${all.filter((c) => c.mobile).length} with a mobile`);
|
||||
chk(all.every((c) => !c.name.includes('*')), 'no «*» left in any name');
|
||||
await pause();
|
||||
|
||||
const probe = all.find((c) => (ACCOUNT ? c.code === ACCOUNT : !!c.mobile)) ?? all[0];
|
||||
const code = probe.code;
|
||||
step(`3. search by mobile (account ${code})`);
|
||||
if (probe.mobile) {
|
||||
const found = await z.findCustomersByMobile(s, probe.mobile);
|
||||
chk(found.some((c) => c.code === code), `found account ${code} by its mobile`);
|
||||
} else note('account has no mobile; skipped');
|
||||
await pause();
|
||||
|
||||
step('4. balances');
|
||||
const [one] = await z.getBalances(s, [code]);
|
||||
chk(!!one, `balance of ${code}: ${one?.goldGrams} g, ${one?.rial} rial (app sign: positive = owes)`);
|
||||
await pause();
|
||||
const many = await z.getBalances(s, all.slice(0, 3).map((c) => c.code));
|
||||
chk(many.length === 3, `3 balances in one call (${many.length})`);
|
||||
await pause();
|
||||
|
||||
step(`5. statement of ${code}`);
|
||||
const toToday = await z.getStatement(s, { code, from: day(60), to: day(0) });
|
||||
await pause();
|
||||
const [now] = await z.getBalances(s, [code]);
|
||||
const last = toToday[toToday.length - 1];
|
||||
if (last) {
|
||||
chk(String(last.goldBalance) === String(now?.goldGrams), `last running balance ${last.goldBalance} = real balance ${now?.goldGrams}`);
|
||||
await pause();
|
||||
const past = await z.getStatement(s, { code, from: day(60), to: day(5) });
|
||||
const byKey = new Map(toToday.map((r) => [r.key, String(r.goldBalance)]));
|
||||
const shared = past.filter((r) => byKey.has(r.key));
|
||||
chk(shared.every((r) => byKey.get(r.key) === String(r.goldBalance)), `a range ending 5 days ago agrees on ${shared.length} shared rows`);
|
||||
note(`${toToday.length} rows in 60 days, ${toToday.filter((r) => r.temporary).length} temporary`);
|
||||
} else note('no rows in the last 60 days');
|
||||
await pause();
|
||||
|
||||
step('6. کارساخته');
|
||||
const items = await z.listKarSakhte(s);
|
||||
chk(items.length > 0, `${items.length} items (pages of 100 read until the last)`);
|
||||
await pause();
|
||||
const groups = await z.listKarSakhteGroups(s);
|
||||
chk(groups.length > 0, `${groups.length} item groups`);
|
||||
await pause();
|
||||
const stocked = items.find((i) => i.stockWeight?.gt(0));
|
||||
if (stocked) {
|
||||
const st = await z.getKarSakhteStock(s, stocked);
|
||||
chk(st.weight !== null || st.count !== null, `stock of «${stocked.name}»: ${st.weight ?? st.count}`);
|
||||
}
|
||||
await pause();
|
||||
|
||||
step('7. change feed');
|
||||
const first = await z.getChanges(s, null);
|
||||
chk(!!first.cursor && first.accountCodes.length === 0, `first poll returns only the cursor (${first.cursor})`);
|
||||
await pause();
|
||||
|
||||
step('8. item movement (slow; Zarrin sometimes fails here itself)');
|
||||
try {
|
||||
const mv = await z.getKarSakhteMovement(s, day(3), day(0));
|
||||
chk(true, `${mv.length} rows`);
|
||||
} catch (err) {
|
||||
note(`not available right now: ${(err as Error).message.slice(0, 160)}`);
|
||||
}
|
||||
|
||||
if (!WRITE) {
|
||||
console.log('\nWrite test skipped (set ZARRIN_WRITE_TEST=1 and ZARRIN_TEST_ACCOUNT to run it).');
|
||||
return;
|
||||
}
|
||||
|
||||
step(`9. edit test account ${TEST_ACCOUNT}`);
|
||||
const acct = all.find((c) => c.code === TEST_ACCOUNT);
|
||||
if (!acct) throw new Error(`test account ${TEST_ACCOUNT} not found`);
|
||||
const parts = acct.name.split(' ');
|
||||
await z.updateCustomer(s, TEST_ACCOUNT, {
|
||||
firstName: parts.slice(0, -1).join(' ') || acct.name,
|
||||
lastName: parts.slice(-1)[0] ?? '',
|
||||
fullName: acct.name,
|
||||
mobile: acct.mobile ?? '',
|
||||
phone: acct.phone,
|
||||
address: `حساب آزمایشی اتصال اپ — آخرین تست ${new Date().toISOString().slice(0, 10)}`,
|
||||
nationalCode: null,
|
||||
group: { id: null, name: acct.groupName },
|
||||
});
|
||||
await pause();
|
||||
const after = (await z.listCustomers(s, null)).items.find((c) => c.code === TEST_ACCOUNT);
|
||||
chk(after?.groupName === acct.groupName, `group kept: ${after?.groupName}`);
|
||||
await pause();
|
||||
|
||||
step('10–11. temporary document, then delete');
|
||||
const [b0] = await z.getBalances(s, [TEST_ACCOUNT]);
|
||||
const sell = items.filter((i) => i.typeName === 'گرمی' && i.stockWeight?.gte(2)).slice(0, 2);
|
||||
if (sell.length < 2) throw new Error('need two gram items with stock ≥ 2 g');
|
||||
const stock0: string[] = [];
|
||||
for (const i of sell) {
|
||||
await pause();
|
||||
stock0.push(String((await z.getKarSakhteStock(s, i)).weight));
|
||||
}
|
||||
let ref = '';
|
||||
try {
|
||||
await pause();
|
||||
const res = await z.postOrderDocument(
|
||||
s,
|
||||
{
|
||||
factorCode: 'zarrinwritetest',
|
||||
customerCode: TEST_ACCOUNT,
|
||||
date: new Date(),
|
||||
description: 'تست اتصال اپ',
|
||||
temporary: true,
|
||||
lines: sell.map((i) => ({ itemCode: i.code, itemName: i.name, weightGrams: D(1), count: 1, wagePercent: D(10), flatRial: D(0), karat: 750, description: `تست اتصال اپ — ${i.name}` })),
|
||||
},
|
||||
{},
|
||||
async (p) => {
|
||||
if (p.factorId) ref = p.factorId;
|
||||
},
|
||||
);
|
||||
ref = res.ref;
|
||||
await pause(2000);
|
||||
const [b1] = await z.getBalances(s, [TEST_ACCOUNT]);
|
||||
chk(b1 && b0 && b1.goldGrams.sub(b0.goldGrams).eq('2.2'), `invoice ${ref}: balance moved ${b1 && b0 ? b1.goldGrams.sub(b0.goldGrams) : '?'} g (expected 2.2)`);
|
||||
await pause();
|
||||
const rows = (await z.getStatement(s, { code: TEST_ACCOUNT, from: day(0), to: day(0) })).filter((r) => r.documentRef === ref);
|
||||
chk(rows.length > 0 && rows.every((r) => r.temporary), `${rows.length} rows, all temporary`);
|
||||
} finally {
|
||||
if (ref) {
|
||||
await pause(1500);
|
||||
await z.deleteDocument(s, { factorCode: '', ref, customerCode: TEST_ACCOUNT });
|
||||
await pause(2000);
|
||||
const [b2] = await z.getBalances(s, [TEST_ACCOUNT]);
|
||||
chk(b2 && b0 && b2.goldGrams.eq(b0.goldGrams), `deleted: balance back to ${b2?.goldGrams}`);
|
||||
await pause();
|
||||
const left = (await z.getStatement(s, { code: TEST_ACCOUNT, from: day(0), to: day(0) })).filter((r) => r.documentRef === ref);
|
||||
chk(left.length === 0, `no rows of invoice ${ref} left`);
|
||||
for (const [n, i] of sell.entries()) {
|
||||
await pause();
|
||||
const w = String((await z.getKarSakhteStock(s, i)).weight);
|
||||
chk(w === stock0[n], `stock of «${i.name}» back to ${stock0[n]} (${w})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
fail += 1;
|
||||
console.error(`\nABORTED: ${(err as Error).message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
});
|
||||
@@ -86,6 +86,16 @@ function tlsErrorMessage(err: NodeJS.ErrnoException, pinned: boolean): string {
|
||||
}
|
||||
|
||||
export function httpRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||
return send(req, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* One attempt. `fresh` = on a new connection: a server that closes idle
|
||||
* kept-alive connections (Zarrin v228 does) makes the next request on a pooled
|
||||
* one fail with ECONNRESET / "socket hang up" before it was received. That one
|
||||
* case is repeated once on a fresh connection; nothing else is ever retried.
|
||||
*/
|
||||
function send(req: HttpRequest, fresh: boolean): Promise<HttpResponse> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url);
|
||||
@@ -106,6 +116,7 @@ export function httpRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: `${url.pathname}${url.search}`,
|
||||
headers,
|
||||
...(fresh ? { agent: false } : {}),
|
||||
};
|
||||
const pins = isHttps ? pinnedFingerprints(req.ca) : new Set<string>();
|
||||
if (isHttps) {
|
||||
@@ -176,6 +187,10 @@ export function httpRequest(req: HttpRequest): Promise<HttpResponse> {
|
||||
}, req.timeoutMs);
|
||||
|
||||
request.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (!fresh && request.reusedSocket && err.code === 'ECONNRESET') {
|
||||
finish(() => resolve(send(req, true)));
|
||||
return;
|
||||
}
|
||||
// Node's own text for these suggests flags that do not apply here.
|
||||
const text = isHttps && err.code && TLS_CERT_ERRORS.test(err.code) ? tlsErrorMessage(err, pins.size > 0) : err.message;
|
||||
finish(() => reject(new AccountingHttpError(text, connected, err.code)));
|
||||
|
||||
@@ -181,23 +181,37 @@ describe('balances and statement', () => {
|
||||
expect(pos[0].goldGrams.toString()).toBe('-75.409');
|
||||
});
|
||||
|
||||
it('folds a running balance from «مانده از قبل»', async () => {
|
||||
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.0', vaznTarazooDaryaft: '0.0', poolPardakht: '0', poolDaryaft: '0' },
|
||||
{ idRadif: '470', idFactor: '221', idHesab: '81', noeRadif: 'چک', tarikh: '04/05/29', vaznTarazooPardakht: '0.0', vaznTarazooDaryaft: '2.5', poolPardakht: '810000000', poolDaryaft: '0', ayar: '0' },
|
||||
{ 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(rows[0].goldBalance?.toString()).toBe('10');
|
||||
expect(rows[1]).toMatchObject({ documentRef: '221', date: '1404/05/29', title: 'چک' });
|
||||
expect(rows[1].goldBalance?.toString()).toBe('7.5');
|
||||
expect(rows[1].rialBalance?.toString()).toBe('810000000');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,3 +297,81 @@ describe('change feed (real-time sync)', () => {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
|
||||
import { jalaliToIsoDate, toIsoDateTehran } from '../jalali';
|
||||
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,
|
||||
@@ -22,12 +28,13 @@ import {
|
||||
* the installation's `http://host:8000/ZarrinService/accounting`. The token
|
||||
* travels in the query string, so a URL built here must never be logged.
|
||||
*
|
||||
* NOT verified against a live installation — the owner has none to test on
|
||||
* (plan, decision 11). Everything below follows the documentation and its
|
||||
* sample outputs, and is covered by unit tests built from those samples. The
|
||||
* open points (balance sign, which rial field the stones belong in) are
|
||||
* configurable or flagged, and the integration is to be switched on for real
|
||||
* only after a trial period on temporary documents.
|
||||
* 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. The writing
|
||||
* calls (addAccount, edithesab, foroushSakhte, hazfRadifSanad,
|
||||
* addFelezsakhteModel) are still unverified.
|
||||
*
|
||||
* Differences from Tahesab that shape the sync:
|
||||
* * No idempotency key on foroushSakhte. A timed-out document call cannot be
|
||||
@@ -40,6 +47,12 @@ import {
|
||||
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;
|
||||
@@ -85,10 +98,27 @@ function errorBodyText(body: string, token: string) {
|
||||
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 {
|
||||
@@ -104,7 +134,7 @@ async function call(
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: s.timeoutMs, ca: s.caCert });
|
||||
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);
|
||||
@@ -113,13 +143,16 @@ async function call(
|
||||
}
|
||||
|
||||
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(res.body);
|
||||
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 {
|
||||
@@ -137,6 +170,17 @@ async function call(
|
||||
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>[];
|
||||
@@ -169,12 +213,18 @@ function fullJalali(v: unknown): string {
|
||||
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),
|
||||
name: str(r.nam) ?? 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),
|
||||
@@ -184,6 +234,41 @@ async function allAccounts(s: ProviderSettings): Promise<ExternalCustomer[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
// Until checked on a real installation (plan §9) the sign is a setting.
|
||||
return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v;
|
||||
@@ -311,25 +396,30 @@ export const zarrinProvider: AccountingProvider = {
|
||||
},
|
||||
|
||||
async getStatement(s, q) {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (let page = 1; page <= MAX_STATEMENT_PAGES; page += 1) {
|
||||
const batch = listOf(
|
||||
await call(s, 'parvande', {
|
||||
id: q.code,
|
||||
dateAz: jalaliToIsoDate(q.from),
|
||||
dateTa: jalaliToIsoDate(q.to),
|
||||
pageNum: page,
|
||||
detailCount: STATEMENT_PAGE,
|
||||
}),
|
||||
);
|
||||
rows.push(...batch);
|
||||
if (batch.length < STATEMENT_PAGE) break;
|
||||
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);
|
||||
}
|
||||
|
||||
// parvande has no running balance; it opens with «مانده از قبل» and the
|
||||
// balance is folded from there. پرداخت = we gave = the customer owes more.
|
||||
let gold = ZERO;
|
||||
let rial = ZERO;
|
||||
return rows.map((r): StatementRow => {
|
||||
const goldIn = dec(r.vaznTarazooDaryaft) ?? ZERO;
|
||||
const goldOut = dec(r.vaznTarazooPardakht) ?? ZERO;
|
||||
@@ -360,7 +450,9 @@ export const zarrinProvider: AccountingProvider = {
|
||||
goldBalance: gold,
|
||||
rialBalance: rial,
|
||||
metal: null,
|
||||
temporary: false,
|
||||
// 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) ?? ''}`),
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -394,14 +486,14 @@ export const zarrinProvider: AccountingProvider = {
|
||||
|
||||
async listKarSakhteTypes(s) {
|
||||
const types = new Map<string, string>();
|
||||
for (const r of listOf(await call(s, 'listKarSakhte'))) {
|
||||
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 listOf(await call(s, 'listKarSakhte'))
|
||||
return (await allKarSakhte(s))
|
||||
.filter((r) => r.id !== undefined && str(r.namFelezsakhthe))
|
||||
.map((r) => ({
|
||||
code: String(r.id),
|
||||
@@ -426,11 +518,18 @@ export const zarrinProvider: AccountingProvider = {
|
||||
},
|
||||
|
||||
async getKarSakhteMovement(s, from, to) {
|
||||
return listOf(await call(s, 'infogardeshsakhte', { az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) })).map(
|
||||
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) ?? '—',
|
||||
date: str(r.tarikh_akharin_gardesh) ?? '',
|
||||
// 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),
|
||||
@@ -498,15 +597,7 @@ export const zarrinProvider: AccountingProvider = {
|
||||
// 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 = listOf(
|
||||
await call(s, 'parvande', {
|
||||
id: doc.customerCode,
|
||||
dateAz: toIsoDateTehran(yearAgo),
|
||||
dateTa: toIsoDateTehran(today),
|
||||
pageNum: 1,
|
||||
detailCount: STATEMENT_PAGE,
|
||||
}),
|
||||
);
|
||||
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', {
|
||||
@@ -519,4 +610,4 @@ export const zarrinProvider: AccountingProvider = {
|
||||
};
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __zarrinInternals = { listOf, fullJalali };
|
||||
export const __zarrinInternals = { listOf, fullJalali, repairZarrinJson, accountName };
|
||||
|
||||
Reference in New Issue
Block a user