From c6a80584302fe1585e1700584687e30faaf26433 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 15:01:20 +0530 Subject: [PATCH] =?UTF-8?q?feat(hq):=20hq-2=20schema=20=E2=80=94=20recurri?= =?UTF-8?q?ng/amc/interaction/reminder=20tables=20+=20bounce=20column=20+?= =?UTF-8?q?=20seeds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/hq/src/db.ts | 40 +++++++++++++++++++++++++++++++++ apps/hq/src/seed.ts | 34 ++++++++++++++++++++++++++++ apps/hq/test/hq2-schema.test.ts | 34 ++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 apps/hq/test/hq2-schema.test.ts diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index c56e84b..c378199 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -101,6 +101,42 @@ CREATE TABLE IF NOT EXISTS stg_invoice ( 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 { @@ -124,4 +160,8 @@ function migrate(db: DB): void { 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`) + } } diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index 4e51ce6..793df68 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto' import { createStaff } from './auth' import { writeAudit } from './audit' import type { DB } from './db' +import { createModule } from './repos-modules' /** First-boot seed: owner login, company settings placeholders, GST18 tax class. */ @@ -17,6 +18,17 @@ const SETTING_DEFAULTS: Record = { 'company.bank': '', } +const INTERACTION_TYPES: [string, string][] = [ + ['call', 'Call'], ['site_visit', 'Site visit'], ['training', 'Training'], + ['courtesy_meeting', 'Courtesy meeting'], ['committee_meeting', 'Committee meeting'], + ['demo', 'Demo'], ['complaint', 'Complaint'], +] + +const REMINDER_SETTINGS: Record = { + 'reminders.overdue_days': '7', + 'reminders.renewal_days': '15', +} + export function seedIfEmpty(db: DB): void { const staff = db.prepare(`SELECT COUNT(*) AS n FROM staff_user`).get() as { n: number } if (staff.n === 0) { @@ -43,4 +55,26 @@ export function seedIfEmpty(db: DB): void { ).run() writeAudit(db, 'system', 'seed', 'tax_class', 'GST18', undefined, { ratePctBp: 1800, effectiveFrom: '2017-07-01' }) } + + const typeInsert = db.prepare( + // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). + `INSERT INTO interaction_type (code, label) VALUES (?, ?) ON CONFLICT (code) DO NOTHING`, + ) + let seededTypes = 0 + for (const [code, label] of INTERACTION_TYPES) seededTypes += typeInsert.run(code, label).changes + if (seededTypes > 0) writeAudit(db, 'system', 'seed', 'interaction_type', '*', undefined, { count: seededTypes }) + + let seededReminderSettings = 0 + for (const [key, value] of Object.entries(REMINDER_SETTINGS)) seededReminderSettings += insert.run(key, value).changes + if (seededReminderSettings > 0) writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS) + + const amc = db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get() as { n: number } + if (amc.n === 0) { + // The AMC/renewal invoice line hangs off a real module so it flows through + // createDraft → computeBill unchanged. SAC 998719 (maintenance/repair) — CA to confirm. + createModule(db, 'system', { + code: 'AMC', name: 'Annual Maintenance Contract', sac: '998719', + allowedKinds: ['yearly', 'one_time'], + }) + } } diff --git a/apps/hq/test/hq2-schema.test.ts b/apps/hq/test/hq2-schema.test.ts new file mode 100644 index 0000000..bc02e48 --- /dev/null +++ b/apps/hq/test/hq2-schema.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' + +describe('hq2 schema', () => { + it('creates every HQ-2 table', () => { + const db = openDb(':memory:') + const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map((r) => r.name) + for (const t of ['recurring_plan', 'amc_contract', 'interaction', 'interaction_type', 'reminder']) + expect(names, `missing table ${t}`).toContain(t) + }) + it('adds the bounced column to email_log', () => { + const db = openDb(':memory:') + const cols = (db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]).map((c) => c.name) + expect(cols).toContain('bounced') + }) + it('enforces the reminder idempotency key', () => { + const db = openDb(':memory:') + const ins = db.prepare( + `INSERT OR IGNORE INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at) + VALUES (?, 'invoice_overdue', 's1', '2026-07', 'c1', 'queued', 'manual', '2026-07-10T00:00:00Z')`, + ) + expect(ins.run('r1').changes).toBe(1) + expect(ins.run('r2').changes).toBe(0) // same (rule_kind, subject_id, due_period) → ignored + }) + it('seeds interaction types, the AMC module and reminder settings', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const types = (db.prepare(`SELECT code FROM interaction_type`).all() as { code: string }[]).map((r) => r.code) + expect(types).toEqual(expect.arrayContaining(['call', 'site_visit', 'training', 'complaint'])) + expect(db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get()).toMatchObject({ n: 1 }) + const s = (db.prepare(`SELECT value FROM setting WHERE key='reminders.overdue_days'`).get() as { value: string } | undefined) + expect(s?.value).toBe('7') + }) +})