// apps/hq/test/quote-followup.test.ts — Phase 6: escalating quote follow-up (spec §7) import express from 'express' import Database from 'better-sqlite3' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { openDb, rebuildReminderRuleKindCheck, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createStaff } from '../src/auth' import { apiRouter } from '../src/api' import { createClient, type Client } from '../src/repos-clients' import { createModule, setPrice, type Module } from '../src/repos-modules' import { cancelDocument, convertDocument, createDraft, getDocument, issueDocument, markStatus, type Doc, } from '../src/repos-documents' import { saveAccount } from '../src/repos-email' import { encrypt } from '../src/crypto' import { getReminder, listQueue, listReminders, queueCounts, setSetting, upsertReminder, } from '../src/repos-reminders' import { reminderContext, sendReminder, type SendReminderDeps } from '../src/send-reminder' import { reminderEmail } from '../src/reminder-templates' import { runDailyScan, type ScanDeps } from '../src/scheduler' const KEY = '11'.repeat(32) const okFetch = (async (url: string) => new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch const deps: ScanDeps & SendReminderDeps = { gmail: { f: okFetch, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: async () => Buffer.from('%PDF-fake'), company: () => ({ 'company.name': 'SiMS' }), now: () => '2026-07-10T09:00:00Z', } async function world() { const db = openDb(':memory:'); await seedIfEmpty(db) await saveAccount(db, 'us@sims.com', encrypt('refresh-token', KEY)) await setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in') // sends refuse a dead relative link without it const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] }) const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) return { db, c, m } } /** A QUOTATION marked sent, with the first-sent event pinned to `sentAt` for age math. */ async function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Promise { const q = await createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) await markStatus(db, by, q.id, 'sent') await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`, sentAt, q.id) return q } const followups = (db: DB) => listReminders(db, { ruleKind: 'quote_followup' }) const shareCount = async (db: DB): Promise => ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM document_share`))!).n // ---------- schema migration (rebuild-once via the shared helper) ---------- const OLD_REMINDER_DDL = ` CREATE TABLE 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, 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) )` describe('reminder.rule_kind CHECK widening', () => { it('a fresh DB accepts quote_followup directly', async () => { const db = openDb(':memory:') const up = await upsertReminder(db, { ruleKind: 'quote_followup', subjectId: 'q1', duePeriod: 'd3', clientId: 'c1', docId: 'q1', now: '2026-07-10T00:00:00Z' }) expect(up.created).toBe(true) }) it('rebuilds an old-CHECK table preserving rows, UNIQUE key and status CHECK; idempotent', () => { // Raw better-sqlite3 handle: the rebuild helper is a SQLite-only migration that // operates below the async DB interface (it takes SqliteRaw). const db = new Database(':memory:') db.exec(OLD_REMINDER_DDL) db.prepare( `INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at) VALUES ('r-old','invoice_overdue','inv1','2026-07','c1','queued','manual','2026-07-01T00:00:00Z')`, ).run() // Old CHECK really rejects the new kind before the rebuild. expect(() => db.prepare( `INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at) VALUES ('r-new','quote_followup','q1','d3','c1','queued','manual','2026-07-01T00:00:00Z')`, ).run()).toThrow() rebuildReminderRuleKindCheck(db) // Old row copied across, new kind now allowed. expect(db.prepare(`SELECT rule_kind FROM reminder WHERE id='r-old'`).get()).toMatchObject({ rule_kind: 'invoice_overdue' }) db.prepare( `INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at) VALUES ('r-new','quote_followup','q1','d3','c1','queued','manual','2026-07-01T00:00:00Z')`, ).run() // Idempotency key preserved verbatim. expect(() => db.prepare( `INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at) VALUES ('r-dup','quote_followup','q1','d3','c1','queued','manual','2026-07-01T00:00:00Z')`, ).run()).toThrow() // status CHECK preserved verbatim. expect(() => db.prepare( `INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at) VALUES ('r-bad','quote_followup','q2','d3','c1','bogus','manual','2026-07-01T00:00:00Z')`, ).run()).toThrow() // Re-run is a no-op (rows intact). rebuildReminderRuleKindCheck(db) expect((db.prepare(`SELECT COUNT(*) AS n FROM reminder`).get() as { n: number }).n).toBe(2) }) }) // ---------- the daily scan ---------- describe('runDailyScan — quote_followup escalation', () => { it('fires each interval at most once per quote, day by day', async () => { const { db, c, m } = await world() const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z') expect((await runDailyScan(db, deps, '2026-07-02')).created['quote_followup'] ?? 0).toBe(0) // age 1 < 3 expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup']).toBe(1) // d3 expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup'] ?? 0).toBe(0) // same day re-run expect((await runDailyScan(db, deps, '2026-07-08')).created['quote_followup']).toBe(1) // d7 expect((await runDailyScan(db, deps, '2026-07-15')).created['quote_followup']).toBe(1) // d14 expect((await runDailyScan(db, deps, '2026-07-20')).created['quote_followup'] ?? 0).toBe(0) // ladder consumed const rows = await followups(db) expect(rows.map((r) => r.duePeriod).sort()).toEqual(['d14', 'd3', 'd7']) expect(rows.every((r) => r.subjectId === q.id && r.docId === q.id && r.policyApplied === 'manual')).toBe(true) }) it('catch-up fires ONLY the single highest crossed milestone', async () => { const { db, c, m } = await world() await sentQuote(db, c, m, '2026-06-01T09:00:00Z') // age 39 on first scan — past 3, 7 and 14 const res = await runDailyScan(db, deps, '2026-07-10') expect(res.created['quote_followup']).toBe(1) const rows = await followups(db) expect(rows).toHaveLength(1) expect(rows[0]!.duePeriod).toBe('d14') }) it('ignores quotes that are not sent (draft / accepted / lost / cancelled leave the set)', async () => { const { db, c, m } = await world() await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) const q = await sentQuote(db, c, m, '2026-06-01T09:00:00Z') await markStatus(db, 'u1', q.id, 'accepted') const res = await runDailyScan(db, deps, '2026-07-10') expect(res.created['quote_followup'] ?? 0).toBe(0) }) it('a converted quote is NEVER chased again — the next scan creates nothing', async () => { const { db, c, m } = await world() const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') // d3 queued await convertDocument(db, 'u1', q.id, 'PROFORMA') expect((await getDocument(db, q.id))!.status).toBe('invoiced') // left the 'sent' set for good const res = await runDailyScan(db, deps, '2026-07-09') // would be d7 expect(res.created['quote_followup'] ?? 0).toBe(0) expect((await followups(db)).every((r) => r.status === 'dismissed')).toBe(true) }) it('a pre-fix converted quote (still status=sent) is excluded via its live forward child', async () => { const { db, c, m } = await world() const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z') await convertDocument(db, 'u1', q.id, 'PROFORMA') await db.run(`UPDATE document SET status='sent' WHERE id=?`, q.id) // data converted before the status flip landed const res = await runDailyScan(db, deps, '2026-07-09') expect(res.created['quote_followup'] ?? 0).toBe(0) }) it('a lost client is never chased, whatever their quote row says', async () => { const { db, c, m } = await world() await sentQuote(db, c, m, '2026-06-01T09:00:00Z') await db.run(`UPDATE client SET status='lost' WHERE id=?`, c.id) const res = await runDailyScan(db, deps, '2026-07-10') expect(res.created['quote_followup'] ?? 0).toBe(0) }) it('auto policy drains sends after the scan, at-most-once, minting one ~60-day share', async () => { const { db, c, m } = await world() await setSetting(db, 'u1', 'quote.followup.policy', 'auto') await sentQuote(db, c, m, '2026-07-01T09:00:00Z') const res = await runDailyScan(db, deps, '2026-07-04') // d3 expect(res.created['quote_followup']).toBe(1) expect(res.autoSent).toBe(1) const row = (await followups(db))[0]! expect(row.policyApplied).toBe('auto') expect(row.status).toBe('sent') expect(await shareCount(db)).toBe(1) const share = (await db.get<{ expires_at: string | null }>(`SELECT expires_at FROM document_share`))! expect(share.expires_at).not.toBeNull() // never-expiring links are refused (F12) const days = (Date.parse(share.expires_at!) - Date.now()) / 86_400_000 expect(days).toBeGreaterThan(59); expect(days).toBeLessThan(61) // Next milestone reuses the live share instead of duplicating it. const res2 = await runDailyScan(db, deps, '2026-07-08') // d7 expect(res2.autoSent).toBe(1) expect(await shareCount(db)).toBe(1) // Re-run creates nothing and sends nothing (unique key → at-most-once). const res3 = await runDailyScan(db, deps, '2026-07-08') expect(res3.created['quote_followup'] ?? 0).toBe(0) expect(res3.autoSent).toBe(0) }) }) // ---------- preview / context (read-only) and send (mints) ---------- describe('quote_followup context, preview and send', () => { it('preview writes nothing — no share minted, no audit rows', async () => { const { db, c, m } = await world() await sentQuote(db, c, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') const rem = (await followups(db))[0]! const auditBefore = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log`))!).n const { ctx } = await reminderContext(db, rem, 'SiMS') const mail = reminderEmail('quote_followup', ctx) expect(mail.subject).toContain('SiMS') expect(await shareCount(db)).toBe(0) // resolve-only: preview NEVER mints expect(((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log`))!).n).toBe(auditBefore) }) it('degrades gracefully when the quote has no docNo — never renders "quotation null"', async () => { const { db, c, m } = await world() const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z') // never issued: docNo is null (F10) await runDailyScan(db, deps, '2026-07-04') const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'SiMS') const mail = reminderEmail('quote_followup', ctx) expect(mail.subject).not.toContain('null') expect(mail.bodyText).not.toContain('null') expect(mail.subject).toContain(`dated ${q.docDate}`) expect(mail.bodyText).toContain('Acme') // clientName substituted }) it('uses the doc number when the quote is issued', async () => { const { db, c, m } = await world() const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z') const issued = await issueDocument(db, 'u1', q.id) await runDailyScan(db, deps, '2026-07-04') const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'SiMS') expect(reminderEmail('quote_followup', ctx).subject).toContain(issued.docNo!) }) it('send resolves the dated subject/body on the injected clock, not the wall clock', async () => { const { db, c, m } = await world() // A dated row that takes over on 2026-07-11: resolving on 07-10 must not see it. await db.run( `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) VALUES ('rs-future','quote_followup','2026-07-11',NULL,'3,7,14','FUTURE {ref}','future body {shareUrl}')`, ) await sentQuote(db, c, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') const rem = (await followups(db))[0]! const before = await reminderContext(db, rem, 'SiMS', '2026-07-10') expect(reminderEmail('quote_followup', before.ctx).subject).not.toContain('FUTURE') const after = await reminderContext(db, rem, 'SiMS', '2026-07-11') expect(reminderEmail('quote_followup', after.ctx).subject).toContain('FUTURE') }) it('refuses to send when share.base_url is unset — loud error, NO share minted, row still queued', async () => { const { db, c, m } = await world() await db.run(`DELETE FROM setting WHERE key='share.base_url'`) await sentQuote(db, c, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') const rem = (await followups(db))[0]! await expect(sendReminder(db, deps, rem.id, 'u1')).rejects.toThrow(/share\.base_url/) expect(await shareCount(db)).toBe(0) // nothing public left behind by the refused send expect((await getReminder(db, rem.id))!.status).toBe('queued') }) it('auto mode parks a hard-failing send as failed instead of crashing the scan', async () => { const { db, c, m } = await world() await db.run(`DELETE FROM setting WHERE key='share.base_url'`) await setSetting(db, 'u1', 'quote.followup.policy', 'auto') await sentQuote(db, c, m, '2026-07-01T09:00:00Z') const res = await runDailyScan(db, deps, '2026-07-04') expect(res.created['quote_followup']).toBe(1) expect(res.autoFailed).toBe(1) const row = (await followups(db))[0]! expect(row.status).toBe('failed') expect(row.error).toContain('share.base_url') expect(await shareCount(db)).toBe(0) }) it('resolves the recipient BEFORE minting — a client without email leaves no share behind', async () => { const { db, m } = await world() const bare = await createClient(db, 'u1', { name: 'NoMail Co', stateCode: '32' }) await sentQuote(db, bare, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') const rem = (await followups(db)).find((r) => r.clientId === bare.id)! await expect(sendReminder(db, deps, rem.id, 'u1')).rejects.toThrow(/recipient/i) expect(await shareCount(db)).toBe(0) }) it('manual send mints one ~60-day share and a second send reuses it', async () => { const { db, c, m } = await world() await sentQuote(db, c, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') // d3, manual const first = (await followups(db))[0]! const out = await sendReminder(db, deps, first.id, 'u1') expect(out).toEqual({ ok: true }) expect(await shareCount(db)).toBe(1) // The sent body carries a live share link. const { ctx } = await reminderContext(db, (await getReminder(db, first.id))!, 'SiMS') expect(reminderEmail('quote_followup', ctx).bodyText).toContain('/share/') await runDailyScan(db, deps, '2026-07-08') // d7 const second = (await followups(db)).find((r) => r.duePeriod === 'd7')! expect((await sendReminder(db, deps, second.id, 'u1')).ok).toBe(true) expect(await shareCount(db)).toBe(1) // reused, not duplicated }) }) // ---------- STOP cleanup ---------- describe('quote_followup STOP cleanup', () => { async function withOpenNudge(status?: 'accepted' | 'lost') { const { db, c, m } = await world() const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') const rem = (await followups(db))[0]! expect(rem.status).toBe('queued') if (status !== undefined) await markStatus(db, 'u1', q.id, status) return { db, q, remId: rem.id } } it('markStatus accepted dismisses open nudges, each audited', async () => { const { db, remId } = await withOpenNudge('accepted') expect((await getReminder(db, remId))!.status).toBe('dismissed') const audit = (await db.get<{ n: number }>( `SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder' AND entity_id=? AND action='update'`, remId, ))! expect(audit.n).toBe(1) // per-row setReminderStatus, not a silent bulk UPDATE }) it('markStatus lost dismisses open nudges', async () => { const { db, remId } = await withOpenNudge('lost') expect((await getReminder(db, remId))!.status).toBe('dismissed') }) it('convertDocument dismisses the quote’s open nudges in the same transaction', async () => { const { db, q, remId } = await withOpenNudge() await convertDocument(db, 'u1', q.id, 'PROFORMA') expect((await getReminder(db, remId))!.status).toBe('dismissed') }) it('cancelDocument dismisses the quotation’s open nudges — dead paper is not chased', async () => { const { db, q, remId } = await withOpenNudge() await issueDocument(db, 'u1', q.id) // only issued documents can cancel await cancelDocument(db, 'u1', q.id) expect((await getReminder(db, remId))!.status).toBe('dismissed') }) it('leaves already-sent follow-ups untouched', async () => { const { db, q, remId } = await withOpenNudge() await sendReminder(db, deps, remId, 'u1') await markStatus(db, 'u1', q.id, 'accepted') expect((await getReminder(db, remId))!.status).toBe('sent') // history is history }) }) // ---------- queue: labelling, owner filtering, pagination ---------- describe('listQueue — quote_followup labelling, owner scope, pagination', () => { it('labels quote follow-ups and derives the owner from document.created_by', async () => { const { db, c, m } = await world() await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a') await runDailyScan(db, deps, '2026-07-04') const page = await listQueue(db) expect(page.total).toBe(1) expect(page.rows[0]!.label).toBe('Quote follow-up (d3)') expect(page.rows[0]!.ownerId).toBe('staff-a') expect(page.rows[0]!.clientName).toBe('Acme') }) it('staff see only their own rows; managerial viewers see everything', async () => { const { db, c, m } = await world() const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a') await sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'staff-b') await runDailyScan(db, deps, '2026-07-04') const a = await listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' }) expect(a.total).toBe(1) expect(a.rows[0]!.ownerId).toBe('staff-a') const boss = await listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' }) expect(boss.total).toBe(2) // A staff request cannot widen its scope via ownerId. const sneaky = await listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a', ownerId: 'staff-b' }) expect(sneaky.total).toBe(1) expect(sneaky.rows[0]!.ownerId).toBe('staff-a') }) it('paginates with an honest total', async () => { const { db, c, m } = await world() const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) await sentQuote(db, c, m, '2026-07-01T09:00:00Z') await sentQuote(db, c2, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') const p1 = await listQueue(db, { page: 1, pageSize: 1 }) const p2 = await listQueue(db, { page: 2, pageSize: 1 }) expect(p1.total).toBe(2); expect(p2.total).toBe(2) expect(p1.rows).toHaveLength(1); expect(p2.rows).toHaveLength(1) expect(p1.rows[0]!.id).not.toBe(p2.rows[0]!.id) }) it('doc-less reminders (no derived owner) stay visible to staff viewers and their counts', async () => { const { db, c, m } = await world() await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-b') // someone else's quote nudge await runDailyScan(db, deps, '2026-07-04') await upsertReminder(db, { ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01', clientId: c.id, now: '2026-07-04T00:00:00Z', }) const a = await listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' }) expect(a.total).toBe(1) // the shared renewal — NOT staff-b's quote nudge expect(a.rows[0]!.ruleKind).toBe('renewal_due') expect(await queueCounts(db, 'staff-a')).toEqual({ queued: 1, failed: 0 }) const boss = await listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' }) expect(boss.total).toBe(2) }) }) // ---------- GET /reminders — ?status= narrows the SAME scoped, paginated view ---------- describe('GET /reminders?status=', () => { async function setup() { const { db, c, m } = await world() await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) const staff = await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' }) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const server = app.listen(0) const base = `http://localhost:${(server.address() as { port: number }).port}/api` return { db, c, m, staff, server, base } } let ctx: Awaited> beforeAll(async () => { ctx = await setup() }) afterAll(() => ctx.server.close()) const tokenOf = async (email: string, password: string) => (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), })).json() as { token: string }).token const get = async (token: string, qs: string) => { const res = await fetch(`${ctx.base}/reminders${qs}`, { headers: { authorization: `Bearer ${token}` } }) return { status: res.status, json: await res.json() as any } } it('is owner-scoped and paginated for staff — no unscoped flat list one query-param away', async () => { const c2 = await createClient(ctx.db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) const mine = await sentQuote(ctx.db, ctx.c, ctx.m, '2026-07-01T09:00:00Z', ctx.staff.id) await sentQuote(ctx.db, c2, ctx.m, '2026-07-01T09:00:00Z', 'someone-else') await runDailyScan(ctx.db, deps, '2026-07-04') const staffTok = await tokenOf('staff@test.in', 'staff-password') const own = await get(staffTok, '?status=queued') expect(own.status).toBe(200) expect(own.json.total).toBe(1) // honest scoped total, usable for the badge expect(own.json.reminders).toHaveLength(1) expect(own.json.reminders[0].docId).toBe(mine.id) const ownerTok = await tokenOf('owner@test.in', 'owner-password') const all = await get(ownerTok, '?status=queued') expect(all.json.total).toBe(2) const paged = await get(ownerTok, '?status=queued&pageSize=1&page=2') expect(paged.json.reminders).toHaveLength(1) expect(paged.json.total).toBe(2) expect((await get(ownerTok, '?status=failed')).json.total).toBe(0) }) })