Files
tehran_taj/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts
T
s.esmaeiliandClaude Opus 4.6 19d490e790 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>
2026-10-06 16:48:36 +03:30

216 lines
9.5 KiB
TypeScript

import { Prisma } from '@prisma/client';
import { httpRequest } from '../accounting-http';
import { OrderDocumentInput, ProviderSettings } from './accounting-provider';
import { __zarrinInternals, zarrinProvider as z } from './zarrin.provider';
jest.mock('../accounting-http', () => ({
...jest.requireActual('../accounting-http'),
httpRequest: jest.fn(),
}));
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
const s: ProviderSettings = {
baseUrl: 'http://zarrin.local:8000/ZarrinService/accounting',
token: 'SECRET',
caCert: null,
timeoutMs: 5000,
balanceOwesWhen: 'negative',
};
const reply = (body: unknown, status = 200) =>
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
const url = (n = 0) => new URL(http.mock.calls[n][0].url);
beforeEach(() => http.mockReset());
describe('wire format', () => {
it('sends GET ?kind=…&token=… with the parameters, and nothing blank', async () => {
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '4', name: 'مشتری (خریدار)' }] }));
const groups = await z.listCustomerGroups(s);
const u = url();
expect(http.mock.calls[0][0].method).toBe('GET');
expect(u.pathname).toBe('/ZarrinService/accounting');
expect(u.searchParams.get('kind')).toBe('listGorooheHesabha');
expect(u.searchParams.get('token')).toBe('SECRET');
expect(groups).toEqual([{ id: '4', name: 'مشتری (خریدار)' }]);
});
it('unwraps the JSON-in-a-string replies the documentation shows', async () => {
const inner = JSON.stringify({ state: true, msg: 'حساب با موفقیت ایجاد شد', id: 132 });
http.mockReturnValueOnce(reply(JSON.stringify(inner)));
const res = await z.createCustomer(s, {
firstName: 'تست',
lastName: 'تست',
fullName: 'تست تست',
mobile: '09121112233',
phone: null,
address: null,
nationalCode: null,
group: { id: '4', name: 'مشتری' },
});
expect(res.code).toBe('132');
expect(url().searchParams.get('fkNoe')).toBe('4');
expect(url().searchParams.has('telephone')).toBe(false);
});
it('turns state:false into an error carrying the message', async () => {
http.mockReturnValueOnce(reply({ state: false, msg: 'دسترسی مجاز نیست' }));
await expect(z.listCustomerGroups(s)).rejects.toThrow('دسترسی مجاز نیست');
});
it('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 });
});
});