2026-10-06 16:48:36 +03:30
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 ( 'دسترسی مجاز نیست' );
});
2026-10-11 14:31:32 +03:30
it ( 'shows the server reason on a non-2xx reply, without the token' , async () => {
http . mockReturnValueOnce ( reply ( '<html><body><h1>Server Error</h1><p>kind=listGorooheHesabha&token=SECRET failed</p></body></html>' , 500 ));
const err = await z . listCustomerGroups ( s ). catch (( e ) => e );
expect ( err . message ). toContain ( 'HTTP 500 — Server Error' );
expect ( err . message ). toContain ( 'failed' );
expect ( err . message ). not . toContain ( 'SECRET' );
http . mockReturnValueOnce ( reply ({ state : false , msg : 'شناسه نامعتبر' }, 400 ));
await expect ( z . listCustomerGroups ( s )). rejects . toThrow ( 'HTTP 400 — شناسه نامعتبر' );
});
2026-10-06 16:48:36 +03:30
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' , () => {
2026-10-11 14:31:32 +03:30
it ( 'lists every account with its group from one listhesabha call' , async () => {
http . mockReturnValueOnce (
reply ({
data : [
{ id : '81' , nam : 'حسین اصغری' , gorooh : 'مشتری (خریدار)' , tell : '021' , mobile : '09121112233' , codeMeli : '' },
{ id : '82' , nam : 'آرین یوسفی' , gorooh : 'سازنده (تامین کننده)' , tell : '' , mobile : '' , codeMeli : '' },
],
}),
);
2026-10-06 16:48:36 +03:30
const res = await z . listCustomers ( s , null );
2026-10-11 14:31:32 +03:30
expect ( http ). toHaveBeenCalledTimes ( 1 );
expect ( url ( 0 ). searchParams . get ( 'kind' )). toBe ( 'listhesabha' );
expect ( url ( 0 ). searchParams . get ( 'az' )). toBe ( '1' );
expect ( res . items ). toHaveLength ( 2 );
2026-10-06 16:48:36 +03:30
expect ( res . items [ 0 ]). toMatchObject ({ code : '81' , name : 'حسین اصغری' , mobile : '09121112233' , groupName : 'مشتری (خریدار)' , phone : '021' });
2026-10-11 14:31:32 +03:30
expect ( res . items [ 1 ]. mobile ). toBeNull ();
expect ( res . next ). toBeNull ();
});
it ( 'finds by mobile with searchAccount, keeping exact matches only' , async () => {
http . mockReturnValueOnce (
reply ({
state : true ,
msg : '' ,
date : [
{ code : 84 , name : 'سعید' , namKhanevadegi : 'رجایی' , mobile : '09121995971' },
{ code : 90 , name : 'x' , namKhanevadegi : 'y' , mobile : '09121995972' },
],
}),
);
const found = await z . findCustomersByMobile ( s , '09121995971' );
expect ( url ( 0 ). searchParams . get ( 'kind' )). toBe ( 'searchAccount' );
expect ( found . map (( c ) => c . code )). toEqual ([ '84' ]);
expect ( found [ 0 ]. name ). toBe ( 'سعید رجایی' );
});
it ( 'falls back to listhesabha when searchAccount is missing (bare 400)' , async () => {
http
. mockReturnValueOnce ( reply ( '' , 400 ))
. mockReturnValueOnce ( reply ({ data : [{ id : '84' , nam : 'رجایی * سعید' , gorooh : 'مشتری (خریدار)' , mobile : '09121995971' }, { id : '85' , nam : 'z' , mobile : '' }] }));
const found = await z . findCustomersByMobile ( s , '09121995971' );
expect ( url ( 1 ). searchParams . get ( 'kind' )). toBe ( 'listhesabha' );
expect ( found . map (( c ) => c . code )). toEqual ([ '84' ]);
});
it ( 'does not hide other errors behind the fallback' , async () => {
http . mockReturnValueOnce ( reply ( '' , 500 ));
await expect ( z . findCustomersByMobile ( s , '09121995971' )). rejects . toThrow ( 'HTTP 500' );
});
it ( 'lists karsakhte groups with goroohSakhte when the installation has it' , async () => {
http . mockReturnValueOnce ( reply ({ state : true , detail : 'OK' , data : [{ id : '26' , name : 'النگو' }] }));
expect ( await z . listKarSakhteGroups ! ( s )). toEqual ([{ id : '26' , name : 'النگو' }]);
expect ( http ). toHaveBeenCalledTimes ( 1 );
});
it ( 'scans karsakhte groups with infogorooh when goroohSakhte is missing, skipping ids that fail' , async () => {
http . mockImplementation (( req ) => {
const u = new URL ( req . url );
if ( u . searchParams . get ( 'kind' ) === 'goroohSakhte' ) return reply ( '' , 400 );
const id = u . searchParams . get ( 'id' );
if ( id === '26' ) return reply ({ data : [{ nam : 'النگو' , code : 26 }] });
if ( id === '115' ) return reply ({ data : [{ nam : 'سکه' , code : 115 }] });
if ( id === '3' ) return reply ( '' , 500 );
return reply ( '{\n "data": \n ]\n}' );
});
expect ( await z . listKarSakhteGroups ! ( s )). toEqual ([
{ id : '26' , name : 'النگو' },
{ id : '115' , name : 'سکه' },
]);
2026-10-06 16:48:36 +03:30
});
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' );
});
2026-10-12 15:23:24 +03:30
it ( 'anchors the running balance on the real balance; «مانده از قبل» is not a movement' , async () => {
// 1) the range, opening row first (seen on v228: its figures are not a balance)
2026-10-06 16:48:36 +03:30
http . mockReturnValueOnce (
reply ({
state : true ,
detail : 'OK' ,
data : [
2026-10-12 15:23:24 +03:30
{ idRadif : '0' , idFactor : '0' , idHesab : '81' , sharh : 'مانده از قبل' , vaznTarazooPardakht : '10.14' , vaznTarazooDaryaft : '10.24' , poolPardakht : '5' , poolDaryaft : '5' },
{ idRadif : '470' , idFactor : '221' , idHesab : '81' , noeRadif : 'چک' , noeFactor : 'سند دریافت' , tarikh : '04/05/29' , vaznTarazooPardakht : '0.0' , vaznTarazooDaryaft : '2.5' , poolPardakht : '810000000' , poolDaryaft : '0' , ayar : '0' },
{ idRadif : '471' , idFactor : '222' , idHesab : '81' , noeRadif : 'کار ساخته' , noeFactor : 'فاکتور موقت' , tarikh : '04/05/30' , vaznTarazooPardakht : '1.0' , vaznTarazooDaryaft : '0.0' , poolPardakht : '0' , poolDaryaft : '0' , ayar : '750' },
2026-10-06 16:48:36 +03:30
],
}),
);
2026-10-12 15:23:24 +03:30
// 2) the account's balance now: − 9.5 g in Zarrin = owes 9.5
http . mockReturnValueOnce ( reply ({ state : true , msg : 'OK' , data : [{ id : '81' , geram : '-9.5' , pool : '-810000000' }] }));
// 3) what happened after the range
http . mockReturnValueOnce (
reply ({ state : true , detail : 'OK' , data : [{ idRadif : '0' , sharh : 'مانده از قبل' }, { idRadif : '500' , idHesab : '81' , vaznTarazooPardakht : '1.0' , vaznTarazooDaryaft : '0' , poolPardakht : '0' , poolDaryaft : '0' }] }),
);
2026-10-06 16:48:36 +03:30
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' );
2026-10-12 15:23:24 +03:30
expect ( url ( 1 ). searchParams . get ( 'kind' )). toBe ( 'listMandeHesabha' );
expect ( url ( 2 ). searchParams . get ( 'dateAz' )). toBe ( '2025-08-23' ); // the day after `to`
expect ( rows ). toHaveLength ( 2 );
expect ( rows [ 0 ]). toMatchObject ({ documentRef : '221' , date : '1404/05/29' , title : 'چک' , temporary : false });
// closing(to) = 9.5 − 1.0 after = 8.5; opening = 8.5 − (− 2.5 + 1.0) = 10
expect ( rows [ 0 ]. goldBalance ? . toString ()). toBe ( '7.5' );
expect ( rows [ 0 ]. rialBalance ? . toString ()). toBe ( '810000000' );
expect ( rows [ 1 ]). toMatchObject ({ temporary : true });
expect ( rows [ 1 ]. goldBalance ? . toString ()). toBe ( '8.5' );
2026-10-06 16:48:36 +03:30
});
});
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 });
});
});
2026-10-12 15:23:24 +03:30
describe ( 'v228 quirks' , () => {
it ( 'reads every page of listKarSakhte (100 per page)' , async () => {
const page = ( from : number , n : number ) => reply ({ state : true , msg : 'ok' , data : Array.from ({ length : n }, ( _ , i ) => ({ id : String ( from + i ), namFelezsakhthe : `کار ${ from + i } ` , fkNoe : '16' , NoeName : 'گرمی' })) });
http . mockReturnValueOnce ( page ( 1 , 100 )). mockReturnValueOnce ( page ( 101 , 100 )). mockReturnValueOnce ( page ( 201 , 7 ));
const items = await z . listKarSakhte ( s );
expect ( items ). toHaveLength ( 207 );
expect ( url ( 1 ). searchParams . get ( 'pageNum' )). toBe ( '2' );
});
it ( 'stops when the server ignores pageNum and repeats page 1' , async () => {
const page1 = () => reply ({ state : true , msg : 'ok' , data : Array.from ({ length : 100 }, ( _ , i ) => ({ id : String ( i + 1 ), namFelezsakhthe : 'x' })) });
http . mockReturnValueOnce ( page1 ()). mockReturnValueOnce ( page1 ());
expect ( await z . listKarSakhte ( s )). toHaveLength ( 100 );
expect ( http ). toHaveBeenCalledTimes ( 2 );
});
it ( 'accepts an HTTP 400 that carries a good reply (goroohSakhte on v228)' , async () => {
http . mockReturnValueOnce ( reply ({ state : true , detail : 'OK' , data : [{ id : '26' , name : 'النگو' }] }, 400 ));
expect ( await z . listKarSakhteGroups ( s )). toEqual ([{ id : '26' , name : 'النگو' }]);
});
it ( 'repairs the hand-printed JSON of infogardeshsakhte' , async () => {
// Verbatim shape of a v228 reply.
const body = [
'{' ,
' "data": [' ,
' {' ,
' "code_kar": 19,' ,
' "nam_kar": دستبند آروشا,' ,
' "tarikh_akharin_gardesh": 05/07/15,' ,
' "sharh_akharin_gardesh": دستبند آروشا,' ,
' "vazn_akharin_gardesh": -15.12,' ,
' "mablagh_akharin_gardesh": 0.0,' ,
' },' ,
' ]' ,
'}' ,
]. join ( '\n' );
http . mockReturnValueOnce ( reply ( body ));
const rows = await z . getKarSakhteMovement ( s , { jy : 1405 , jm : 7 , jd : 1 }, { jy : 1405 , jm : 7 , jd : 20 });
expect ( rows ). toEqual ([
expect . objectContaining ({ itemCode : '19' , itemName : 'دستبند آروشا' , date : '1405/07/15' , weight : new Prisma . Decimal ( '-15.12' ) }),
]);
});
it ( 'drops the «*» listhesabha puts between first and last name' , async () => {
const { accountName } = __zarrinInternals ;
expect ( accountName ( 'آرین * یوسفی' )). toBe ( 'آرین یوسفی' );
expect ( accountName ( ' * هزینه هدایا (پیامک گروهی)' )). toBe ( 'هزینه هدایا (پیامک گروهی)' );
expect ( accountName ( ' * ' )). toBeNull ();
http . mockReturnValueOnce ( reply ({ data : [{ id : '953' , nam : 'تست اپ * گلدبانک' , gorooh : 'متفرقه' , mobile : '09990001111' }] }));
const { items } = await z . listCustomers ( s , null );
expect ( items [ 0 ]). toMatchObject ({ code : '953' , name : 'تست اپ گلدبانک' , groupName : 'متفرقه' , mobile : '09990001111' });
});
it ( 'sees a document posted with movaghat=1 as temporary (shomareSanad «موقت»)' , async () => {
http . mockReturnValueOnce (
reply ({
state : true ,
data : [
{ idRadif : '71962' , idFactor : '16792' , idHesab : '953' , noeFactor : 'فاکتور فروش' , shomareSanad : 'موقت' , noeRadif : 'کار ساخته' , vaznTarazooPardakht : '1' , vaznTarazooDaryaft : '0' , poolPardakht : '0' , poolDaryaft : '0' },
{ idRadif : '71710' , idFactor : '16722' , idHesab : '953' , noeFactor : 'فاکتور موقت' , shomareSanad : 'باز' , vaznTarazooPardakht : '0.1' , vaznTarazooDaryaft : '0' , poolPardakht : '0' , poolDaryaft : '0' },
{ idRadif : '52961' , idFactor : '12282' , idHesab : '953' , noeFactor : 'فاکتور فروش' , shomareSanad : '12282' , vaznTarazooPardakht : '1' , vaznTarazooDaryaft : '0' , poolPardakht : '0' , poolDaryaft : '0' },
],
}),
);
http . mockReturnValueOnce ( reply ({ state : true , data : [{ id : '953' , geram : '-2.1' , pool : '0' }] }));
const today = toJalaliToday ();
const rows = await z . getStatement ( s , { code : '953' , from : today , to : today });
expect ( rows . map (( r ) => r . temporary )). toEqual ([ true , true , false ]);
});
});
function toJalaliToday() {
// Today in Tehran, so the statement needs no "after the range" call.
const { toJalali } = jest . requireActual ( '../jalali' );
return toJalali ( new Date ());
}