You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/test/import-apex-full.test.ts

452 lines
28 KiB
TypeScript

// 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, string>[]): 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<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',
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 <date>' → 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<DB> {
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 <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 '%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)
})
})