feat(hq): hq-2 schema — recurring/amc/interaction/reminder tables + bounce column + seeds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent ea0bc146bb
commit c6a8058430

@ -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`)
}
}

@ -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<string, string> = {
'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<string, string> = {
'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'],
})
}
}

@ -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')
})
})
Loading…
Cancel
Save