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; 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 = {}) => ({ ID, MCode, NO: 'فروش طلا', No_Kar: 'null', ZamanEdit: null, ...extra, }); const feed = (rows: ReturnType[]) => 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 }); }); });