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.
452 lines
28 KiB
TypeScript
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)
|
|
})
|
|
})
|