import Database from 'better-sqlite3' import fs from 'node:fs' import path from 'node:path' /** HQ console DB — SQLite behind portable repositories, S3 backup at deploy time. */ export type DB = Database.Database const SCHEMA = ` CREATE TABLE IF NOT EXISTS staff_user ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('owner','staff')), pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS session ( token TEXT PRIMARY KEY, staff_id TEXT NOT NULL, expires_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS client ( id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, gstin TEXT, state_code TEXT NOT NULL DEFAULT '32', address TEXT NOT NULL DEFAULT '', contacts TEXT NOT NULL DEFAULT '[]', -- JSON [{name,phone,email,role}] status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')), notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS module ( id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, sac TEXT NOT NULL DEFAULT '998313', -- IT services default; CA session confirms per module allowed_kinds TEXT NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]', multi_subscription INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, quote_content TEXT NOT NULL DEFAULT '[]' -- JSON string[]: "what's included" lines on quotes ); CREATE TABLE IF NOT EXISTS module_price_book ( id TEXT PRIMARY KEY, module_id TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', kind TEXT NOT NULL, price_paise INTEGER NOT NULL, effective_from TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS client_module ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, module_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'quoted' CHECK (status IN ('quoted','ordered','installing','installed','trained','live','expired','cancelled')), kind TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', installed_on TEXT, completed_on TEXT, trained_on TEXT, next_renewal TEXT, active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS tax_class ( class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL, cess_pct_bp INTEGER NOT NULL DEFAULT 0, effective_from TEXT NOT NULL, effective_to TEXT ); CREATE TABLE IF NOT EXISTS doc_series ( doc_type TEXT NOT NULL, fy TEXT NOT NULL, prefix TEXT NOT NULL, next_seq INTEGER NOT NULL, PRIMARY KEY (doc_type, fy) ); CREATE TABLE IF NOT EXISTS document ( id TEXT PRIMARY KEY, doc_type TEXT NOT NULL CHECK (doc_type IN ('QUOTATION','PROFORMA','INVOICE','RECEIPT','CREDIT_NOTE')), doc_no TEXT, fy TEXT NOT NULL, client_id TEXT NOT NULL, doc_date TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','sent','accepted','invoiced','part_paid','paid','lost','cancelled')), ref_doc_id TEXT, -- QT→PI→INV chain; CN → the invoice it amends taxable_paise INTEGER NOT NULL, cgst_paise INTEGER NOT NULL, sgst_paise INTEGER NOT NULL, igst_paise INTEGER NOT NULL, round_off_paise INTEGER NOT NULL, payable_paise INTEGER NOT NULL, payload TEXT NOT NULL, -- JSON { lines: BillLine[], totals: BillTotals, terms?: string } source TEXT NOT NULL DEFAULT 'hq', -- 'apex' rows keep legacy numbers, exempt from series created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (doc_no) ); CREATE TABLE IF NOT EXISTS document_event ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, at_wall TEXT NOT NULL, kind TEXT NOT NULL, meta TEXT NOT NULL DEFAULT '{}' ); CREATE TABLE IF NOT EXISTS payment ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, received_on TEXT NOT NULL, mode TEXT NOT NULL CHECK (mode IN ('bank','upi','cheque','cash','other')), reference TEXT NOT NULL DEFAULT '', amount_paise INTEGER NOT NULL, tds_paise INTEGER NOT NULL DEFAULT 0, created_by TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS payment_allocation ( id TEXT PRIMARY KEY, payment_id TEXT NOT NULL, document_id TEXT NOT NULL, amount_paise INTEGER NOT NULL -- includes this allocation's TDS share ); CREATE TABLE IF NOT EXISTS email_account ( id TEXT PRIMARY KEY, address TEXT NOT NULL, refresh_token_enc TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','dead')), updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS email_log ( id TEXT PRIMARY KEY, document_id TEXT, to_addr TEXT NOT NULL, subject TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('sent','failed')), gmail_message_id TEXT, error TEXT, at_wall TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS setting ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS audit_log ( id TEXT PRIMARY KEY, at_wall TEXT NOT NULL, user_id TEXT NOT NULL, action TEXT NOT NULL, entity TEXT NOT NULL, entity_id TEXT NOT NULL, before_json TEXT, after_json TEXT ); CREATE TABLE IF NOT EXISTS stg_client ( row_no INTEGER PRIMARY KEY, code TEXT, name TEXT, gstin TEXT, state_code TEXT, address TEXT, phone TEXT, email TEXT, status TEXT, problems TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS stg_invoice ( row_no INTEGER PRIMARY KEY, client_code TEXT, doc_no TEXT, doc_date TEXT, taxable_paise INTEGER, tax_paise INTEGER, total_paise INTEGER, paid INTEGER, problems TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS recurring_plan ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, client_module_id TEXT, -- source of the invoice line's module; required for generation in HQ-2 cadence TEXT NOT NULL CHECK (cadence IN ('monthly','yearly')), amount_paise INTEGER, -- NULL = price from module_price_book at generation time next_run TEXT NOT NULL, policy TEXT NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')), active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS amc_contract ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, coverage TEXT NOT NULL DEFAULT '', period_from TEXT NOT NULL, period_to TEXT NOT NULL, amount_paise INTEGER NOT NULL, renewal_reminder_days INTEGER NOT NULL DEFAULT 30, legacy_paid INTEGER, -- manual flag for imported contracts only; else paid derives from invoice_doc_id invoice_doc_id TEXT, active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS interaction_type ( code TEXT PRIMARY KEY, label TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS interaction ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, type_code TEXT NOT NULL, on_date TEXT NOT NULL, staff_id TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', outcome TEXT CHECK (outcome IN ('positive','neutral','negative')), follow_up_on TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS reminder ( id TEXT PRIMARY KEY, rule_kind TEXT NOT NULL CHECK (rule_kind IN ('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced')), subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL, doc_id TEXT, -- the document this reminder concerns (overdue/recurring/amc invoice); NULL otherwise status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')), policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')), error TEXT, created_at TEXT NOT NULL, sent_at TEXT, UNIQUE (rule_kind, subject_id, due_period) -- the idempotency key: at-most-once per due-period ); ` export function openDb(dataDir?: string): DB { let db: DB if (dataDir === ':memory:') { db = new Database(':memory:') } else { const dir = dataDir ?? path.resolve(process.cwd(), 'data') fs.mkdirSync(dir, { recursive: true }) db = new Database(path.join(dir, 'hq.db')) db.pragma('journal_mode = WAL') } db.exec(SCHEMA) migrate(db) return db } /** Additive, idempotent column adds for DBs created before a schema change. */ function migrate(db: DB): void { const moduleCols = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[] if (!moduleCols.some((c) => c.name === 'quote_content')) { db.exec(`ALTER TABLE module ADD COLUMN quote_content TEXT NOT NULL DEFAULT '[]'`) } const emailCols = db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[] if (!emailCols.some((c) => c.name === 'bounced')) { db.exec(`ALTER TABLE email_log ADD COLUMN bounced INTEGER NOT NULL DEFAULT 0`) } }