feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps

- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-06 16:48:36 +03:30
co-authored by Claude Opus 4.6
parent a72fa607a5
commit 19d490e790
179 changed files with 12538 additions and 370 deletions
@@ -0,0 +1,310 @@
import { Prisma } from '@prisma/client';
import { JalaliDate } from '../jalali';
/**
* One accounting software (D27). AccountingSyncService picks a driver by the
* admin's switch and owns everything around the call — links, outbox, retries,
* logging — so a driver only translates between the app's shapes and its
* software's API.
*
* Every figure a driver returns is ALREADY in the app's convention:
* * gold in grams of 750, positive balance = the customer owes;
* * rial in rial, positive balance = the customer owes.
* Each software's own sign and unit quirks stay inside its driver.
*/
export type ProviderName = 'tahesab' | 'zarrin';
/** Decrypted connection settings, handed to every call. */
export interface ProviderSettings {
baseUrl: string;
token: string;
caCert: string | null;
timeoutMs: number;
/** Tahesab only: the `DBname` header (`TahesabDB` for SQL Server, `DB` for Access). */
dbName?: string;
/** Zarrin only, until verified on a real installation (plan §9). */
balanceOwesWhen?: 'negative' | 'positive';
}
export interface ExternalGroup {
id: string;
name: string;
}
export interface ExternalCustomer {
code: string;
name: string;
/** Normalised to 09xxxxxxxxx, or null when the account has no usable mobile. */
mobile: string | null;
phone: string | null;
nationalCode: string | null;
address: string | null;
groupId: string | null;
groupName: string | null;
}
export interface CustomerPayload {
firstName: string;
lastName: string;
fullName: string;
mobile: string;
phone: string | null;
address: string | null;
nationalCode: string | null;
group: { id: string | null; name: string | null };
}
export interface ExternalBalance {
code: string;
goldGrams: Prisma.Decimal;
rial: Prisma.Decimal;
}
export interface StatementQuery {
code: string;
from: JalaliDate;
to: JalaliDate;
}
/** One line of a customer's statement, normalised across providers. */
export interface StatementRow {
/** Stable within the provider (Tahesab ID + Radif, Zarrin idRadif). */
key: string;
/** The document the line belongs to — what a detail lookup is keyed on. */
documentRef: string;
/** The account the line belongs to, for the SEC-16 ownership check. */
accountCode: string;
documentNo: string;
/** Jalali `1405/07/12`. */
date: string;
time: string | null;
title: string;
description: string | null;
itemName: string | null;
weight: Prisma.Decimal | null;
karat: number | null;
count: Prisma.Decimal | null;
unitPrice: Prisma.Decimal | null;
wagePercent: Prisma.Decimal | null;
/** 750-equivalent gold received from the customer (reduces what they owe). */
goldIn: Prisma.Decimal;
goldOut: Prisma.Decimal;
rialIn: Prisma.Decimal;
rialOut: Prisma.Decimal;
/** Running balance after this line, when the software reports one. */
goldBalance: Prisma.Decimal | null;
rialBalance: Prisma.Decimal | null;
metal: string | null;
temporary: boolean;
}
export interface ExternalKarSakhte {
/** Zarrin idKar; for Tahesab, which has no item ids, the item name itself. */
code: string;
name: string;
groupId: string | null;
groupName: string | null;
typeId: string | null;
typeName: string | null;
saleWagePercent: Prisma.Decimal | null;
stockWeight: Prisma.Decimal | null;
stockCount: Prisma.Decimal | null;
}
export interface KarSakhteStock {
weight: Prisma.Decimal | null;
count: Prisma.Decimal | null;
}
export interface MovementRow {
itemCode: string | null;
itemName: string;
date: string;
description: string | null;
weight: Prisma.Decimal | null;
amount: Prisma.Decimal | null;
count: Prisma.Decimal | null;
}
export interface DocumentLine {
/** The linked کارساخته: Zarrin idKar / Tahesab Name_Kar. */
itemCode: string;
itemName: string;
/** Physical (raw) weight of the line, all pieces. */
weightGrams: Prisma.Decimal;
count: number;
/** Effective making charge: variant wage minus discount, in percent. */
wagePercent: Prisma.Decimal;
/** Flat rial charge of the line (stones), all pieces. */
flatRial: Prisma.Decimal;
karat: number;
description: string;
}
export interface OrderDocumentInput {
/** Deterministic per order; Tahesab dedupes on it (≥ 20 chars, lowercase). */
factorCode: string;
customerCode: string;
date: Date;
description: string;
temporary: boolean;
lines: DocumentLine[];
}
/** Resumable state of a multi-call document (Zarrin posts one call per line). */
export interface DocumentProgress {
factorId?: string;
doneLines?: number;
}
export interface DocumentPostResult {
ref: string;
progress: DocumentProgress;
response: unknown;
}
/** What changed in the accounting software since the last poll (real-time sync). */
export interface ChangeBatch {
/** Opaque; hand it back on the next poll. */
cursor: string;
/** Accounts that got a new or edited document. */
accountCodes: string[];
/** کارساخته names that got a new or edited document. */
itemNames: string[];
/** More changed than one poll can see: the caller must do a full refresh. */
overflow: boolean;
}
export interface ProviderCapabilities {
/** Can a کارساخته be created through the API? (Zarrin yes, Tahesab no.) */
createKarSakhte: boolean;
/** Can a posted document be looked up by our own key before a retry? */
documentInquiry: boolean;
/** Does the movement report cover every account, or only recent documents? */
movementScope: 'all' | 'recent-documents';
/** Can recent changes be polled cheaply (see getChanges)? */
changeFeed: boolean;
}
export interface AccountingProvider {
readonly name: ProviderName;
readonly label: string;
readonly capabilities: ProviderCapabilities;
health(s: ProviderSettings): Promise<Record<string, unknown>>;
listCustomerGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
/** One page of accounts; `next` is null after the last page. */
listCustomers(s: ProviderSettings, cursor: string | null): Promise<{ items: ExternalCustomer[]; next: string | null }>;
findCustomersByMobile(s: ProviderSettings, mobile: string): Promise<ExternalCustomer[]>;
createCustomer(s: ProviderSettings, p: CustomerPayload): Promise<{ code: string; response: unknown }>;
updateCustomer(s: ProviderSettings, code: string, p: CustomerPayload): Promise<{ response: unknown }>;
getBalances(s: ProviderSettings, codes: string[]): Promise<ExternalBalance[]>;
/**
* Documents added or edited since `cursor` (null = first poll: only returns
* the starting cursor). Cheap enough to call every few seconds.
*/
getChanges(s: ProviderSettings, cursor: string | null): Promise<ChangeBatch>;
getStatement(s: ProviderSettings, q: StatementQuery): Promise<StatementRow[]>;
listKarSakhteGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
listKarSakhteTypes(s: ProviderSettings): Promise<ExternalGroup[]>;
listKarSakhte(s: ProviderSettings): Promise<ExternalKarSakhte[]>;
getKarSakhteStock(s: ProviderSettings, item: { code: string; name: string }): Promise<KarSakhteStock>;
getKarSakhteMovement(s: ProviderSettings, from: JalaliDate, to: JalaliDate): Promise<MovementRow[]>;
createKarSakhte(
s: ProviderSettings,
item: { name: string; groupId: string | null; typeId: string | null },
): Promise<{ code: string; response: unknown }>;
postOrderDocument(
s: ProviderSettings,
doc: OrderDocumentInput,
progress: DocumentProgress,
onProgress: (p: DocumentProgress) => Promise<void>,
): Promise<DocumentPostResult>;
/** True when a document with this key exists. */
documentExists(s: ProviderSettings, doc: { factorCode: string; ref: string | null }): Promise<boolean>;
deleteDocument(
s: ProviderSettings,
doc: { factorCode: string; ref: string | null; customerCode: string },
): Promise<{ response: unknown }>;
}
/**
* A call the software answered with an error, or could not be made.
* `ambiguous` = the software may have acted on it anyway (timeout after send).
*/
export class AccountingProviderError extends Error {
constructor(
message: string,
readonly ambiguous = false,
readonly response?: unknown,
readonly notFound = false,
) {
super(message);
this.name = 'AccountingProviderError';
}
}
/** Not offered by this software's API. */
export class AccountingNotSupportedError extends AccountingProviderError {
constructor(what: string) {
super(`${what} در API این نرم‌افزار وجود ندارد`);
this.name = 'AccountingNotSupportedError';
}
}
// ── shared helpers ─────────────────────────────────────────────────────
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
export function asciiDigits(v: string) {
return v
.replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d)));
}
/**
* `9121234567`, `09121234567`, `+989121234567`, `00989121234567`, Persian
* digits → `09121234567`. Anything that is not an Iranian mobile → null.
*/
export function normalizeMobile(v: unknown): string | null {
if (v === null || v === undefined) return null;
let d = asciiDigits(String(v)).replace(/\D/g, '');
if (d.startsWith('0098')) d = d.slice(4);
else if (d.startsWith('98') && d.length === 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** A Prisma.Decimal from a value the software sent, or null for "null"/blank/NaN. */
export function dec(v: unknown): Prisma.Decimal | null {
if (v === null || v === undefined) return null;
const s = asciiDigits(String(v)).replace(/,/g, '').trim();
if (s === '' || s.toLowerCase() === 'null' || s.toLowerCase() === 'nan') return null;
try {
const d = new Prisma.Decimal(s);
return d.isFinite() ? d : null;
} catch {
return null;
}
}
export const ZERO = new Prisma.Decimal(0);
export function str(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v).trim();
return s === '' || s.toLowerCase() === 'null' ? null : s;
}
/** Split a full name into first/last the way the app's DB trigger does. */
export function splitName(full: string): { firstName: string; lastName: string } {
const parts = full.trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return { firstName: parts[0] ?? '', lastName: '' };
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
}
@@ -0,0 +1,18 @@
import { AccountingProvider, ProviderName } from './accounting-provider';
import { tahesabProvider } from './tahesab.provider';
import { zarrinProvider } from './zarrin.provider';
/**
* Every accounting software, keyed by the admin switch's value. A new one is a
* driver file plus one line here — the same shape as the SMS drivers (D26).
*/
export const ACCOUNTING_PROVIDERS: Readonly<Record<ProviderName, AccountingProvider>> = {
tahesab: tahesabProvider,
zarrin: zarrinProvider,
};
export function isProviderName(v: unknown): v is ProviderName {
return typeof v === 'string' && Object.prototype.hasOwnProperty.call(ACCOUNTING_PROVIDERS, v);
}
export * from './accounting-provider';
@@ -0,0 +1,298 @@
import { Prisma } from '@prisma/client';
import { httpRequest } from '../accounting-http';
import { AccountingProviderError, OrderDocumentInput, ProviderSettings } from './accounting-provider';
import { karatWithWage, tahesabProvider as t } from './tahesab.provider';
jest.mock('../accounting-http', () => ({
...jest.requireActual('../accounting-http'),
httpRequest: jest.fn(),
}));
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
const s: ProviderSettings = { baseUrl: 'https://tahesab.local:8081/', token: 'TOK', caCert: null, timeoutMs: 5000, dbName: 'TahesabDB' };
const reply = (status: number, body: unknown) =>
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
/** The body of the n-th request, parsed. */
const sent = (n = 0) => JSON.parse(http.mock.calls[n][0].body as string);
beforeEach(() => http.mockReset());
describe('wire format', () => {
it('posts {"Method": [args]} to the root with the bearer token and DBname', async () => {
http.mockReturnValueOnce(reply(200, [{ Name: 'بنکدار', GID: 2 }]));
const groups = await t.listCustomerGroups(s);
const req = http.mock.calls[0][0];
expect(req.method).toBe('POST');
expect(req.url).toBe('https://tahesab.local:8081/');
expect(req.headers).toMatchObject({ Authorization: 'Bearer TOK', DBname: 'TahesabDB' });
expect(sent()).toEqual({ DoListGorooh: [] });
// GID 0 (accounts in no group) is offered too.
expect(groups).toEqual([
{ id: '2', name: 'بنکدار' },
{ id: '0', name: 'بدون گروه (GID 0)' },
]);
});
it('turns {"ERROR": …} into a provider error, flagging "not found"', async () => {
http.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.', Command: 'POST', SSL: '1' }));
await expect(t.updateCustomer(s, '5', { firstName: 'a', lastName: 'b', fullName: 'a b', mobile: '09120000000', phone: null, address: null, nationalCode: null, group: { id: null, name: null } })).rejects.toMatchObject({
notFound: true,
response: { ERROR: 'هیچ رکوردی یافت نشد.' },
});
});
it('reports a timeout after the request went out as ambiguous', async () => {
const { AccountingHttpError } = jest.requireActual('../accounting-http');
http.mockRejectedValueOnce(new AccountingHttpError('timeout', true, 'TIMEOUT'));
await expect(t.listCustomerGroups(s)).rejects.toMatchObject({ ambiguous: true });
});
});
describe('customers', () => {
it('pages through code windows and stops after empty ones', async () => {
http.mockReturnValueOnce(
reply(200, {
'1': { Code: 1, Name: 'masoud', GID: '2', GoroupName: 'بنکدار', Mobile: '9121234567', CodeMelli: '', Address: 'آدرس' },
}),
);
const page1 = await t.listCustomers(s, null);
expect(sent()).toEqual({ Dolistmoshtari: [1, 500] });
expect(page1.items[0]).toMatchObject({ code: '1', mobile: '09121234567', groupId: '2', nationalCode: null });
expect(page1.next).toBe('501:0');
http.mockReturnValue(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
let cursor: string | null = page1.next;
let calls = 0;
while (cursor) {
cursor = (await t.listCustomers(s, cursor)).next;
calls += 1;
}
expect(calls).toBe(4);
});
it('looks a mobile up in both stored forms and keeps exact matches only', async () => {
http
.mockReturnValueOnce(reply(200, { '1': { Code: 1, Name: 'a', Mobile: '9121234567' }, '790': { Code: 790, Name: 'b', Mobile: '9121234567' } }))
.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
const found = await t.findCustomersByMobile(s, '09121234567');
expect(sent(0)).toEqual({ DoListMoshtari: [9121234567] });
expect(sent(1)).toEqual({ DoListMoshtari: ['09121234567'] });
expect(found.map((c) => c.code)).toEqual(['1', '790']);
});
it('creates with an auto code and returns it', async () => {
http.mockReturnValueOnce(reply(200, { OK: 35 }));
const res = await t.createCustomer(s, {
firstName: 'علی',
lastName: 'محمدی',
fullName: 'علی محمدی',
mobile: '09121112233',
phone: null,
address: null,
nationalCode: null,
group: { id: '13', name: 'مشتري' },
});
expect(res.code).toBe('35');
const args = sent().DoNewMoshtari;
expect(args[0]).toBe('علی محمدی');
expect(args[1]).toBe('مشتري');
expect(args[2]).toBe('09121112233');
expect(args[8]).toBe(-1);
});
});
describe('balances and statement', () => {
it('flips the sign: in Tahesab negative means the customer owes', async () => {
http.mockReturnValueOnce(
reply(200, { MandeHesab: [{ Code: '1', MandeyeMali: '-238999230', MandeyeVazni: '-177.431' }, { Code: '2', MandeyeMali: '5', MandeyeVazni: '1.5' }] }),
);
const b = await t.getBalances(s, ['1', '2', '999']);
expect(sent()).toEqual({ getmandehesabbycode: ['1', '2', '999'] });
expect(b).toEqual([
{ code: '1', goldGrams: new Prisma.Decimal('177.431'), rial: new Prisma.Decimal('238999230') },
{ code: '2', goldGrams: new Prisma.Decimal('-1.5'), rial: new Prisma.Decimal('-5') },
]);
});
it('maps DoListAsnad rows (real test-server rows)', async () => {
http.mockReturnValueOnce(
reply(200, {
'11558': {
ID: 11558, Radif: 1, Factor_Code: '123456789123421578191111985', MCode: 1, Tarikh: '1405/07/11', Tarikh_Time: '19:47:44',
NO: 'ورود کارساخته', Sh_Factor: 228, Vazn: 1.25, No_Kar: 'testmm', Ayar: 750, Fi: 10000, Mazaneh: 'null', Count: 1,
TabdilVazn: 1.25, Mali: 100000, Sharh1: 'sharhtest', TahesabVazni: 11526.174, TahesabMali: -209358396276,
Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: false, DarsadSood: 5.5,
},
'12261': {
ID: 12261, Radif: 1, Factor_Code: 'GBORDX', MCode: 1, Tarikh: '1405/07/12', NO: 'خروج کارساخته', Vazn: 2,
TabdilVazn: -2, Mali: 'null', TahesabVazni: 0, TahesabMali: 0, Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: true,
},
}),
);
const rows = await t.getStatement(s, { code: '1', from: { jy: 1405, jm: 6, jd: 1 }, to: { jy: 1405, jm: 7, jd: 13 } });
expect(sent()).toEqual({ DoListAsnad: [-1, 1, '1405-06-01', '1405-07-13', '', 0, 'IRR'] });
expect(rows[0]).toMatchObject({
documentRef: '123456789123421578191111985',
accountCode: '1',
title: 'ورود کارساخته',
itemName: 'testmm',
metal: 'طلا',
temporary: false,
});
expect(rows[0].goldIn.toString()).toBe('1.25');
expect(rows[0].goldOut.toString()).toBe('0');
expect(rows[0].rialIn.toString()).toBe('100000');
expect(rows[0].goldBalance?.toString()).toBe('-11526.174');
expect(rows[0].rialBalance?.toString()).toBe('209358396276');
// A temporary row moves nothing yet: no balance of its own.
expect(rows[1]).toMatchObject({ documentRef: 'gbordx', temporary: true, goldBalance: null });
expect(rows[1].goldOut.toString()).toBe('2');
});
});
describe('order documents', () => {
const doc: OrderDocumentInput = {
factorCode: 'gbordcm1abcdefghijklmnopqrstuv',
customerCode: '2',
date: new Date('2026-10-05T08:00:00Z'),
description: 'سفارش 1001 از اپ',
temporary: true,
lines: [
{ itemCode: 'انگشتر', itemName: 'انگشتر', weightGrams: new Prisma.Decimal('4.200'), count: 2, wagePercent: new Prisma.Decimal(10), flatRial: new Prisma.Decimal(0), karat: 750, description: 'line 1' },
{ itemCode: 'النگو', itemName: 'النگو', weightGrams: new Prisma.Decimal('10'), count: 1, wagePercent: new Prisma.Decimal('18.5'), flatRial: new Prisma.Decimal(500000), karat: 750, description: 'line 2' },
],
};
it('raises the karat by the making charge — the only field that moves the gold balance', () => {
expect(karatWithWage(750, new Prisma.Decimal(10)).toString()).toBe('825');
expect(karatWithWage(750, new Prisma.Decimal('18.5')).toString()).toBe('888.75');
});
it('posts one temporary exit row per line under one Factor_Code', async () => {
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
const progress: unknown[] = [];
const res = await t.postOrderDocument(s, doc, {}, async (p) => {
progress.push(p);
});
const r1 = sent(0).DoNewSanadVKHKar;
const r2 = sent(1).DoNewSanadVKHKar;
expect(r1).toHaveLength(27);
expect(r1[0]).toBe(0); // temporary
expect(r1[1]).toBe(2); // Moshtari_Code
expect([r1[3], r2[3]]).toEqual([1, 2]); // Radif_Number
expect([r1[4], r1[5], r1[6]]).toEqual([1405, 7, 13]);
expect(r1[7]).toBe(4.2); // physical weight
expect(r1[8]).toBe(825); // karat with the charge
expect(r1[9]).toBe(2);
expect(r1[10]).toBe('انگشتر');
expect(r1[11]).toBe(1); // exit
expect(r1[16]).toBe(0); // Darsad_Talayee never double-counts
expect(r2[13]).toBe(500000); // stones in MablaghKol
expect(r2[20]).toBe(0); // not PoolSang
expect(r1[22]).toBe(doc.factorCode);
expect(r1[24]).toBe(1); // MultiRadif
expect(progress).toEqual([{ doneLines: 1 }, { doneLines: 2 }]);
expect(res.ref).toBe(doc.factorCode);
});
it('resumes after the last confirmed line', async () => {
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
await t.postOrderDocument(s, doc, { doneLines: 1 }, async () => undefined);
expect(http).toHaveBeenCalledTimes(1);
expect(sent(0).DoNewSanadVKHKar[3]).toBe(2);
});
it('treats a duplicate-row rejection as "already there", which makes a retry safe', async () => {
http
.mockReturnValueOnce(
reply(400, {
ERROR:
"[EUniError] The statement has been terminated.\r\nCannot insert duplicate key row in object 'dbo.hesab_detail' with unique index 'UX_hesab_detail_UniqRowID'.",
}),
)
.mockReturnValueOnce(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
const res = await t.postOrderDocument(s, doc, {}, async () => undefined);
expect(res.progress).toEqual({ doneLines: 2 });
});
it('does not swallow other errors', async () => {
http.mockReturnValueOnce(reply(400, { ERROR: 'کد حساب نامعتبر است' }));
await expect(t.postOrderDocument(s, doc, {}, async () => undefined)).rejects.toBeInstanceOf(AccountingProviderError);
});
it('inquires and deletes by Factor_Code', async () => {
http.mockReturnValueOnce(reply(200, { Found: doc.factorCode })).mockReturnValueOnce(reply(404, { ERROR: 'Not_Found' }));
expect(await t.documentExists(s, { factorCode: doc.factorCode, ref: null })).toBe(true);
expect(await t.documentExists(s, { factorCode: 'x', ref: null })).toBe(false);
http.mockReturnValueOnce(reply(404, { ERROR: 'سند یافت نشد.' }));
expect((await t.deleteDocument(s, { factorCode: 'x', ref: null, customerCode: '2' })).response).toEqual({ alreadyDeleted: true });
});
});
describe('items', () => {
it('skips item names that lost their encoding and reads stock per name', async () => {
http.mockReturnValueOnce(reply(200, [{ Name: 'انگشتر', Ayar: 750 }, { Name: '???????0.400????' }]));
const items = await t.listKarSakhte(s);
expect(items.map((i) => i.name)).toEqual(['انگشتر']);
http.mockReturnValueOnce(reply(200, { Name: 'انگشتر', VaznMojood: 12.5, TedadMojood: 3, MaxAyar: 750 }));
const stock = await t.getKarSakhteStock(s, { code: 'انگشتر', name: 'انگشتر' });
expect(sent(1)).toEqual({ GetMojoodiKarSakhte: ['انگشتر', 0] });
expect(stock.weight?.toString()).toBe('12.5');
expect(stock.count?.toString()).toBe('3');
});
it('cannot create items through the API', async () => {
await expect(t.createKarSakhte(s, { name: 'x', groupId: null, typeId: null })).rejects.toThrow('وجود ندارد');
});
});
describe('change feed (real-time sync)', () => {
const row = (ID: number, MCode: number, extra: Record<string, unknown> = {}) => ({
ID,
MCode,
NO: 'فروش طلا',
No_Kar: 'null',
ZamanEdit: null,
...extra,
});
const feed = (rows: ReturnType<typeof row>[]) => reply(200, Object.fromEntries(rows.map((r) => [String(r.ID), r])));
it('the first poll only returns the starting cursor', async () => {
http.mockReturnValueOnce(feed([row(10, 1), row(11, 2)]));
expect(await t.getChanges(s, null)).toEqual({ cursor: '11|', accountCodes: [], itemNames: [], overflow: false });
expect(sent()).toEqual({ DoListUpdatedAsnad: [200] });
});
it('later polls report the accounts and کارساخته of new documents only', async () => {
http.mockReturnValueOnce(
feed([row(11, 2), row(12, 7), row(13, 7), row(14, 9, { NO: 'خروج کارساخته', No_Kar: 'انگشتر' })]),
);
const r = await t.getChanges(s, '11|');
expect(r.accountCodes.sort()).toEqual(['7', '9']);
expect(r.itemNames).toEqual(['انگشتر']);
expect(r.cursor).toBe('14|');
expect(r.overflow).toBe(false);
});
it('sees an edit of an old document through ZamanEdit', async () => {
http.mockReturnValueOnce(feed([row(11, 2, { ZamanEdit: '1405/07/12 18:00:00' }), row(12, 3)]));
const r = await t.getChanges(s, '12|1405/07/12 17:00:00');
expect(r.accountCodes).toEqual(['2']);
});
it('says overflow when every row of the window is new', async () => {
const rows = Array.from({ length: 200 }, (_, i) => row(100 + i, i));
http.mockReturnValueOnce(feed(rows));
expect((await t.getChanges(s, '50|')).overflow).toBe(true);
});
it('reports nothing when nothing changed', async () => {
http.mockReturnValueOnce(feed([row(11, 2), row(12, 3)]));
expect(await t.getChanges(s, '12|')).toMatchObject({ accountCodes: [], itemNames: [], overflow: false });
});
});
@@ -0,0 +1,511 @@
import { Prisma } from '@prisma/client';
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
import { formatJalali, toJalali } from '../jalali';
import {
AccountingNotSupportedError,
AccountingProvider,
AccountingProviderError,
ExternalCustomer,
ProviderSettings,
StatementRow,
ZERO,
dec,
normalizeMobile,
str,
} from './accounting-provider';
/**
* Tahesab (ته‌حساب) — https://tahesab.ir/APiDoc/ (OpenAPI 3.1, API 10.1405.x).
*
* Wire format, confirmed against the public test server on 2026-10-05:
* POST <base>/ body {"<Method>": [args by position]}
* headers Authorization: Bearer <token>, DBname: TahesabDB (SQL Server) | DB (Access)
* The server dispatches on the body key; the path in the docs is cosmetic.
* Errors come back as {"ERROR": "..."} with HTTP 400/404.
*
* Behaviour measured on the test server (plan §9), which this driver relies on:
* * Balance sign: an exit (خروج) LOWERS MandeyeVazni — negative = the
* customer owes, as henza_server's `* -1` assumed. Flipped here.
* * A temporary (موقت) document does not move the balance until the
* accountant finalises it.
* * The gold a document moves is Vazn × Ayar / 750. Darsad_Talayee and
* Ojrat are stored but do NOT move the balance, so the making charge is sent
* as a raised Ayar (750 at 10% → 825); Vazn stays the physical weight.
* * PoolSang does not move the rial balance; MablaghKol does.
* * Factor_Code is stored lowercased; re-sending a row with the same
* Factor_Code + Radif_Number is rejected as a duplicate key — which is what
* makes a retry safe.
*/
const CUSTOMER_PAGE = 500;
/** Consecutive empty code windows before the account list is considered done. */
const EMPTY_WINDOWS_TO_STOP = 4;
const MAX_CUSTOMER_CODE = 2_000_000;
const BALANCE_CHUNK = 100;
const RECENT_DOCUMENTS = 1000;
const CHANGE_WINDOW = 200;
function baseUrl(s: ProviderSettings) {
return s.baseUrl.replace(/\/+$/, '');
}
function headers(s: ProviderSettings) {
return {
Authorization: `Bearer ${s.token}`,
DBname: s.dbName || 'TahesabDB',
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
function isNotFoundMessage(msg: string) {
return /یافت نشد|Not_Found|not found/i.test(msg);
}
async function call(s: ProviderSettings, method: string, args: unknown[]): Promise<unknown> {
let res;
try {
res = await httpRequest({
method: 'POST',
url: `${baseUrl(s)}/`,
headers: headers(s),
body: JSON.stringify({ [method]: args }),
timeoutMs: s.timeoutMs,
ca: s.caCert,
});
} catch (err) {
if (err instanceof AccountingHttpError) {
throw new AccountingProviderError(`ته‌حساب (${method}): ${err.message}`, err.ambiguous);
}
throw err;
}
let json: unknown;
try {
json = parseLooseJson(res.body);
} catch {
throw new AccountingProviderError(
`ته‌حساب (${method}): پاسخ نامعتبر (HTTP ${res.status})`,
res.status >= 200 && res.status < 300,
res.body.slice(0, 500),
);
}
const error = json && typeof json === 'object' && !Array.isArray(json) ? (json as { ERROR?: unknown }).ERROR : undefined;
if (error !== undefined || res.status < 200 || res.status >= 300) {
const msg = typeof error === 'string' ? error : `HTTP ${res.status}`;
throw new AccountingProviderError(
`ته‌حساب (${method}): ${msg.split('\r\n')[0]}`,
false,
trimDiagnostics(json),
res.status === 404 || isNotFoundMessage(msg),
);
}
return json;
}
/** Tahesab error bodies carry ~30 diagnostic fields; keep only the message. */
function trimDiagnostics(json: unknown) {
if (json && typeof json === 'object' && 'ERROR' in (json as object)) {
return { ERROR: (json as { ERROR: unknown }).ERROR };
}
return json;
}
/** A method that answers "nothing found" with an ERROR, as an empty result. */
async function callList(s: ProviderSettings, method: string, args: unknown[]) {
try {
return await call(s, method, args);
} catch (err) {
if (err instanceof AccountingProviderError && err.notFound) return null;
throw err;
}
}
/** Tahesab keys row objects by id: {"1": {...}, "2": {...}}. */
function rowsOf(json: unknown): Record<string, unknown>[] {
if (!json) return [];
if (Array.isArray(json)) return json as Record<string, unknown>[];
if (typeof json === 'object') {
return Object.values(json as Record<string, unknown>).filter(
(v): v is Record<string, unknown> => !!v && typeof v === 'object',
);
}
return [];
}
function toCustomer(r: Record<string, unknown>): ExternalCustomer {
return {
code: String(r.Code),
name: str(r.Name) ?? '',
mobile: normalizeMobile(r.Mobile),
phone: null,
nationalCode: str(r.CodeMelli),
address: str(r.Address),
groupId: str(r.GID),
groupName: str(r.GoroupName),
};
}
const neg = (d: Prisma.Decimal | null) => (d ? d.negated() : null);
const METALS: Record<string, string> = { '0': 'طلا', '1': 'پلاتین', '2': 'نقره' };
/**
* Rounds the raised karat to 3 decimals, which is what the desktop app keeps.
* 750 × (1 + 10/100) = 825.
*/
export function karatWithWage(karat: number, wagePercent: Prisma.Decimal) {
return new Prisma.Decimal(karat).mul(new Prisma.Decimal(1).add(wagePercent.div(100))).toDecimalPlaces(3);
}
export const tahesabProvider: AccountingProvider = {
name: 'tahesab',
label: 'ته‌حساب',
capabilities: { createKarSakhte: false, documentInquiry: true, movementScope: 'recent-documents', changeFeed: true },
async health(s) {
const path = baseUrl(s).startsWith('https:') ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL';
let info: Record<string, unknown> = {};
try {
const res = await httpRequest({
method: 'GET',
url: `${baseUrl(s)}/${path}`,
headers: headers(s),
timeoutMs: s.timeoutMs,
ca: s.caCert,
});
const json = parseLooseJson(res.body) as Record<string, unknown>;
info = {
status: json.Status,
api: json.Api_Status,
db: json.DB_Status,
version: json.TahesabVersion,
dbType: json.DBType,
dbName: json.DBName,
};
} catch (err) {
if (err instanceof AccountingHttpError) throw new AccountingProviderError(`ته‌حساب: ${err.message}`);
throw new AccountingProviderError('ته‌حساب: پاسخ CheckHealth نامعتبر است');
}
// CheckHealth ignores the token and DBname; a real call proves both.
const groups = rowsOf(await call(s, 'DoListGorooh', []));
return { ...info, customerGroups: groups.length };
},
async listCustomerGroups(s) {
const groups = rowsOf(await call(s, 'DoListGorooh', [])).map((g) => ({
id: String(g.GID),
name: str(g.Name) ?? String(g.GID),
}));
// Accounts outside every defined group carry GID 0 (seen on the test
// server as «مشتري»); offer it so it can be picked for import.
if (!groups.some((g) => g.id === '0')) groups.push({ id: '0', name: 'بدون گروه (GID 0)' });
return groups;
},
async listCustomers(s, cursor) {
let [start, empty] = (cursor ?? '1:0').split(':').map(Number);
if (!Number.isFinite(start) || start < 1) start = 1;
if (!Number.isFinite(empty)) empty = 0;
const json = await callList(s, 'Dolistmoshtari', [start, start + CUSTOMER_PAGE - 1]);
const items = rowsOf(json).map(toCustomer);
empty = items.length === 0 ? empty + 1 : 0;
const nextStart = start + CUSTOMER_PAGE;
const done = empty >= EMPTY_WINDOWS_TO_STOP || nextStart > MAX_CUSTOMER_CODE;
return { items, next: done ? null : `${nextStart}:${empty}` };
},
async findCustomersByMobile(s, mobile) {
const target = normalizeMobile(mobile);
if (!target) return [];
// The account's Mobile is free text: some rows hold 9121234567, others
// 09121234567, and the lookup matches the stored form. Ask both ways.
const found = new Map<string, ExternalCustomer>();
for (const form of [Number(target.slice(1)), target]) {
for (const r of rowsOf(await callList(s, 'DoListMoshtari', [form]))) {
const c = toCustomer(r);
if (c.mobile === target) found.set(c.code, c);
}
}
return [...found.values()];
},
async createCustomer(s, p) {
const json = (await call(s, 'DoNewMoshtari', [
p.fullName,
p.group.name ?? '',
p.mobile,
p.address ?? '',
p.nationalCode ?? '',
'',
'',
'-1',
-1, // Moshtari_Code -1: Tahesab assigns the code
0, // Jens_Felez: gold
])) as { OK?: unknown };
if (json?.OK === undefined || json.OK === null) {
throw new AccountingProviderError('ته‌حساب: کد حساب جدید برنگشت', false, json);
}
return { code: String(json.OK), response: json };
},
async updateCustomer(s, code, p) {
// "-1" leaves a field unchanged (DoEditMoshtari example in the docs).
const json = await call(s, 'DoEditMoshtari', [
p.fullName,
p.group.name ?? '-1',
p.mobile,
p.address ?? '-1',
p.nationalCode ?? '-1',
'-1',
'-1',
'-1',
Number(code),
]);
return { response: json };
},
async getBalances(s, codes) {
const out = [];
for (let i = 0; i < codes.length; i += BALANCE_CHUNK) {
const chunk = codes.slice(i, i + BALANCE_CHUNK);
// Unknown codes are simply left out of MandeHesab.
const json = (await callList(s, 'getmandehesabbycode', chunk)) as { MandeHesab?: unknown } | null;
for (const r of rowsOf(json?.MandeHesab)) {
out.push({
code: String(r.Code),
goldGrams: (dec(r.MandeyeVazni) ?? ZERO).negated(),
rial: (dec(r.MandeyeMali) ?? ZERO).negated(),
});
}
}
return out;
},
async getChanges(s, cursor) {
// DoListUpdatedAsnad lists the newest documents by registration/edit time.
// Cursor "maxId|latestEdit": a row is news when its ID is higher, or it
// carries a later ZamanEdit. Nothing older than the window is visible, so
// a window with no already-seen row means "too many changes: refresh all".
const rows = rowsOf(await callList(s, 'DoListUpdatedAsnad', [CHANGE_WINDOW]));
let maxId = 0;
let latestEdit = '';
for (const r of rows) {
maxId = Math.max(maxId, Number(r.ID) || 0);
const edited = String(r.ZamanEdit ?? '');
if (edited && edited !== 'null' && edited > latestEdit) latestEdit = edited;
}
const next = `${maxId}|${latestEdit}`;
if (cursor === null) return { cursor: next, accountCodes: [], itemNames: [], overflow: false };
const [seenId, seenEdit] = [Number(cursor.split('|')[0]) || 0, cursor.split('|')[1] ?? ''];
const fresh = rows.filter((r) => {
const edited = String(r.ZamanEdit ?? '');
return (Number(r.ID) || 0) > seenId || (!!edited && edited !== 'null' && edited > seenEdit);
});
const accounts = new Set<string>();
const items = new Set<string>();
for (const r of fresh) {
const code = str(r.MCode);
if (code) accounts.add(code);
const kar = str(r.No_Kar);
if (kar && /کار/.test(String(r.NO ?? ''))) items.add(kar);
}
return {
cursor: next,
accountCodes: [...accounts],
itemNames: [...items],
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
};
},
async getStatement(s, q) {
// Count_Last = -1 asks for the running balance on every row.
const json = await callList(s, 'DoListAsnad', [
-1,
Number(q.code),
formatJalali(q.from),
formatJalali(q.to),
'',
0,
'IRR',
]);
return rowsOf(json).map((r) => toStatementRow(r));
},
async listKarSakhteGroups() {
return [];
},
async listKarSakhteTypes() {
return [];
},
async listKarSakhte(s) {
// Only items that already have a document appear here (Tahesab has no
// item master in its API). Names that arrive as "????" lost their
// encoding upstream and cannot be matched back; they are skipped.
return rowsOf(await callList(s, 'DoListNameKarsakhte', []))
.map((r) => str(r.Name))
.filter((name): name is string => !!name && !name.includes('??'))
.map((name) => ({
code: name,
name,
groupId: null,
groupName: null,
typeId: null,
typeName: null,
saleWagePercent: null,
stockWeight: null,
stockCount: null,
}));
},
async getKarSakhteStock(s, item) {
const json = (await callList(s, 'GetMojoodiKarSakhte', [item.name, 0])) as Record<string, unknown> | null;
return { weight: dec(json?.VaznMojood), count: dec(json?.TedadMojood) };
},
async getKarSakhteMovement(s, from, to) {
// Tahesab cannot list documents across all accounts by date; the closest
// is the most recent N documents, filtered here.
const lo = formatJalali(from, '/');
const hi = formatJalali(to, '/');
return rowsOf(await callList(s, 'DoListUpdatedAsnad', [RECENT_DOCUMENTS]))
.filter((r) => /کار/.test(String(r.NO ?? '')))
.filter((r) => {
const d = String(r.Tarikh ?? '');
return d >= lo && d <= hi;
})
.map((r) => ({
itemCode: str(r.No_Kar),
itemName: str(r.No_Kar) ?? '—',
date: String(r.Tarikh ?? ''),
description: [str(r.NO), str(r.Sharh1)].filter(Boolean).join(' — ') || null,
weight: dec(r.Vazn),
amount: dec(r.Mali),
count: dec(r.Count),
}));
},
async createKarSakhte() {
throw new AccountingNotSupportedError('ساخت کارساخته');
},
async postOrderDocument(s, doc, progress, onProgress) {
const d = toJalali(doc.date);
const multi = doc.lines.length > 1 ? 1 : -1;
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 args = [
doc.temporary ? 0 : 1, // Sabte_Kol_Or_Movaghat: 0 = temporary
Number(doc.customerCode),
-1, // Factor_Number: assigned by Tahesab
i + 1, // Radif_Number
d.jy,
d.jm,
d.jd,
Number(line.weightGrams.toFixed(3)),
Number(karatWithWage(line.karat, line.wagePercent).toString()),
line.count,
line.itemName,
1, // IsVoroodOrKhorooj: 1 = exit (goods to the customer)
1, // OjratTedadiOrGerami: per gram
Number(line.flatRial.toFixed(0)), // MablaghKol — the only rial field that moves the balance
0,
0,
0, // Darsad_Talayee: the charge is already in Ayar; never count it twice
0,
'',
0,
0, // PoolSang: does not move the balance; the stones are in MablaghKol
line.description,
doc.factorCode,
-1,
multi,
0,
0,
];
try {
last = await call(s, 'DoNewSanadVKHKar', args);
} catch (err) {
// The row is already there (an earlier attempt landed but its reply
// was lost): Factor_Code + Radif_Number is a unique key in Tahesab.
if (!(err instanceof AccountingProviderError && /duplicate key/i.test(err.message + JSON.stringify(err.response ?? '')))) {
throw err;
}
last = { duplicate: true };
}
done = i + 1;
await onProgress({ doneLines: done });
}
const ref = String((last as { Factor_Code?: unknown })?.Factor_Code ?? doc.factorCode).toLowerCase();
return { ref, progress: { doneLines: done }, response: last };
},
async documentExists(s, doc) {
try {
await call(s, 'DoNewSanadInquiry', [doc.factorCode]);
return true;
} catch (err) {
if (err instanceof AccountingProviderError && err.notFound) return false;
// A multi-row document answers {"ERROR":"Duplicate"}: found, more than
// one row under the code (seen on the test server).
if (err instanceof AccountingProviderError && /:\s*Duplicate$/i.test(err.message)) return true;
throw err;
}
},
async deleteDocument(s, doc) {
try {
return { response: await call(s, 'DoDeleteSanad', [doc.factorCode]) };
} catch (err) {
// Already gone — the goal is reached.
if (err instanceof AccountingProviderError && err.notFound) return { response: { alreadyDeleted: true } };
throw err;
}
},
};
function toStatementRow(r: Record<string, unknown>): StatementRow {
const metal = String(r.Jens_Felez ?? '0');
const temporary = r.isSanadMovaghat === true;
const isGold = metal === '0';
const isRial = !r.Arz || String(r.Arz).toUpperCase() === 'IRR';
// Positive TabdilVazn / Mali = in the customer's favour (they gave us gold
// or money); negative = they took goods or money from us.
const tabdil = isGold ? dec(r.TabdilVazn) ?? ZERO : ZERO;
const mali = isRial ? dec(r.Mali) ?? ZERO : ZERO;
return {
key: `${r.ID}-${r.Radif ?? 1}`,
documentRef: String(r.Factor_Code ?? r.ID).toLowerCase(),
accountCode: String(r.MCode ?? ''),
documentNo: String(r.Sh_Factor ?? ''),
date: String(r.Tarikh ?? ''),
time: str(r.Tarikh_Time),
title: str(r.NO) ?? '',
description: [str(r.Sharh1), str(r.Sharh2), str(r.Baabat)].filter(Boolean).join(' — ') || null,
itemName: str(r.No_Kar),
weight: dec(r.Vazn),
karat: dec(r.Ayar)?.toNumber() ?? null,
count: dec(r.Count),
unitPrice: dec(r.Fi) ?? dec(r.Mazaneh),
wagePercent: dec(r.DarsadSood),
goldIn: tabdil.gt(0) ? tabdil : ZERO,
goldOut: tabdil.lt(0) ? tabdil.negated() : ZERO,
rialIn: mali.gt(0) ? mali : ZERO,
rialOut: mali.lt(0) ? mali.negated() : ZERO,
// A temporary row reports TahesabVazni/Mali = 0 because it moves nothing
// yet; null lets the report carry the previous balance forward instead.
goldBalance: temporary ? null : neg(dec(r.TahesabVazni)),
rialBalance: temporary ? null : neg(dec(r.TahesabMali)),
metal: METALS[metal] ?? metal,
temporary,
};
}
@@ -0,0 +1,215 @@
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('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('pages with Get100Account and fills groups from listhesabha', async () => {
const page = Array.from({ length: 100 }, (_, i) => ({ code: 81 + i, name: 'n', namKhanevadegi: `f${i}`, mobile: i === 0 ? '09121112233' : '' }));
http
.mockReturnValueOnce(reply({ state: true, msg: '', date: page }))
.mockReturnValueOnce(reply({ data: [{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' }] }));
const res = await z.listCustomers(s, null);
expect(url(0).searchParams.get('id')).toBe('0');
expect(url(1).searchParams.get('az')).toBe('81');
expect(url(1).searchParams.get('ta')).toBe('180');
expect(res.items[0]).toMatchObject({ code: '81', name: 'حسین اصغری', mobile: '09121112233', groupName: 'مشتری (خریدار)', phone: '021' });
expect(res.next).toBe('181');
});
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('folds a running balance from «مانده از قبل»', async () => {
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' },
],
}),
);
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');
});
});
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 });
});
});
@@ -0,0 +1,466 @@
import { Prisma } from '@prisma/client';
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
import { jalaliToIsoDate, toIsoDateTehran } from '../jalali';
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.
*
* 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.
*
* 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;
function endpoint(s: ProviderSettings) {
return s.baseUrl.trim();
}
async function call(
s: ProviderSettings,
kind: string,
params: Record<string, string | number | null | undefined> = {},
): 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: 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) {
throw new AccountingProviderError(`زرین (${kind}): HTTP ${res.status}`, false, res.body.slice(0, 500), res.status === 404);
}
let json: unknown;
try {
json = parseLooseJson(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[];
}
/** Zarrin wraps lists as `data`, once as `date` (Get100Account), 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 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;
}
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, cursor) {
const from = Number(cursor ?? 0) || 0;
// Get100Account pages by code (100 accounts from `id` on) but carries no
// group or address; listhesabha over the same code span fills those in.
const page = listOf(await call(s, 'Get100Account', { id: from }));
if (page.length === 0) return { items: [], next: null };
const codes = page.map((r) => Number(r.code)).filter(Number.isFinite);
const lo = Math.min(...codes);
const hi = Math.max(...codes);
const details = new Map<string, Record<string, unknown>>();
for (const r of listOf(await call(s, 'listhesabha', { az: lo, ta: hi }))) details.set(String(r.id), r);
const items: ExternalCustomer[] = page.map((r) => {
const code = String(r.code);
const d = details.get(code) ?? {};
return {
code,
name: str(d.nam) ?? joinName(r.name, r.namKhanevadegi),
mobile: normalizeMobile(d.mobile ?? r.mobile),
phone: str(d.tell),
nationalCode: str(d.codeMeli),
address: str(d.address),
groupId: null,
groupName: str(d.gorooh),
};
});
const next = page.length < 100 ? null : String(hi + 1);
return { items, next };
},
async findCustomersByMobile(s, mobile) {
const target = normalizeMobile(mobile);
if (!target) return [];
let rows: Record<string, unknown>[];
try {
rows = listOf(await call(s, 'getinfoByPhone', { number: target }));
} catch (err) {
if (err instanceof AccountingProviderError && err.notFound) return [];
throw err;
}
return rows
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
.map((r) => ({
code: String(r.code),
name: joinName(r.nam, r.namKhanevadegi),
mobile: target,
phone: str(r.tel),
nationalCode: null,
address: str(r.address),
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.
// Not yet seen on a real install (ACC-01).
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: 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;
}
// 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;
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,
temporary: false,
};
});
},
async listKarSakhteGroups(s) {
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
},
async listKarSakhteTypes(s) {
const types = new Map<string, string>();
for (const r of listOf(await call(s, 'listKarSakhte'))) {
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'))
.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) {
return listOf(await call(s, 'infogardeshsakhte', { az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) })).map(
(r) => ({
itemCode: str(r.code_kar),
itemName: str(r.nam_kar) ?? '—',
date: str(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 = listOf(
await call(s, 'parvande', {
id: doc.customerCode,
dateAz: toIsoDateTehran(yearAgo),
dateTa: toIsoDateTehran(today),
pageNum: 1,
detailCount: STATEMENT_PAGE,
}),
);
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 };