// apps/hq/test/quote-followup.test.ts — Phase 6: escalating quote follow-up (spec §7) import { describe, it, expect } from 'vitest' import { openDb, rebuildReminderRuleKindCheck, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createClient, type Client } from '../src/repos-clients' import { createModule, setPrice, type Module } from '../src/repos-modules' import { convertDocument, createDraft, issueDocument, markStatus, type Doc } from '../src/repos-documents' import { saveAccount } from '../src/repos-email' import { encrypt } from '../src/crypto' import { getReminder, listQueue, listReminders, 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': 'Tecnostac' }), now: () => '2026-07-10T09:00:00Z', } function world() { const db = openDb(':memory:'); seedIfEmpty(db) saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] }) const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' }) 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. */ function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Doc { const q = createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) markStatus(db, by, q.id, 'sent') db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`).run(sentAt, q.id) return q } const followups = (db: DB) => listReminders(db, { ruleKind: 'quote_followup' }) const shareCount = (db: DB): number => (db.prepare(`SELECT COUNT(*) AS n FROM document_share`).get() as { n: number }).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', () => { const db = openDb(':memory:') const up = 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', () => { const db = openDb(':memory:') db.exec(`DROP TABLE reminder`) 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 } = world() const q = 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 = 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 } = world() 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 = 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 } = world() createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) const q = sentQuote(db, c, m, '2026-06-01T09:00:00Z') markStatus(db, 'u1', q.id, 'accepted') 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 } = world() setSetting(db, 'u1', 'quote.followup.policy', 'auto') 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 = followups(db)[0]! expect(row.policyApplied).toBe('auto') expect(row.status).toBe('sent') expect(shareCount(db)).toBe(1) const share = db.prepare(`SELECT expires_at FROM document_share`).get() as { expires_at: string | null } 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(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 } = world() sentQuote(db, c, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') const rem = followups(db)[0]! const auditBefore = (db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n const { ctx } = reminderContext(db, rem, 'Tecnostac') const mail = reminderEmail('quote_followup', ctx) expect(mail.subject).toContain('Tecnostac') expect(shareCount(db)).toBe(0) // resolve-only: preview NEVER mints expect((db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n).toBe(auditBefore) }) it('degrades gracefully when the quote has no docNo — never renders "quotation null"', async () => { const { db, c, m } = world() const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') // never issued: docNo is null (F10) await runDailyScan(db, deps, '2026-07-04') const { ctx } = reminderContext(db, followups(db)[0]!, 'Tecnostac') 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 } = world() const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') const issued = issueDocument(db, 'u1', q.id) await runDailyScan(db, deps, '2026-07-04') const { ctx } = reminderContext(db, followups(db)[0]!, 'Tecnostac') expect(reminderEmail('quote_followup', ctx).subject).toContain(issued.docNo!) }) it('manual send mints one ~60-day share and a second send reuses it', async () => { const { db, c, m } = world() sentQuote(db, c, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') // d3, manual const first = followups(db)[0]! const out = await sendReminder(db, deps, first.id, 'u1') expect(out).toEqual({ ok: true }) expect(shareCount(db)).toBe(1) // The sent body carries a live share link. const { ctx } = reminderContext(db, getReminder(db, first.id)!, 'Tecnostac') expect(reminderEmail('quote_followup', ctx).bodyText).toContain('/share/') await runDailyScan(db, deps, '2026-07-08') // d7 const second = followups(db).find((r) => r.duePeriod === 'd7')! expect((await sendReminder(db, deps, second.id, 'u1')).ok).toBe(true) expect(shareCount(db)).toBe(1) // reused, not duplicated }) }) // ---------- STOP cleanup ---------- describe('quote_followup STOP cleanup', () => { async function withOpenNudge(status?: 'accepted' | 'lost') { const { db, c, m } = world() const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') const rem = followups(db)[0]! expect(rem.status).toBe('queued') if (status !== undefined) 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(getReminder(db, remId)!.status).toBe('dismissed') const audit = db.prepare( `SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder' AND entity_id=? AND action='update'`, ).get(remId) as { n: number } 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(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() convertDocument(db, 'u1', q.id, 'PROFORMA') expect(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') markStatus(db, 'u1', q.id, 'accepted') expect(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 } = world() sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a') await runDailyScan(db, deps, '2026-07-04') const page = 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 } = world() const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a') sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'staff-b') await runDailyScan(db, deps, '2026-07-04') const a = listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' }) expect(a.total).toBe(1) expect(a.rows[0]!.ownerId).toBe('staff-a') const boss = listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' }) expect(boss.total).toBe(2) // A staff request cannot widen its scope via ownerId. const sneaky = 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 } = world() const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) sentQuote(db, c, m, '2026-07-01T09:00:00Z') sentQuote(db, c2, m, '2026-07-01T09:00:00Z') await runDailyScan(db, deps, '2026-07-04') const p1 = listQueue(db, { page: 1, pageSize: 1 }) const p2 = 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) }) })