Files
hedena_gold/apps/api/scripts/test-accounting-zarrin.ts
T
s.esmaeili ea86636027 Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
2026-10-15 17:19:10 +03:30

227 lines
9.8 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.
/**
* 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);
});