|
|
// 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)
|
|
|
})
|
|
|
})
|