@ -1,4 +1,5 @@
// apps/hq/test/import-apex-full.test.ts — D20 full-APEX importer v2 (stage → problems → one-txn commit)
// apps/hq/test/import-apex-full.test.ts — D20 full-APEX importer v2, reworked to the
// REAL export shape (bills = line grain, OLD_ID ticket chains, status vocabulary).
import { describe , it , expect } from 'vitest'
import { fyOf } from '@sims/domain'
import { openDb , type DB } from '../src/db'
@ -25,75 +26,167 @@ const RTGS_CLIENT_HDR = 'SID_NO,BANK_NAME,ADDRESS_IP,PHONE_NO,RTGS,IP_CODE,E_COL
const SMS_PROJECT_HDR = 'CID,BANK,DISTRICT,DT_INFO,RNO,SMS,PHONE,ASSIGNED_TO,TICKET_STATUS,DESCRIPTION,NEW_FORM,CREATED_BY,UPDATED_BY,TID'
const RTGS_PROJECT_HDR = 'CID,BANK,DT_INFO,RNO,RTGS,PHONE,ASSIGNED_TO,TICKET_STATUS,DESCRIPTION,CREATED_BY,UPDATED_BY,TID'
/** A small but complete workbook exercising every section. */
/** A small but complete workbook exercising the real-export shapes per section. */
function fullFiles ( ) : Record < string , string > {
return {
clients : csv ( CLIENT_HDR , [
{
SID_NO : '101' , BANK : 'Acme Bank' , ADDRESS : 'Kochi HQ' , PHONE : '9876543210' ,
MAIL : 'acme@bank.in' , DISTRICT : 'Ernakulam' , SMS : 'YES' , RTGS : 'NO' , WHATSAPP : 'Y' ,
MOBILE_APP: 'NO' , DB_PASSWORD: 's3cret-db' , ATM : 'NO' , ANYDESK : '123 456 789' ,
DB_PASSWORD: 's3cret-db' , ATM : 'NO' , ANYDESK : '123 456 789' ,
SECRETARY_CONTACT_DETAILS : 'Secretary John 9999' , OS : 'Windows 10' , SECTOR : 'Co-op' ,
} ,
{
SID_NO : '102' , BANK : 'Beta Bank' , DISTRICT : 'Kollam' , SMS : 'NO' , RTGS : 'YES' ,
WHATSAPP : 'NO' , MOBILE_APP : 'NO' , DB_PASSWORD : 'NO DATA' , ATM : 'NO' ,
ANYDESK : 'NO DATA' , SECTOR : 'Service' ,
{ // 'NO DATA' placeholders filtered case-insensitively; district misspelling fixed
SID_NO : '102' , BANK : 'Beta Bank' , PHONE : 'NO DATA' , DISTRICT : 'Pathanamthita' ,
SMS : 'NO' , RTGS : 'YES' , DB_PASSWORD : 'NO DATA' , ANYDESK : 'No Data' , SECTOR : 'Service' ,
} ,
{ // flag pollution: GENERAL ENQUIRY is NOT yes; ATM holds a provider name;
// MOBILE_APP holds a phone number
SID_NO : '103' , BANK : 'Gamma Bank' , SMS : 'GENERAL ENQUIRY' , RTGS : 'NO' ,
ATM : 'EWIRE' , MOBILE_APP : '8301932166' ,
} ,
] ) ,
branches : csv ( BRANCH_HDR , [
{ ID : '1' , BRANCH : 'Main Branch' , MID : '101' , BANK : 'Acme Bank' , BRCODE : 'BR001' } ,
{ ID : '1' , BRANCH : 'HEAD OFFICE' , MID : '101' , BANK : 'Acme Bank' , BRCODE : '1' } ,
{ ID : '2' , BRANCH : 'HEAD OFFICE' , MID : '101' , BANK : 'Acme Bank' , BRCODE : '1' } , // dup → dedupe
{ ID : '3' , BRANCH : 'HEAD OFFICE' , MID : '999' , BANK : 'Ghost Bank' , BRCODE : '1' } , // orphan → note
] ) ,
bills : csv ( BILL_HDR , [
{ // paid current-FY invoice — rupees→paise, payment+allocation, series seed
BILL_DATE : ` ${ TODAY } 00:00:00 ` , BANK_ID : '101' , INVOICE_NO : 'SMS/2026/0042' ,
INVOICE_TYPE : 'INVOICE' , INVOICE_AMOUNT : '1000' , GST_AMOUNT : '180' , TOTAL_AMOUNT : '1180' ,
// Era (a): base line + GST pseudo-line; payment fields repeated per line = ONE payment.
{
BILL_DATE : ` ${ TODAY } 00:00:00 ` , BANK_ID : '101' , BANK_NAME : 'ACME BANK OLD NAME' ,
ADDRESS : 'Old Addr' , MAIL : 'old@acme.in' , INVOICE_NO : 'SMS/2026/10042' , SRNO : '1' ,
DESCRIPTION : 'Bulk SMS 1L' , AMT : '1000' , INVOICE_TYPE : 'INVOICE' , INVOICE_AMOUNT : '1000' ,
PAYMENT_STATUS : 'PAID' , PAYMENT_DATE : ` ${ TODAY } 00:00:00 ` , PAYMENT_AMOUNT : '1180' ,
NOTES : 'DLT renewal amount' , TOTAL_AMOUNT : '1180' ,
} ,
{
BILL_DATE : ` ${ TODAY } 00:00:00 ` , BANK_ID : '101' , INVOICE_NO : 'SMS/2026/10042' , SRNO : '2' ,
DESCRIPTION : 'GST' , PER_QTY : '18%' , AMT : '180' , INVOICE_TYPE : 'INVOICE' ,
INVOICE_AMOUNT : '1000' , PAYMENT_STATUS : 'PAID' , PAYMENT_DATE : ` ${ TODAY } 00:00:00 ` ,
PAYMENT_AMOUNT : '1180' , TOTAL_AMOUNT : '1180' ,
} ,
// Era (b): no GST line, GST in columns; INVOICE_AMOUNT is GROSS here — AMT is the base.
{
BILL_DATE : ` ${ TODAY } 00:00:00 ` , BANK_ID : '102' , INVOICE_NO : 'SMS/2026/10234' , SRNO : '1' ,
DESCRIPTION : 'Software' , AMT : '12500' , INVOICE_TYPE : 'INVOICE' , INVOICE_AMOUNT : '14750' ,
PAYMENT_STATUS : 'N' , GST_RATE : '18' , GST_AMOUNT : '2250' , TOTAL_AMOUNT : '14750' ,
} ,
// Era (c): pre-GST history — no GST anywhere, tax 0, total = taxable.
{
BILL_DATE : '2024-11-01 00:00:00' , BANK_ID : '101' , INVOICE_NO : 'SMS/2024/10010' , SRNO : '1' ,
DESCRIPTION : 'AMC renewal' , AMT : '500' , INVOICE_TYPE : 'PROFORMA INVOICE' , INVOICE_AMOUNT : '500' ,
} ,
// Exact-duplicate line (10208 pattern): same SRNO+AMT, blank description → dropped.
{
BILL_DATE : ` ${ TODAY } 00:00:00 ` , BANK_ID : '102' , INVOICE_NO : 'SMS/2026/10208' , SRNO : '1' ,
DESCRIPTION : 'Renewal' , AMT : '100' , INVOICE_TYPE : 'INVOICE' , PAYMENT_STATUS : 'N' ,
} ,
{
BILL_DATE : ` ${ TODAY } 00:00:00 ` , BANK_ID : '102' , INVOICE_NO : 'SMS/2026/10208' , SRNO : '1' ,
DESCRIPTION : '' , AMT : '100' , INVOICE_TYPE : 'INVOICE' , PAYMENT_STATUS : 'N' ,
} ,
// Genuine per-line split payment (10133 pattern): two distinct (date, amount) pairs.
{
BILL_DATE : '2025-06-01 00:00:00' , BANK_ID : '101' , INVOICE_NO : 'SMS/2025/10133' , SRNO : '1' ,
DESCRIPTION : 'Websms' , AMT : '175' , INVOICE_TYPE : 'INVOICE' , PAYMENT_STATUS : 'PAID' ,
PAYMENT_DATE : '2025-06-10 00:00:00' , PAYMENT_AMOUNT : '175' ,
} ,
{
BILL_DATE : '2025-06-01 00:00:00' , BANK_ID : '101' , INVOICE_NO : 'SMS/2025/10133' , SRNO : '2' ,
DESCRIPTION : 'Addon' , AMT : '31.5' , INVOICE_TYPE : 'INVOICE' , PAYMENT_STATUS : 'PAID' ,
PAYMENT_DATE : '2025-06-20 00:00:00' , PAYMENT_AMOUNT : '31.5' ,
} ,
{ // proforma with '' tax ('→0) and '' total ('→taxable+tax); never gets a payment
BILL_DATE : '2025-08-18 00:00:00' , BANK_ID : '102' , INVOICE_NO : 'PI/2025/007' ,
INVOICE_TYPE : 'PROFORMA INVOICE' , INVOICE_AMOUNT : '500' , GST_AMOUNT : '' , TOTAL_AMOUNT : '' ,
// PAID with no payment data (10140 pattern): status paid, NO payment row, note.
{
BILL_DATE : '2025-07-01 00:00:00' , BANK_ID : '102' , INVOICE_NO : 'SMS/2025/10140' , SRNO : '1' ,
DESCRIPTION : 'Small job' , AMT : '59' , INVOICE_TYPE : 'INVOICE' , PAYMENT_STATUS : 'PAID' ,
} ,
] ) ,
tickets : csv ( TICKET_HDR , [
// One OLD_ID chain: two superseded PENDING_TRANSfERED copies + terminal CLOSED row.
{
ID : '1' , OLD_ID : '1' , BANK_ID : '101' , STATUS : 'PENDING_TRANSfERED' ,
CREATED : '2025-01-01 09:00:00' , ASSIGN_DATE : '2025-01-01 00:00:00' ,
DESCRIPTION : 'Printer issue' , MOBILE_NO : '9400000001' ,
} ,
{
ID : '1' , BANK_ID : '101' , TYPE : 'MODIFICATION' , GROUP : 'SMS' ,
ASSIGN_DATE : '2025-02-28 00:00:00' , DESCRIPTION : 'Fix template' , STATUS : 'CLOSED' ,
CREATED : '2025-02-28 09:00:00' , UPDATED : '2025-03-02 10:00:00' , ONLINE_OFFLINE : 'ONLINE' ,
ID : '2' , OLD_ID : '1' , BANK_ID : '101' , STATUS : 'PENDING_TRANSfERED' ,
CREATED : '2025-01-02 09:00:00' , DESCRIPTION : 'Printer issue' ,
} ,
{
ID : '3' , OLD_ID : '2' , BANK_ID : '101' , STATUS : 'CLOSED' , CREATED : '2025-01-03 09:00:00' ,
UPDATED : '2025-01-04 10:00:00' , DESCRIPTION : 'Printer issue - fixed' , MAIL : 'Y' ,
TYPE : 'MODIFICATION' , GROUP : 'SMS' , ONLINE_OFFLINE : 'ONLINE' ,
} ,
{ // blank BANK_ID → exact BANK_NAME match; WAITING* → waiting
ID : '10' , OLD_ID : '10' , BANK_NAME : 'Acme Bank' , STATUS : 'WAITING FOR CONFIRMATION' ,
CREATED : '2025-03-01 08:00:00' , DESCRIPTION : 'Waiting one' ,
} ,
{ // 'DROPPED BY …' free text + flag → dropped, closed_on from UPDATED
ID : '11' , OLD_ID : '11' , BANK_ID : '102' , STATUS : 'DROPPED BY DEVIKA-01-01-2025' ,
TICKET_DROPED : 'Y' , CREATED : '2025-04-01 09:00:00' , UPDATED : '2025-04-02 00:00:00' ,
DESCRIPTION : 'Dropped one' ,
} ,
{ // blank BANK_ID → exact BANK_NAME match; DD-MON-YYYY open date; WAITING* status
ID : '2' , BANK_NAME : 'Acme Bank' , DAY_DT : '01-MAR-2025' ,
STATUS : 'WAITING FOR CONFIRMATION' , DESCRIPTION : 'Waiting one' ,
{ // orphan client ref → skipped with a note, never a blocking problem
ID : ' 12', OLD_ID : '12' , BANK_ID : '999' , STATUS : 'OPEN ',
CREATED: '2025-05-01 00:00:00' , DESCRIPTION : 'Orphan one',
} ,
{ // TICKET_DROPED='Y' with a non-terminal status → dropped, closed_on from UPDATED
ID : '3' , BANK_ID : '102' , STATUS : '' , TICKET_DROPED : 'Y' , DESCRIPTION : 'Dropped one' ,
CREATED : '2025-04-01 09:00:00' , UPDATED : '2025-04-02 00:00:00' ,
{ // 'PENDING AS ON <date>' → open
ID : ' 13', OLD_ID : '1 3', BANK_ID : '102' , STATUS : ' PENDING AS ON 20-02-2026 ',
CREATED : '2025-0 6-01 07:00:00', DESCRIPTION : 'Pending one ',
} ,
] ) ,
sms_clients : csv ( SMS_CLIENT_HDR , [
{ // CID 102's SMS flag said NO — this row wins and creates the assignment
RNO : '1' , CID : '102' , BANK : 'Beta Bank' , USERNAME : 'betauser' , PASSWORD : 'smsPass9' ,
SMS_PROVIDER : 'AltSMS' , PHONE : '0474222' , SMS_BALANCE : '5000' , REMARK : 'ok' ,
RESELLER : 'RSL' , PAYMENT : 'PAID 2025' , INSTALLATION_DATE : '10-NOV-2023' ,
{ // junk duplicate (CID 33 pattern) — fewer fields, dropped in favour of the richer row
RNO : '1' , CID : '102' , BANK : 'Beta Bank' , SMS : 'GENERAL ENQUIRY' ,
USERNAME : 'WHATSAPP' , PASSWORD : 'WHATSAPP' ,
} ,
{
RNO : '2' , CID : '102' , BANK : 'Beta Bank' , SMS : 'YES' , USERNAME : 'betauser' ,
PASSWORD : 'smsPass9' , SMS_PROVIDER : 'AltSMS' , PHONE : '0474222' , SMS_BALANCE : 'YES' ,
REMARK : 'ok' , RESELLER : 'RSL' , PAYMENT : 'PAID' , INSTALLATION_DATE : '2026-03-25 00:00:00' ,
} ,
{ // pipeline vocabulary: GENERAL ENQUIRY → assignment exists at 'quoted'
RNO : '3' , CID : '103' , BANK : 'Gamma Bank' , SMS : 'GENERAL ENQUIRY' ,
} ,
] ) ,
sms_modules : csv ( SMS_MODULE_HDR , [
{ RNO : '1' , SID_NO : '102' , HEAD : 'ATM ALERT' , ACTIV : 'Y' } ,
{ RNO : '2' , SID_NO : '102' , HEAD : 'BALANCE ALERT' , ACTIV : 'Y' } ,
{ RNO : '3' , SID_NO : '102' , HEAD : 'OLD ALERT' , ACTIV : 'N' } ,
{ RNO : '1' , SID_NO : '102' , HEAD : 'ATM ALERT' , ACTIV : 'Y' , PKG : 'PKG_SMS_JOB' } ,
{ RNO : '2' , SID_NO : '102' , HEAD : 'BALANCE ALERT' , ACTIV : 'Y' , REMARK : 'emp only' } ,
{ RNO : '3' , SID_NO : '102' , HEAD : 'OLD ALERT' , ACTIV : 'N' , PKG : 'PKG_SMS_JOB' } ,
] ) ,
rtgs_clients : csv ( RTGS_CLIENT_HDR , [
{
{ // richer engagement row
SID_NO : '102' , BANK_NAME : 'Beta Bank' , ADDRESS_IP : '10.0.0.5' , PHONE_NO : '333' ,
IP_CODE : 'IPC1' , ABB : 'BETA' , INTEGRATED_BANK_PARTNERS : 'SBI' ,
INSTALLED_ON : '2024-01-05 00:00:00' , GENERATE_QR : 'YES' , PAYMENT : 'PAID' ,
RTGS : 'YES' , IP_CODE : 'IPC1' , ABB : 'BETA' , INTEGRATED_BANK_PARTNERS : 'SBI' ,
INSTALLATION_DATE : '2024-01-05 00:00:00' , INSTALLED_ON : 'Database Server' ,
GENERATE_QR : 'YES' , PAYMENT : 'PAID' ,
} ,
{ // sparse duplicate engagement (SID 84/91/205 pattern) → merged, richer preferred
SID_NO : '102' , BANK_NAME : 'Beta Bank' , RTGS : 'NEW PROJECT' , PAYMENT : 'Nil' ,
E_COLLECTION_CODE : 'EC77' ,
} ,
] ) ,
sms_projects : csv ( SMS_PROJECT_HDR , [
{ CID : '101' , DT_INFO : '13-05-2024' , TICKET_STATUS : 'COMPLETED' , DESCRIPTION : 'Install SMS' } ,
{ // TID-bearing closed row → interaction history (ticket import owns the TID)
CID : '101' , DT_INFO : '13-05-2024' , SMS : 'COMPLETED' , TICKET_STATUS : 'CLOSED' ,
DESCRIPTION : 'Install SMS' , ASSIGNED_TO : 'DEVIKA' , TID : '54055184000000000000000000000000000001' ,
} ,
{ // TID-less OPEN row → lands as a ticket (the live queue must survive cutover)
CID : '103' , DT_INFO : '08/29/2024' , SMS : 'YES' , TICKET_STATUS : 'OPEN' ,
DESCRIPTION : 'Onboard SMS' , PHONE : '9037779234' ,
} ,
{ // NEW PROJECT vocabulary upgrades 103's quoted SMS assignment to 'installing'
CID : '103' , DT_INFO : '12-13-2023' , SMS : 'NEW PROJECT' , TICKET_STATUS : 'CLOSED' ,
DESCRIPTION : 'Site survey' , TID : '54055184000000000000000000000000000002' ,
} ,
] ) ,
rtgs_projects : csv ( RTGS_PROJECT_HDR , [
{ CID : '102' , DT_INFO : 'not-a-date' , TICKET_STATUS : 'PENDING' , DESCRIPTION : 'RTGS setup' } ,
{ // TID-less PENDING_TRANSfERED → in_progress ticket; unparseable date noted
CID : '102' , DT_INFO : 'not-a-date' , RTGS : 'YES' , TICKET_STATUS : 'PENDING_TRANSfERED' ,
DESCRIPTION : 'RTGS setup' ,
} ,
] ) ,
}
}
@ -104,183 +197,211 @@ async function freshDb(): Promise<DB> {
return db
}
const cmFor = async ( db : DB , clientCode : string , module Code : string ) = >
db . get < {
status : string ; kind : string ; provider : string | null ; username : string | null
password_enc : string | null ; details : string ; remark : string | null ; installed_on : string | null
} > (
` SELECT cm.status, cm.kind, cm.provider, cm.username, cm.password_enc, cm.details, cm.remark, cm.installed_on
FROM client_module cm JOIN module m ON m . id = cm . module _id JOIN client c ON c . id = cm . client_id
WHERE c . code = ? AND m . code = ? ` , clientCode, moduleCode)
describe ( 'parseApexDate' , ( ) = > {
it ( 'handles every format seen in the APEX export' , ( ) = > {
it ( 'handles every format profiled in the APEX export (separator decides day/month) ', ( ) = > {
expect ( parseApexDate ( '2025-08-18 00:00:00' ) ) . toBe ( '2025-08-18' ) // ISO datetime
expect ( parseApexDate ( '10-NOV-2023 10:57:18' ) ) . toBe ( '2023-11-10' ) // DD-MON-YYYY
expect ( parseApexDate ( '08/29/2024' ) ) . toBe ( '2024-08-29' ) // second >12 → MM/DD
expect ( parseApexDate ( '13-05-2024' ) ) . toBe ( '2024-05-13' ) // first >12 → DD-MM
expect ( parseApexDate ( '10-11-2023' ) ) . toBe ( '2023-11-10' ) // ambiguous → assume DD-MM
expect ( parseApexDate ( '08/29/2024' ) ) . toBe ( '2024-08-29' ) // slash → MM/DD
expect ( parseApexDate ( '09/03/2024' ) ) . toBe ( '2024-09-03' ) // slash ambiguous → MM/DD
expect ( parseApexDate ( '13-05-2024' ) ) . toBe ( '2024-05-13' ) // dash → DD-MM
expect ( parseApexDate ( '10-11-2023' ) ) . toBe ( '2023-11-10' ) // dash ambiguous → DD-MM
expect ( parseApexDate ( '12-13-2023' ) ) . toBe ( '2023-12-13' ) // dash with month>12 → falls back MM-DD
expect ( parseApexDate ( '06-12-2024 ' ) ) . toBe ( '2024-12-06' ) // trailing whitespace trimmed
expect ( parseApexDate ( '' ) ) . toBeNull ( )
expect ( parseApexDate ( 'not-a-date' ) ) . toBeNull ( )
expect ( parseApexDate ( '99-99-2024' ) ) . toBeNull ( )
} )
} )
describe ( 'APEX full importer v2 ', ( ) = > {
it ( 'stage: clean workbook → per-section counts, zero problem s, and NO writes', async ( ) = > {
describe ( 'APEX full importer v2 (real-export shapes) ', ( ) = > {
it ( 'stage: clean workbook → zero problems, deliberate skips as note s, and NO writes', async ( ) = > {
const db = await freshDb ( )
const report = await stageApexFull ( db , fullFiles ( ) , KEY )
expect ( report . clients ) . toEqual ( { staged : 2 , problems : [ ] } )
expect ( report . branches ) . toEqual ( { staged : 1 , problems : [ ] } )
expect ( report . bills ) . toEqual ( { staged : 2 , problems : [ ] } )
expect ( report . tickets ) . toEqual ( { staged : 3 , problems : [ ] } )
expect ( report . sms_clients ) . toEqual ( { staged : 1 , problems : [ ] } )
expect ( report . sms_modules ) . toEqual ( { staged : 3 , problems : [ ] } )
expect ( report . rtgs_clients ) . toEqual ( { staged : 1 , problems : [ ] } )
expect ( report . sms_projects ) . toEqual ( { staged : 1 , problems : [ ] } )
expect ( report . rtgs_projects ) . toEqual ( { staged : 1 , problems : [ ] } )
// Pure validation: no clients, no documents, no import audit rows.
const { sections , summary } = await stageApexFull ( db , fullFiles ( ) , KEY )
for ( const s of Object . values ( sections ) ) expect ( s . problems ) . toEqual ( [ ] )
// Deliberate auto-resolutions surface as notes, not problems.
expect ( sections . branches . notes . some ( ( n ) = > n . includes ( 'MID=999' ) ) ) . toBe ( true )
expect ( sections . branches . notes . some ( ( n ) = > n . includes ( 'duplicate branch row' ) ) ) . toBe ( true )
expect ( sections . bills . notes . some ( ( n ) = > n . includes ( 'exact-duplicate line' ) ) ) . toBe ( true )
expect ( sections . bills . notes . some ( ( n ) = > n . includes ( 'marked PAID with no payment' ) ) ) . toBe ( true )
expect ( sections . bills . notes . some ( ( n ) = > n . includes ( 'CGST+SGST' ) ) ) . toBe ( true )
expect ( sections . tickets . notes . some ( ( n ) = > n . includes ( 'BANK_ID=999' ) ) ) . toBe ( true )
expect ( sections . tickets . notes . some ( ( n ) = > n . includes ( 'collapsed 2 superseded' ) ) ) . toBe ( true )
expect ( sections . sms_clients . notes . some ( ( n ) = > n . includes ( 'duplicate CID=102' ) ) ) . toBe ( true )
expect ( sections . rtgs_clients . notes . some ( ( n ) = > n . includes ( 'duplicate SID_NO=102 merged' ) ) ) . toBe ( true )
expect ( summary ) . toMatchObject ( {
clients : 3 , client_modules : 7 , branches : 1 ,
documents : 6 , totalPayablePaise : 1679550 , payments : 3 ,
tickets : 6 , interactions : 2 , currentFy : FY , seedTail : 10234 ,
} )
// Pure validation: nothing written at all.
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM client ` ) ) ! . n ) . toBe ( 0 )
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM document ` ) ) ! . n ) . toBe ( 0 )
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM ticke t` ) ) ! . n ) . toBe ( 0 )
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM audit_log WHERE action='import' ` ) ) ! . n ) . toBe ( 0 )
} )
it ( 'stage: unknown join keys are per-row problems with row numbers' , async ( ) = > {
const db = await freshDb ( )
const report = await stageApexFull ( db , {
clients : csv ( CLIENT_HDR , [ { SID_NO : '101' , BANK : 'Acme Bank' } ] ) ,
branches : csv ( BRANCH_HDR , [ { ID : '9' , BRANCH : 'Ghost' , MID : '999' } ] ) ,
tickets : csv ( TICKET_HDR , [ { ID : '9' , BANK_ID : '999' , STATUS : 'OPEN' , DAY_DT : '2025-01-01 00:00:00' } ] ) ,
sms_projects : csv ( SMS_PROJECT_HDR , [ { CID : '999' , DT_INFO : '2025-01-01' } ] ) ,
} , KEY )
expect ( report . branches . problems ) . toEqual ( [ 'row 1: unknown client MID=999' ] )
expect ( report . tickets . problems ) . toEqual ( [ 'row 1: unknown client BANK_ID=999' ] )
expect ( report . sms_projects . problems ) . toEqual ( [ 'row 1: unknown client CID=999' ] )
} )
it ( 'commit: full happy path lands every section in one transaction' , async ( ) = > {
const db = await freshDb ( )
const res = await commitApexFull ( db , 'u-test' , fullFiles ( ) , KEY )
expect ( res . clients ) . toBe ( 2 )
expect ( res . client_modules ) . toBe ( 4 ) // 101: SMS+WHATSAPP (flags); 102: RTGS (flag) + SMS (data wins)
expect ( res . branches ) . toBe ( 1 )
expect ( res . bills ) . toBe ( 2 )
expect ( res . payments ) . toBe ( 1 )
expect ( res . tickets ) . toBe ( 3 )
expect ( res . sms_projects ) . toBe ( 1 )
expect ( res . rtgs_projects ) . toBe ( 1 )
expect ( res . seeded ) . toEqual ( { fy : FY , lastSeq : 42 } )
expect ( res ) . toMatchObject ( {
clients : 3 , client_modules : 7 , branches : 1 , bills : 6 , payments : 3 , tickets : 6 ,
sms_projects : 3 , rtgs_projects : 1 , seeded : { fy : FY , lastSeq : 10234 } ,
} )
// Clients: code = SID_NO, contacts carry bank + secretary, support fields ride along .
// Clients: hygiene (district normalize, NO DATA filters) + contacts + encrypted password.
const c101 = ( await db . get < {
id : string ; name : string ; address : string ; district : string ; sector : string
os : string ; anydesk : string ; contacts : string ; status : string ; source : string
state_code : string ; db_password_enc : string
} > ( ` SELECT * FROM client WHERE code='101' ` ) ) !
expect ( c101 ) . toMatchObject ( {
name : 'Acme Bank' , address : 'Kochi HQ' , district : 'Ernakulam' , sector : 'Co-op' ,
os : 'Windows 10' , anydesk : '123 456 789' , status : 'active' , source : 'apex' , state_code : '32' ,
} )
id : string ; district : string ; contacts : string ; db_password_enc : string ; source : string
} > ( ` SELECT id, district, contacts, db_password_enc, source FROM client WHERE code='101' ` ) ) !
expect ( c101 . district ) . toBe ( 'ERNAKULAM' )
expect ( c101 . source ) . toBe ( 'apex' )
expect ( JSON . parse ( c101 . contacts ) ) . toEqual ( [
{ name : 'Acme Bank' , phone : '9876543210' , email : 'acme@bank.in' } ,
{ name : 'Secretary John 9999' , role : 'secretary' } ,
] )
// DB password encrypted at rest, decryptable with the key.
expect ( decrypt ( c101 . db_password_enc , KEY ) ) . toBe ( 's3cret-db' )
const c102 = ( await db . get < { anydesk : string | null ; db_password_enc : string | null } > (
` SELECT anydesk, db_password_enc FROM client WHERE code='102' ` ) ) !
expect ( c102 ) . toEqual ( { anydesk : null , db_password_enc : null } ) // 'NO DATA' → nothing stored
const c102 = ( await db . get < { anydesk : string | null ; db_password_enc : string | null ; district : string ; contacts : string } > (
` SELECT anydesk, db_password_enc, district, contacts FROM client WHERE code='102' ` ) ) !
expect ( c102 . anydesk ) . toBeNull ( ) // 'No Data' → nothing stored
expect ( c102 . db_password_enc ) . toBeNull ( )
expect ( c102 . district ) . toBe ( 'PATHANAMTHITTA' ) // misspelling normalized
expect ( JSON . parse ( c102 . contacts ) ) . toEqual ( [ ] ) // PHONE 'NO DATA' filtered
// Flags → live yearly assignments on get-or-created modules.
const module s = await db . all < { code : string ; name : string } > (
` SELECT code, name FROM module WHERE code IN ('SMS','RTGS','WHATSAPP') ORDER BY code ` )
expect ( module s ) . toEqual ( [
{ code : 'RTGS' , name : 'RTGS' } , { code : 'SMS' , name : 'Bulk SMS' } ,
{ code : 'WHATSAPP' , name : 'WhatsApp' } ,
] )
const codes101 = await db . all < { code : string ; status : string ; kind : string } > (
` SELECT m.code, cm.status, cm.kind FROM client_module cm
JOIN module m ON m . id = cm . module _id JOIN client c ON c . id = cm . client_id
WHERE c . code = '101' ORDER BY m . code ` )
expect ( codes101 ) . toEqual ( [
{ code : 'SMS' , status : 'live' , kind : 'yearly' } ,
{ code : 'WHATSAPP' , status : 'live' , kind : 'yearly' } ,
] )
// Flags per policy 4: GENERAL ENQUIRY is not a module; ATM value → provider;
// MOBILE_APP phone number → Contact detail.
const atm = ( await cmFor ( db , '103' , 'ATM' ) ) !
expect ( atm . status ) . toBe ( 'live' )
expect ( atm . provider ) . toBe ( 'EWIRE' )
const mob = ( await cmFor ( db , '103' , 'MOBILEAPP' ) ) !
expect ( JSON . parse ( mob . details ) ) . toContainEqual ( { label : 'Contact' , value : '8301932166' } )
// Branch joined by MID.
const branch = await db . get ( ` SELECT b.name, b.code, b.active FROM client_branch b
JOIN client c ON c . id = b . client_id WHERE c . code = '101' ` )
expect ( branch) . toEqual ( { name : 'Main Branch' , code : 'BR001' , active : 1 } )
// Status vocabulary: 103's SMS came from GENERAL ENQUIRY (quoted) and the latest
// project row (NEW PROJECT) upgraded it to installing — never to live.
const sms103 = ( await cmFor ( db , '103' , 'SMS' ) ) !
expect ( sms103 . status ) . toBe ( 'installing' )
// Bills: rupees→paise, intra-state split, payment + allocation, series seed.
const inv = ( await db . get < {
id : string ; doc_type : string ; fy : string ; status : string ; taxable_paise : number
cgst_paise : number ; sgst_paise : number ; igst_paise : number ; round_off_paise : number
payable_paise : number ; source : string ; payload : string
} > ( ` SELECT * FROM document WHERE doc_no='SMS/2026/0042' ` ) ) !
expect ( inv ) . toMatchObject ( {
doc_type : 'INVOICE' , fy : FY , status : 'paid' , taxable_paise : 100000 ,
cgst_paise : 9000 , sgst_paise : 9000 , igst_paise : 0 , round_off_paise : 0 ,
payable_paise : 118000 , source : 'apex' ,
} )
expect ( JSON . parse ( inv . payload ) ) . toMatchObject ( { lines : [ ] , totals : { payablePaise : 118000 } } )
const pi = await db . get ( ` SELECT doc_type, status, taxable_paise, payable_paise, cgst_paise
FROM document WHERE doc_no = 'PI/2025/007' ` )
expect ( pi ) . toEqual ( {
doc_type : 'PROFORMA' , status : 'sent' , taxable_paise : 50000 , payable_paise : 50000 , cgst_paise : 0 ,
} )
const pay = ( await db . get < { id : string ; mode : string ; reference : string ; amount_paise : number ; received_on : string } > (
` SELECT * FROM payment ` ) ) !
expect ( pay ) . toMatchObject ( {
mode : 'bank' , reference : 'APEX import' , amount_paise : 118000 , received_on : TODAY ,
} )
const alloc = await db . get ( ` SELECT payment_id, document_id, amount_paise FROM payment_allocation ` )
expect ( alloc ) . toEqual ( { payment_id : pay.id , document_id : inv.id , amount_paise : 118000 } )
const series = await db . get ( ` SELECT next_seq FROM doc_series WHERE doc_type='INVOICE' AND fy=? ` , FY )
expect ( series ) . toEqual ( { next_seq : 43 } ) // continues after the legacy 0042 tail
// Tickets: status mapping + join fallbacks.
const t1 = await db . get ( ` SELECT status, opened_on, closed_on, online_offline, module_code, source
FROM ticket WHERE description = 'Fix template' ` )
expect ( t1 ) . toEqual ( {
status : 'closed' , opened_on : '2025-02-28' , closed_on : '2025-03-02' ,
online_offline : 'online' , module _code : 'SMS' , source : 'apex' ,
} )
const t2 = ( await db . get < { status : string ; opened_on : string ; client_id : string } > (
` SELECT status, opened_on, client_id FROM ticket WHERE description='Waiting one' ` ) ) !
expect ( t2 . status ) . toBe ( 'waiting' )
expect ( t2 . opened_on ) . toBe ( '2025-03-01' ) // DD-MON-YYYY DAY_DT
expect ( t2 . client_id ) . toBe ( c101 . id ) // blank BANK_ID resolved by exact BANK_NAME
const t3 = await db . get ( ` SELECT status, opened_on, closed_on FROM ticket WHERE description='Dropped one' ` )
expect ( t3 ) . toEqual ( { status : 'dropped' , opened_on : '2025-04-01' , closed_on : '2025-04-02' } )
// SMS service data landed on 102's (data-created) SMS module.
const sms102 = ( await db . get < { provider : string ; username : string ; password_enc : string ; details : string ; remark : string } > (
` SELECT cm.provider, cm.username, cm.password_enc, cm.details, cm.remark FROM client_module cm
JOIN module m ON m . id = cm . module _id JOIN client c ON c . id = cm . client_id
WHERE c . code = '102' AND m . code = 'SMS' ` ))!
// SMS service data on 102 (richer duplicate row won; junk 'WHATSAPP' row dropped).
const sms102 = ( await cmFor ( db , '102' , 'SMS' ) ) !
expect ( sms102 . status ) . toBe ( 'live' )
expect ( sms102 . provider ) . toBe ( 'AltSMS' )
expect ( sms102 . username ) . toBe ( 'betauser' )
expect ( decrypt ( sms102 . password_enc , KEY ) ) . toBe ( 'smsPass9' )
expect ( decrypt ( sms102 . password_enc ! , KEY ) ) . toBe ( 'smsPass9' )
expect ( sms102 . remark ) . toBe ( 'ok' )
expect ( sms102 . installed_on ) . toBe ( '2026-03-25' ) // typed column, not a detail
const smsDetails = JSON . parse ( sms102 . details ) as { label : string ; value : string } [ ]
expect ( smsDetails ) . toContainEqual ( { label : 'SMS balance' , value : '5000' } )
expect ( smsDetails ) . toContainEqual ( { label : 'Installed' , value : '10-NOV-2023' } )
expect ( smsDetails ) . toContainEqual ( { label : 'Active alerts' , value : 'ATM ALERT, BALANCE ALERT' } )
expect ( smsDetails . find ( ( d ) = > d . label === 'SMS balance' ) ) . toBeUndefined ( ) // 'YES' is a flag, not a balance
expect ( smsDetails ) . toContainEqual ( { label : 'Payment' , value : 'paid' } )
expect ( smsDetails ) . toContainEqual ( { label : 'Active alerts' , value : 'ATM ALERT (PKG_SMS_JOB), BALANCE ALERT' } )
expect ( smsDetails ) . toContainEqual ( { label : 'Alert notes' , value : 'BALANCE ALERT: emp only' } )
// RTGS service data on 102's RTGS module.
const rtgs102 = ( await db . get < { details : string } > (
` SELECT cm.details FROM client_module cm
JOIN module m ON m . id = cm . module _id JOIN client c ON c . id = cm . client_id
WHERE c . code = '102' AND m . code = 'RTGS' ` ))!
// RTGS: duplicate engagements merged richer-first; INSTALLATION_DATE → installed_on;
// INSTALLED_ON server text → detail; Nil payment did not overwrite PAID.
const rtgs102 = ( await cmFor ( db , '102' , 'RTGS' ) ) !
expect ( rtgs102 . status ) . toBe ( 'live' )
expect ( rtgs102 . installed_on ) . toBe ( '2024-01-05' )
const rtgsDetails = JSON . parse ( rtgs102 . details ) as { label : string ; value : string } [ ]
expect ( rtgsDetails ) . toContainEqual ( { label : 'IP' , value : '10.0.0.5' } )
expect ( rtgsDetails ) . toContainEqual ( { label : 'Installed' , value : '2024-01-05 00:00:00' } )
expect ( rtgsDetails ) . toContainEqual ( { label : 'QR' , value : 'YES' } )
expect ( rtgsDetails ) . toContainEqual ( { label : 'Installed on' , value : 'Database Server' } )
expect ( rtgsDetails ) . toContainEqual ( { label : 'E-collection code' , value : 'EC77' } ) // merged from sparse row
expect ( rtgsDetails ) . toContainEqual ( { label : 'Payment' , value : 'paid' } )
// Projects → interaction rows; unparseable DT_INFO falls back to today + a note.
// Bills, era (a): grouped lines → ONE document; GST from the pseudo-line; ONE payment.
const inv = ( await db . get < {
id : string ; taxable_paise : number ; cgst_paise : number ; sgst_paise : number
payable_paise : number ; status : string ; payload : string ; fy : string
} > ( ` SELECT id, taxable_paise, cgst_paise, sgst_paise, payable_paise, status, payload, fy
FROM document WHERE doc_no = 'SMS/2026/10042' ` ))!
expect ( inv ) . toMatchObject ( {
taxable_paise : 100000 , cgst_paise : 9000 , sgst_paise : 9000 , payable_paise : 118000 ,
status : 'paid' , fy : FY ,
} )
const payload = JSON . parse ( inv . payload ) as { notes? : string ; billTo? : object ; totals : { payablePaise : number } }
expect ( payload . notes ) . toBe ( 'Bulk SMS 1L\nDLT renewal amount' )
expect ( payload . billTo ) . toEqual ( { name : 'ACME BANK OLD NAME' , address : 'Old Addr' , email : 'old@acme.in' } )
// Era (b): taxable keyed on AMT (12500), never INVOICE_AMOUNT (14750 gross).
const eraB = await db . get ( ` SELECT taxable_paise, cgst_paise, sgst_paise, payable_paise, status
FROM document WHERE doc_no = 'SMS/2026/10234' ` )
expect ( eraB ) . toEqual ( {
taxable_paise : 1250000 , cgst_paise : 112500 , sgst_paise : 112500 , payable_paise : 1475000 , status : 'sent' ,
} )
// Era (c): pre-GST history, zero tax.
const eraC = await db . get ( ` SELECT doc_type, taxable_paise, cgst_paise, payable_paise
FROM document WHERE doc_no = 'SMS/2024/10010' ` )
expect ( eraC ) . toEqual ( { doc_type : 'PROFORMA' , taxable_paise : 50000 , cgst_paise : 0 , payable_paise : 50000 } )
// Duplicate line dropped: taxable is one line, not two.
const dup = await db . get ( ` SELECT taxable_paise, payable_paise FROM document WHERE doc_no='SMS/2026/10208' ` )
expect ( dup ) . toEqual ( { taxable_paise : 10000 , payable_paise : 10000 } )
// Split payment: two payment rows + allocations, fully settling the doc.
const splitDoc = ( await db . get < { id : string ; payable_paise : number } > (
` SELECT id, payable_paise FROM document WHERE doc_no='SMS/2025/10133' ` ) ) !
const allocs = await db . all < { amount_paise : number } > (
` SELECT amount_paise FROM payment_allocation WHERE document_id=? ORDER BY amount_paise ` , splitDoc . id )
expect ( allocs ) . toEqual ( [ { amount_paise : 3150 } , { amount_paise : 17500 } ] )
expect ( splitDoc . payable_paise ) . toBe ( 20650 )
// PAID with no data: status paid, zero payment rows.
const noPay = ( await db . get < { id : string ; status : string } > (
` SELECT id, status FROM document WHERE doc_no='SMS/2025/10140' ` ) ) !
expect ( noPay . status ) . toBe ( 'paid' )
expect ( ( await db . get < { n : number } > (
` SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=? ` , noPay . id ) ) ! . n ) . toBe ( 0 )
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM payment ` ) ) ! . n ) . toBe ( 3 )
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM document ` ) ) ! . n ) . toBe ( 6 )
// Series: shared APEX number space → INVOICE series seeds from max tail of ALL
// current-FY docs (10042 / 10208 / 10234 → 10234).
const series = await db . get ( ` SELECT next_seq FROM doc_series WHERE doc_type='INVOICE' AND fy=? ` , FY )
expect ( series ) . toEqual ( { next_seq : 10235 } )
// Tickets: the OLD_ID chain collapsed to ONE closed ticket with markers.
const chain = ( await db . get < {
status : string ; opened_on : string ; closed_on : string ; description : string
kind : string ; module _code : string ; online_offline : string ; created_at : string
} > ( ` SELECT status, opened_on, closed_on, description, kind, module_code, online_offline, created_at
FROM ticket WHERE description LIKE 'Printer issue - fixed%' ` ))!
expect ( chain . status ) . toBe ( 'closed' )
expect ( chain . opened_on ) . toBe ( '2025-01-01' ) // earliest CREATED in the chain
expect ( chain . closed_on ) . toBe ( '2025-01-04' ) // terminal row's UPDATED
expect ( chain . kind ) . toBe ( 'MODIFICATION' )
expect ( chain . module _code ) . toBe ( 'SMS' )
expect ( chain . online_offline ) . toBe ( 'online' )
expect ( chain . created_at ) . toBe ( '2025-01-01 09:00:00' )
expect ( chain . description ) . toBe ( 'Printer issue - fixed\n[contact: 9400000001]\n[mail attachment retained in APEX]' )
// Status mapping: WAITING* / DROP* / PENDING AS ON.
const waiting = ( await db . get < { status : string ; client_id : string } > (
` SELECT status, client_id FROM ticket WHERE description='Waiting one' ` ) ) !
expect ( waiting . status ) . toBe ( 'waiting' )
expect ( waiting . client_id ) . toBe ( c101 . id ) // blank BANK_ID resolved by exact BANK_NAME
const droppedT = await db . get ( ` SELECT status, closed_on FROM ticket WHERE description='Dropped one' ` )
expect ( droppedT ) . toEqual ( { status : 'dropped' , closed_on : '2025-04-02' } )
const pending = await db . get ( ` SELECT status FROM ticket WHERE description='Pending one' ` )
expect ( pending ) . toEqual ( { status : 'open' } ) // PENDING AS ON <date> → open
expect ( await db . get ( ` SELECT id FROM ticket WHERE description='Orphan one' ` ) ) . toBeUndefined ( )
// Open project rows without a TID landed as tickets (the live queue survives).
const projT = ( await db . get < { status : string ; kind : string ; module _code : string ; opened_on : string } > (
` SELECT status, kind, module_code, opened_on FROM ticket WHERE description LIKE 'Onboard SMS%' ` ) ) !
expect ( projT ) . toMatchObject ( { status : 'open' , kind : 'PROJECT' , module _code : 'SMS' , opened_on : '2024-08-29' } )
const projR = ( await db . get < { status : string ; module _code : string ; description : string } > (
` SELECT status, module_code, description FROM ticket WHERE description LIKE 'RTGS setup%' ` ) ) !
expect ( projR . status ) . toBe ( 'in_progress' ) // PENDING_TRANSfERED → in_progress
expect ( projR . module _code ) . toBe ( 'RTGS' )
expect ( projR . description ) . toContain ( '(DT_INFO unparsed: not-a-date)' )
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM ticket ` ) ) ! . n ) . toBe ( 6 )
// TID-bearing / closed project rows became interaction history with markers.
const sp = ( await db . get < { on_date : string ; notes : string ; type_code : string } > (
` SELECT on_date, notes, type_code FROM interaction WHERE notes LIKE '[SMS project]%' ` ) ) !
` SELECT on_date, notes, type_code FROM interaction WHERE notes LIKE ' %Install SMS %'` ) ) !
expect ( sp . type_code ) . toBe ( 'project' )
expect ( sp . on_date ) . toBe ( '2024-05-13' )
expect ( sp . notes ) . toBe ( '[SMS project] COMPLETED — Install SMS' )
const rp = ( await db . get < { on_date : string ; notes : string } > (
` SELECT on_date, notes FROM interaction WHERE notes LIKE '[RTGS project]%' ` ) ) !
expect ( rp . on_date ) . toBe ( TODAY )
expect ( rp . notes ) . toContain ( 'DT_INFO unparsed: not-a-date' )
expect ( sp . notes ) . toBe ( '[SMS project] CLOSED — Install SMS [assigned: DEVIKA]' )
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM interaction WHERE type_code='project' ` ) ) ! . n ) . toBe ( 2 )
// One summary audit per section (plus the series seed) — and never a plaintext secret.
// One summary audit per section (plus the series seed) — never a plaintext secret.
const auditEntities = ( await db . all < { entity : string } > (
` SELECT entity FROM audit_log WHERE action='import' ` ) ) . map ( ( r ) = > r . entity ) . sort ( )
expect ( auditEntities ) . toEqual ( [ 'bills' , 'branches' , 'clients' , 'doc_series' , 'rtgs_clients' ,
@ -290,45 +411,41 @@ describe('APEX full importer v2', () => {
expect ( auditDump ) . not . toContain ( 'smsPass9' )
} )
it ( ' refuses plaintext passwords loudly when no encryption key is configured ', async ( ) = > {
it ( ' dry run needs no key: passwords aggregate into blocking problems; commit refuses ', async ( ) = > {
const db = await freshDb ( )
const files = fullFiles ( )
const report = await stageApexFull ( db , files , '' )
expect ( report . clients . problems ) . toEqual ( [
' row 1: DB_PASSWORD present but HQ_SECRET_KEY is not configured — refusing to import it unencrypted ',
const { sections } = await stageApexFull ( db , files , '' )
expect ( sections . clients . problems ) . toEqual ( [
' 1 row(s) carry a plaintext DB_PASSWORD but HQ_SECRET_KEY is not set — commit refuses until the key is provided (values are never stored unencrypted) ',
] )
expect ( report . sms_clients . problems ) . toEqual ( [
' row 1: PASSWORD present but HQ_SECRET_KEY is not configured — refusing to import it unencrypted ',
expect ( sections . sms_clients . problems ) . toEqual ( [
' 1 row(s) carry a plaintext portal PASSWORD but HQ_SECRET_KEY is not set — commit refuses until the key is provided (values are never stored unencrypted) ',
] )
await expect ( commitApexFull ( db , 'u-test' , files , '' ) ) . rejects . toThrow ( /HQ_SECRET_KEY/ )
// Refused before the transaction — nothing landed.
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM client ` ) ) ! . n ) . toBe ( 0 )
} )
it ( ' duplicate doc_no is a problem that blocks the whole commit ', async ( ) = > {
it ( ' re-staging after commit reports duplicates as blocking problems (idempotency guard) ', async ( ) = > {
const db = await freshDb ( )
const files = {
clients : csv ( CLIENT_HDR , [ { SID_NO : '101' , BANK : 'Acme Bank' } ] ) ,
bills : csv ( BILL_HDR , [
{ BILL_DATE : '2025-05-01 00:00:00' , BANK_ID : '101' , INVOICE_NO : 'INV-1' , INVOICE_TYPE : 'INVOICE' , INVOICE_AMOUNT : '100' } ,
{ BILL_DATE : '2025-05-02 00:00:00' , BANK_ID : '101' , INVOICE_NO : 'INV-1' , INVOICE_TYPE : 'INVOICE' , INVOICE_AMOUNT : '200' } ,
] ) ,
}
const report = await stageApexFull ( db , files , KEY )
expect ( report . bills . problems ) . toEqual ( [ 'row 2: duplicate doc_no: INV-1' ] )
await expect ( commitApexFull ( db , 'u-test' , files , KEY ) ) . rejects . toThrow ( /duplicate doc_no/ )
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM document ` ) ) ! . n ) . toBe ( 0 )
expect ( ( await db . get < { n : number } > ( ` SELECT COUNT(*) AS n FROM client ` ) ) ! . n ) . toBe ( 0 )
const files = fullFiles ( )
await commitApexFull ( db , 'u-test' , files , KEY )
const { sections } = await stageApexFull ( db , files , KEY )
expect ( sections . clients . problems . some ( ( p ) = > p . includes ( 'duplicate SID_NO' ) ) ) . toBe ( true )
expect ( sections . bills . problems . some ( ( p ) = > p . includes ( 'already exists in HQ' ) ) ) . toBe ( true )
await expect ( commitApexFull ( db , 'u-test' , files , KEY ) ) . rejects . toThrow ( /problem/ )
} )
it ( 'rejects an unknown INVOICE_TYPE instead of guessing', async ( ) = > {
it ( 'rejects an unknown INVOICE_TYPE and an inconsistent line group instead of guessing' , async ( ) = > {
const db = await freshDb ( )
const report = await stageApexFull ( db , {
clients : csv ( CLIENT_HDR , [ { SID_NO : '101' , BANK : 'Acme Bank' } ]) ,
const { sections } = await stageApexFull ( db , {
clients : csv ( CLIENT_HDR , [ { SID_NO : '101' , BANK : 'Acme Bank' } , { SID_NO : '102' , BANK : 'Beta Bank' } ] ) ,
bills : csv ( BILL_HDR , [
{ BILL_DATE : '2025-05-01 00:00:00' , BANK_ID : '101' , INVOICE_NO : 'X-1' , INVOICE_TYPE : 'RECEIPT' , INVOICE_AMOUNT : '100' } ,
{ BILL_DATE : '2025-05-01 00:00:00' , BANK_ID : '101' , INVOICE_NO : 'X-1' , SRNO : '1' , INVOICE_TYPE : 'RECEIPT' , AMT : '100' } ,
{ BILL_DATE : '2025-05-01 00:00:00' , BANK_ID : '101' , INVOICE_NO : 'X-2' , SRNO : '1' , INVOICE_TYPE : 'INVOICE' , AMT : '100' } ,
{ BILL_DATE : '2025-05-01 00:00:00' , BANK_ID : '102' , INVOICE_NO : 'X-2' , SRNO : '2' , INVOICE_TYPE : 'INVOICE' , AMT : '50' } ,
] ) ,
} , KEY )
expect ( report . bills . problems ) . toEqual ( [ 'row 1: unknown INVOICE_TYPE: RECEIPT' ] )
expect ( sections . bills . problems . some ( ( p ) = > p . includes ( 'unknown INVOICE_TYPE: RECEIPT' ) ) ) . toBe ( true )
expect ( sections . bills . problems . some ( ( p ) = > p . includes ( 'inconsistent BANK_ID' ) ) ) . toBe ( true )
} )
} )