feat(d20): importer v2 rework from the 46-agent real-data audit

Bills are LINE ITEMS not documents: group by INVOICE_NO across three GST
eras (GST pseudo-line / 2026 columns-only / pre-GST zero-tax). 46 raw
ticket statuses -> 5 via ordered pattern map; OLD_ID re-key chains
collapse so superseded rows do not double-import. YES/NO flags respect
real vocabulary (GENERAL ENQUIRY != yes); ATM cell value becomes the
module provider. SID 329 test client + orphan refs skip (counted notes,
distinct from commit-blocking problems). District normalize, NO DATA
scrub, payment normalize, richer-row merge for duplicate service rows,
shared-number-space series seed. Dry run over the real export: 273
clients, 150 documents = Rs 33,16,800.24, 72 payments, 1,826 tickets,
667 interactions, INVOICE seed 10252 (matches APEX next_val). Suite 358
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent ea958ace40
commit 1328a9e898

@ -1,14 +1,16 @@
/** /**
* Full-APEX importer CLI (D20): `npm run import:full -- --dir <folder> [--commit]` * Full-APEX importer CLI (D20): `npm run import:full -- --dir <folder> [--commit]`
* Reads the nine workbook CSVs from <folder> (all optional except clients.csv), * Reads the nine workbook CSVs from <folder> (all optional except clients.csv),
* prints the per-section stage report, and only with --commit and a clean * prints the per-section stage report (problems block commit; notes are the
* report applies everything in one transaction. Without --commit it is a dry * deliberate skips/merges), and only with --commit and a problem-free report
* run that writes nothing. DB path comes from HQ_DATA_DIR (run from the repo * applies everything in one transaction. Without --commit it is a dry run that
* root otherwise the default resolves ./data from CWD); passwords in the * writes nothing and needs no HQ_SECRET_KEY. DB path comes from HQ_DATA_DIR (run
* export need HQ_SECRET_KEY set or their rows are refused. * from the repo root otherwise the default resolves ./data from CWD). NEVER
* prints credential values the report carries counts and row numbers only.
*/ */
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { formatINR } from '@sims/domain'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { APEX_FILES, commitApexFull, stageApexFull } from '../src/import-apex-full' import { APEX_FILES, commitApexFull, stageApexFull } from '../src/import-apex-full'
@ -38,20 +40,26 @@ void (async () => {
await seedIfEmpty(db) await seedIfEmpty(db)
const keyHex = process.env['HQ_SECRET_KEY'] ?? '' const keyHex = process.env['HQ_SECRET_KEY'] ?? ''
const report = await stageApexFull(db, files, keyHex) const { sections, summary } = await stageApexFull(db, files, keyHex)
let problemTotal = 0 let problemTotal = 0
for (const section of APEX_FILES) { for (const section of APEX_FILES) {
const r = report[section] const r = sections[section]
if (r.staged === 0 && r.problems.length === 0) continue if (r.staged === 0 && r.problems.length === 0 && r.notes.length === 0) continue
console.log(`${section}: ${r.staged} staged, ${r.problems.length} problem(s)`) console.log(`${section}: ${r.staged} staged, ${r.problems.length} problem(s), ${r.notes.length} note(s)`)
for (const p of r.problems) console.log(` - ${p}`) for (const p of r.problems) console.log(` ! ${p}`)
for (const n of r.notes) console.log(` - ${n}`)
problemTotal += r.problems.length problemTotal += r.problems.length
} }
console.log('---')
console.log(`Plan: ${summary.clients} clients, ${summary.client_modules} module assignments, ${summary.branches} branches,`)
console.log(` ${summary.documents} documents totalling ${formatINR(summary.totalPayablePaise)} payable, ${summary.payments} payments,`)
console.log(` ${summary.tickets} tickets, ${summary.interactions} interactions;`)
console.log(` INVOICE series seed for ${summary.currentFy}: ${summary.seedTail ?? 'none (no current-FY docs)'}`)
if (!args.includes('--commit')) { if (!args.includes('--commit')) {
console.log('Dry run — nothing written. Re-run with --commit to apply.') console.log('Dry run — nothing written. Re-run with --commit to apply.')
} else if (problemTotal > 0) { } else if (problemTotal > 0) {
console.error(`Refusing to commit: ${problemTotal} problem row(s) above — fix the CSVs and re-run.`) console.error(`Refusing to commit: ${problemTotal} problem(s) above — fix the CSVs and re-run.`)
process.exitCode = 1 process.exitCode = 1
} else { } else {
const counts = await commitApexFull(db, 'system', files, keyHex) const counts = await commitApexFull(db, 'system', files, keyHex)

File diff suppressed because it is too large Load Diff

@ -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 { describe, it, expect } from 'vitest'
import { fyOf } from '@sims/domain' import { fyOf } from '@sims/domain'
import { openDb, type DB } from '../src/db' 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 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' 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> { function fullFiles(): Record<string, string> {
return { return {
clients: csv(CLIENT_HDR, [ clients: csv(CLIENT_HDR, [
{ {
SID_NO: '101', BANK: 'Acme Bank', ADDRESS: 'Kochi HQ', PHONE: '9876543210', SID_NO: '101', BANK: 'Acme Bank', ADDRESS: 'Kochi HQ', PHONE: '9876543210',
MAIL: 'acme@bank.in', DISTRICT: 'Ernakulam', SMS: 'YES', RTGS: 'NO', WHATSAPP: 'Y', 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', 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', DISTRICT: 'Kollam', SMS: 'NO', RTGS: 'YES', SID_NO: '102', BANK: 'Beta Bank', PHONE: 'NO DATA', DISTRICT: 'Pathanamthita',
WHATSAPP: 'NO', MOBILE_APP: 'NO', DB_PASSWORD: 'NO DATA', ATM: 'NO', SMS: 'NO', RTGS: 'YES', DB_PASSWORD: 'NO DATA', ANYDESK: 'No Data', SECTOR: 'Service',
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, [ 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, [ bills: csv(BILL_HDR, [
{ // paid current-FY invoice — rupees→paise, payment+allocation, series seed // Era (a): base line + GST pseudo-line; payment fields repeated per line = ONE payment.
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', 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', 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 // PAID with no payment data (10140 pattern): status paid, NO payment row, note.
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: '', 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, [ 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', ID: '2', OLD_ID: '1', BANK_ID: '101', STATUS: 'PENDING_TRANSfERED',
ASSIGN_DATE: '2025-02-28 00:00:00', DESCRIPTION: 'Fix template', STATUS: 'CLOSED', CREATED: '2025-01-02 09:00:00', DESCRIPTION: 'Printer issue',
CREATED: '2025-02-28 09:00:00', UPDATED: '2025-03-02 10:00:00', ONLINE_OFFLINE: 'ONLINE', },
{
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 { // orphan client ref → skipped with a note, never a blocking problem
ID: '2', BANK_NAME: 'Acme Bank', DAY_DT: '01-MAR-2025', ID: '12', OLD_ID: '12', BANK_ID: '999', STATUS: 'OPEN',
STATUS: 'WAITING FOR CONFIRMATION', DESCRIPTION: 'Waiting one', CREATED: '2025-05-01 00:00:00', DESCRIPTION: 'Orphan one',
}, },
{ // TICKET_DROPED='Y' with a non-terminal status → dropped, closed_on from UPDATED { // 'PENDING AS ON <date>' → open
ID: '3', BANK_ID: '102', STATUS: '', TICKET_DROPED: 'Y', DESCRIPTION: 'Dropped one', ID: '13', OLD_ID: '13', BANK_ID: '102', STATUS: 'PENDING AS ON 20-02-2026',
CREATED: '2025-04-01 09:00:00', UPDATED: '2025-04-02 00:00:00', CREATED: '2025-06-01 07:00:00', DESCRIPTION: 'Pending one',
}, },
]), ]),
sms_clients: csv(SMS_CLIENT_HDR, [ sms_clients: csv(SMS_CLIENT_HDR, [
{ // CID 102's SMS flag said NO — this row wins and creates the assignment { // junk duplicate (CID 33 pattern) — fewer fields, dropped in favour of the richer row
RNO: '1', CID: '102', BANK: 'Beta Bank', USERNAME: 'betauser', PASSWORD: 'smsPass9', RNO: '1', CID: '102', BANK: 'Beta Bank', SMS: 'GENERAL ENQUIRY',
SMS_PROVIDER: 'AltSMS', PHONE: '0474222', SMS_BALANCE: '5000', REMARK: 'ok', USERNAME: 'WHATSAPP', PASSWORD: 'WHATSAPP',
RESELLER: 'RSL', PAYMENT: 'PAID 2025', INSTALLATION_DATE: '10-NOV-2023', },
{
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, [ sms_modules: csv(SMS_MODULE_HDR, [
{ RNO: '1', SID_NO: '102', HEAD: 'ATM ALERT', ACTIV: 'Y' }, { RNO: '1', SID_NO: '102', HEAD: 'ATM ALERT', ACTIV: 'Y', PKG: 'PKG_SMS_JOB' },
{ RNO: '2', SID_NO: '102', HEAD: 'BALANCE ALERT', ACTIV: 'Y' }, { RNO: '2', SID_NO: '102', HEAD: 'BALANCE ALERT', ACTIV: 'Y', REMARK: 'emp only' },
{ RNO: '3', SID_NO: '102', HEAD: 'OLD ALERT', ACTIV: 'N' }, { RNO: '3', SID_NO: '102', HEAD: 'OLD ALERT', ACTIV: 'N', PKG: 'PKG_SMS_JOB' },
]), ]),
rtgs_clients: csv(RTGS_CLIENT_HDR, [ 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', 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', RTGS: 'YES', IP_CODE: 'IPC1', ABB: 'BETA', INTEGRATED_BANK_PARTNERS: 'SBI',
INSTALLED_ON: '2024-01-05 00:00:00', GENERATE_QR: 'YES', PAYMENT: 'PAID', 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, [ 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, [ 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 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', () => { 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('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('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('08/29/2024')).toBe('2024-08-29') // slash → MM/DD
expect(parseApexDate('13-05-2024')).toBe('2024-05-13') // first >12 → DD-MM expect(parseApexDate('09/03/2024')).toBe('2024-09-03') // slash ambiguous → MM/DD
expect(parseApexDate('10-11-2023')).toBe('2023-11-10') // ambiguous → assume DD-MM 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('')).toBeNull()
expect(parseApexDate('not-a-date')).toBeNull() expect(parseApexDate('not-a-date')).toBeNull()
expect(parseApexDate('99-99-2024')).toBeNull() expect(parseApexDate('99-99-2024')).toBeNull()
}) })
}) })
describe('APEX full importer v2', () => { describe('APEX full importer v2 (real-export shapes)', () => {
it('stage: clean workbook → per-section counts, zero problems, and NO writes', async () => { it('stage: clean workbook → zero problems, deliberate skips as notes, and NO writes', async () => {
const db = await freshDb() const db = await freshDb()
const report = await stageApexFull(db, fullFiles(), KEY) const { sections, summary } = await stageApexFull(db, fullFiles(), KEY)
expect(report.clients).toEqual({ staged: 2, problems: [] }) for (const s of Object.values(sections)) expect(s.problems).toEqual([])
expect(report.branches).toEqual({ staged: 1, problems: [] }) // Deliberate auto-resolutions surface as notes, not problems.
expect(report.bills).toEqual({ staged: 2, problems: [] }) expect(sections.branches.notes.some((n) => n.includes('MID=999'))).toBe(true)
expect(report.tickets).toEqual({ staged: 3, problems: [] }) expect(sections.branches.notes.some((n) => n.includes('duplicate branch row'))).toBe(true)
expect(report.sms_clients).toEqual({ staged: 1, problems: [] }) expect(sections.bills.notes.some((n) => n.includes('exact-duplicate line'))).toBe(true)
expect(report.sms_modules).toEqual({ staged: 3, problems: [] }) expect(sections.bills.notes.some((n) => n.includes('marked PAID with no payment'))).toBe(true)
expect(report.rtgs_clients).toEqual({ staged: 1, problems: [] }) expect(sections.bills.notes.some((n) => n.includes('CGST+SGST'))).toBe(true)
expect(report.sms_projects).toEqual({ staged: 1, problems: [] }) expect(sections.tickets.notes.some((n) => n.includes('BANK_ID=999'))).toBe(true)
expect(report.rtgs_projects).toEqual({ staged: 1, problems: [] }) expect(sections.tickets.notes.some((n) => n.includes('collapsed 2 superseded'))).toBe(true)
// Pure validation: no clients, no documents, no import audit rows. 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 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 ticket`))!.n).toBe(0)
expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log WHERE action='import'`))!.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 () => { it('commit: full happy path lands every section in one transaction', async () => {
const db = await freshDb() const db = await freshDb()
const res = await commitApexFull(db, 'u-test', fullFiles(), KEY) const res = await commitApexFull(db, 'u-test', fullFiles(), KEY)
expect(res.clients).toBe(2) expect(res).toMatchObject({
expect(res.client_modules).toBe(4) // 101: SMS+WHATSAPP (flags); 102: RTGS (flag) + SMS (data wins) clients: 3, client_modules: 7, branches: 1, bills: 6, payments: 3, tickets: 6,
expect(res.branches).toBe(1) sms_projects: 3, rtgs_projects: 1, seeded: { fy: FY, lastSeq: 10234 },
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 })
// 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<{ const c101 = (await db.get<{
id: string; name: string; address: string; district: string; sector: string id: string; district: string; contacts: string; db_password_enc: string; source: string
os: string; anydesk: string; contacts: string; status: string; source: string }>(`SELECT id, district, contacts, db_password_enc, source FROM client WHERE code='101'`))!
state_code: string; db_password_enc: string expect(c101.district).toBe('ERNAKULAM')
}>(`SELECT * FROM client WHERE code='101'`))! expect(c101.source).toBe('apex')
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',
})
expect(JSON.parse(c101.contacts)).toEqual([ expect(JSON.parse(c101.contacts)).toEqual([
{ name: 'Acme Bank', phone: '9876543210', email: 'acme@bank.in' }, { name: 'Acme Bank', phone: '9876543210', email: 'acme@bank.in' },
{ name: 'Secretary John 9999', role: 'secretary' }, { 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') expect(decrypt(c101.db_password_enc, KEY)).toBe('s3cret-db')
const c102 = (await db.get<{ anydesk: string | null; db_password_enc: string | null }>( const c102 = (await db.get<{ anydesk: string | null; db_password_enc: string | null; district: string; contacts: string }>(
`SELECT anydesk, db_password_enc FROM client WHERE code='102'`))! `SELECT anydesk, db_password_enc, district, contacts FROM client WHERE code='102'`))!
expect(c102).toEqual({ anydesk: null, db_password_enc: null }) // 'NO DATA' → nothing stored 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. // Flags per policy 4: GENERAL ENQUIRY is not a module; ATM value → provider;
const modules = await db.all<{ code: string; name: string }>( // MOBILE_APP phone number → Contact detail.
`SELECT code, name FROM module WHERE code IN ('SMS','RTGS','WHATSAPP') ORDER BY code`) const atm = (await cmFor(db, '103', 'ATM'))!
expect(modules).toEqual([ expect(atm.status).toBe('live')
{ code: 'RTGS', name: 'RTGS' }, { code: 'SMS', name: 'Bulk SMS' }, expect(atm.provider).toBe('EWIRE')
{ code: 'WHATSAPP', name: 'WhatsApp' }, const mob = (await cmFor(db, '103', 'MOBILEAPP'))!
]) expect(JSON.parse(mob.details)).toContainEqual({ label: 'Contact', value: '8301932166' })
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' },
])
// Branch joined by MID. // Status vocabulary: 103's SMS came from GENERAL ENQUIRY (quoted) and the latest
const branch = await db.get(`SELECT b.name, b.code, b.active FROM client_branch b // project row (NEW PROJECT) upgraded it to installing — never to live.
JOIN client c ON c.id=b.client_id WHERE c.code='101'`) const sms103 = (await cmFor(db, '103', 'SMS'))!
expect(branch).toEqual({ name: 'Main Branch', code: 'BR001', active: 1 }) expect(sms103.status).toBe('installing')
// Bills: rupees→paise, intra-state split, payment + allocation, series seed. // SMS service data on 102 (richer duplicate row won; junk 'WHATSAPP' row dropped).
const inv = (await db.get<{ const sms102 = (await cmFor(db, '102', 'SMS'))!
id: string; doc_type: string; fy: string; status: string; taxable_paise: number expect(sms102.status).toBe('live')
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'`))!
expect(sms102.provider).toBe('AltSMS') expect(sms102.provider).toBe('AltSMS')
expect(sms102.username).toBe('betauser') 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.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 }[] const smsDetails = JSON.parse(sms102.details) as { label: string; value: string }[]
expect(smsDetails).toContainEqual({ label: 'SMS balance', value: '5000' }) expect(smsDetails.find((d) => d.label === 'SMS balance')).toBeUndefined() // 'YES' is a flag, not a balance
expect(smsDetails).toContainEqual({ label: 'Installed', value: '10-NOV-2023' }) expect(smsDetails).toContainEqual({ label: 'Payment', value: 'paid' })
expect(smsDetails).toContainEqual({ label: 'Active alerts', value: 'ATM ALERT, BALANCE ALERT' }) 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. // RTGS: duplicate engagements merged richer-first; INSTALLATION_DATE → installed_on;
const rtgs102 = (await db.get<{ details: string }>( // INSTALLED_ON server text → detail; Nil payment did not overwrite PAID.
`SELECT cm.details FROM client_module cm const rtgs102 = (await cmFor(db, '102', 'RTGS'))!
JOIN module m ON m.id=cm.module_id JOIN client c ON c.id=cm.client_id expect(rtgs102.status).toBe('live')
WHERE c.code='102' AND m.code='RTGS'`))! expect(rtgs102.installed_on).toBe('2024-01-05')
const rtgsDetails = JSON.parse(rtgs102.details) as { label: string; value: string }[] 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: 'IP', value: '10.0.0.5' })
expect(rtgsDetails).toContainEqual({ label: 'Installed', value: '2024-01-05 00:00:00' }) expect(rtgsDetails).toContainEqual({ label: 'Installed on', value: 'Database Server' })
expect(rtgsDetails).toContainEqual({ label: 'QR', value: 'YES' }) 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 }>( 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.type_code).toBe('project')
expect(sp.on_date).toBe('2024-05-13') expect(sp.on_date).toBe('2024-05-13')
expect(sp.notes).toBe('[SMS project] COMPLETED — Install SMS') expect(sp.notes).toBe('[SMS project] CLOSED — Install SMS [assigned: DEVIKA]')
const rp = (await db.get<{ on_date: string; notes: string }>( expect((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM interaction WHERE type_code='project'`))!.n).toBe(2)
`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')
// 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 }>( const auditEntities = (await db.all<{ entity: string }>(
`SELECT entity FROM audit_log WHERE action='import'`)).map((r) => r.entity).sort() `SELECT entity FROM audit_log WHERE action='import'`)).map((r) => r.entity).sort()
expect(auditEntities).toEqual(['bills', 'branches', 'clients', 'doc_series', 'rtgs_clients', expect(auditEntities).toEqual(['bills', 'branches', 'clients', 'doc_series', 'rtgs_clients',
@ -290,45 +411,41 @@ describe('APEX full importer v2', () => {
expect(auditDump).not.toContain('smsPass9') 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 db = await freshDb()
const files = fullFiles() const files = fullFiles()
const report = await stageApexFull(db, files, '') const { sections } = await stageApexFull(db, files, '')
expect(report.clients.problems).toEqual([ expect(sections.clients.problems).toEqual([
'row 1: DB_PASSWORD present but HQ_SECRET_KEY is not configured — refusing to import it unencrypted', '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([ expect(sections.sms_clients.problems).toEqual([
'row 1: PASSWORD present but HQ_SECRET_KEY is not configured — refusing to import it unencrypted', '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/) 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) 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 db = await freshDb()
const files = { const files = fullFiles()
clients: csv(CLIENT_HDR, [{ SID_NO: '101', BANK: 'Acme Bank' }]), await commitApexFull(db, 'u-test', files, KEY)
bills: csv(BILL_HDR, [ const { sections } = await stageApexFull(db, files, KEY)
{ BILL_DATE: '2025-05-01 00:00:00', BANK_ID: '101', INVOICE_NO: 'INV-1', INVOICE_TYPE: 'INVOICE', INVOICE_AMOUNT: '100' }, expect(sections.clients.problems.some((p) => p.includes('duplicate SID_NO'))).toBe(true)
{ BILL_DATE: '2025-05-02 00:00:00', BANK_ID: '101', INVOICE_NO: 'INV-1', INVOICE_TYPE: 'INVOICE', INVOICE_AMOUNT: '200' }, 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/)
}
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)
}) })
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 db = await freshDb()
const report = await stageApexFull(db, { const { sections } = await stageApexFull(db, {
clients: csv(CLIENT_HDR, [{ SID_NO: '101', BANK: 'Acme Bank' }]), clients: csv(CLIENT_HDR, [{ SID_NO: '101', BANK: 'Acme Bank' }, { SID_NO: '102', BANK: 'Beta Bank' }]),
bills: csv(BILL_HDR, [ 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) }, 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)
}) })
}) })

Loading…
Cancel
Save