// 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' import { seedIfEmpty } from '../src/seed' import { decrypt } from '../src/crypto' import { commitApexFull, parseApexDate, stageApexFull } from '../src/import-apex-full' const KEY = '22'.repeat(32) // 64 hex chars = 32 bytes const TODAY = new Date().toISOString().slice(0, 10) const FY = fyOf(TODAY) // Build a CSV from sparse row objects — headers EXACTLY as the APEX export ships them. const csv = (headers: string, rows: Record[]): string => headers + '\n' + rows.map((r) => headers.split(',').map((h) => r[h] ?? '').join(',')).join('\n') + '\n' const CLIENT_HDR = 'SID_NO,BANK,ADDRESS,PHONE,MAIL,DISTRICT,SMS,RTGS,WHATSAPP,MOBILE_APP,DB_PASSWORD,ATM,ANYDESK,SECRETARY_CONTACT_DETAILS,OS,SECTOR' const BRANCH_HDR = 'ID,BRANCH,SMS,MID,BANK,BRCODE' const BILL_HDR = 'DAY_DT,BILL_TYP,BILL_DATE,BILL_NO,BANK_ID,BANK_NAME,ADDRESS,MOBILE,MAIL,REMARK,USR,INVOICE_NO,QTY,PER_QTY,DESCRIPTION,INVOICE_TYPE,INVOICE_AMOUNT,PAYMENT_STATUS,PAYMENT_DATE,PAYMENT_AMOUNT,NOTES,AMT,SRNO,GST_RATE,GST_AMOUNT,TOTAL_AMOUNT,CONVERTED_FROM_PROFORMA_ID,GST_APPLICABLE,INVOICE_ID,SCROLL' const TICKET_HDR = 'ID,DAY_DT,BANK_NAME,BANK_ID,BRID,TYPE,GROUP,ASSIGN_DATE,MOBILE_NO,ASSIGNED_TO,DESCRIPTION,STATUS,CREATED,CREATED_BY,UPDATED,UPDATED_BY,ONLINE_OFFLINE,MAIL_BLOB,MAIL,TRNS_ID,TRNS_FRM,DAY_ID,ENAME,TRANS,TICKET_DROPED,DAY_CLS,OLD_ID,SCROLL' const SMS_CLIENT_HDR = 'RNO,CID,BANK,DISTRICT,SMS,USERNAME,PASSWORD,PAYMENT,SMS_PROVIDER,ADDED_FEATURES,CREATED_BY,UPDATED_BY,PHONE,TID,SMS_BALANCE,ERR,REMARK,UPDATION,RESELLER,PAYMENT_DATE,INSTALLATION_DATE' const SMS_MODULE_HDR = 'RNO,SID_NO,SMS_ID,HEAD,COLS,PKG,ACTIV,REMARK,IMP_BY,ON_DAY_DT' const RTGS_CLIENT_HDR = 'SID_NO,BANK_NAME,ADDRESS_IP,PHONE_NO,RTGS,IP_CODE,E_COLLECTION_CODE,RID,TID,ABB,INTEGRATED_BANK_PARTNERS,REMARK,PAYMENT,INSTALLATION_DATE,INSTALLED_ON,GENERATE_QR,SBI_INWARD_REPORT,PASSBOOK_PROGRAM,IFSC_2025,NOTIFICATION,MISC_RTGS_OUTWARD,PAYMENT_DATE' 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 the real-export shapes per section. */ function fullFiles(): Record { 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', DB_PASSWORD: 's3cret-db', ATM: 'NO', ANYDESK: '123 456 789', SECRETARY_CONTACT_DETAILS: 'Secretary John 9999', OS: 'Windows 10', SECTOR: 'Co-op', }, { // '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: '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, [ // 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', }, // 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: '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', }, { // 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', }, { // 'PENDING AS ON ' → open ID: '13', OLD_ID: '13', BANK_ID: '102', STATUS: 'PENDING AS ON 20-02-2026', CREATED: '2025-06-01 07:00:00', DESCRIPTION: 'Pending one', }, ]), sms_clients: csv(SMS_CLIENT_HDR, [ { // 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', 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', 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, [ { // 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, [ { // 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', }, ]), } } async function freshDb(): Promise { const db = openDb(':memory:') await seedIfEmpty(db) return db } const cmFor = async (db: DB, clientCode: string, moduleCode: 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 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') // 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 (real-export shapes)', () => { it('stage: clean workbook → zero problems, deliberate skips as notes, and NO writes', async () => { const db = await freshDb() 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 ticket`))!.n).toBe(0) expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log WHERE action='import'`))!.n).toBe(0) }) 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).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: hygiene (district normalize, NO DATA filters) + contacts + encrypted password. const c101 = (await db.get<{ 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' }, ]) expect(decrypt(c101.db_password_enc, KEY)).toBe('s3cret-db') 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 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' }) // 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') // 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(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.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: 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 on', value: 'Database Server' }) expect(rtgsDetails).toContainEqual({ label: 'E-collection code', value: 'EC77' }) // merged from sparse row expect(rtgsDetails).toContainEqual({ label: 'Payment', value: 'paid' }) // 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 → 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 '%Install SMS%'`))! expect(sp.type_code).toBe('project') expect(sp.on_date).toBe('2024-05-13') 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) — 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', 'rtgs_projects', 'sms_clients', 'sms_modules', 'sms_projects', 'tickets']) const auditDump = JSON.stringify(await db.all(`SELECT * FROM audit_log`)) expect(auditDump).not.toContain('s3cret-db') expect(auditDump).not.toContain('smsPass9') }) it('dry run needs no key: passwords aggregate into blocking problems; commit refuses', async () => { const db = await freshDb() const files = fullFiles() 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(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/) expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM client`))!.n).toBe(0) }) it('re-staging after commit reports duplicates as blocking problems (idempotency guard)', async () => { const db = await freshDb() 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 and an inconsistent line group instead of guessing', async () => { const db = await freshDb() 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', 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(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) }) })