@ -1,14 +1,19 @@
// apps/hq/test/quote-followup.test.ts — Phase 6: escalating quote follow-up (spec §7)
import { describe , it , expect } from 'vitest'
import express from 'express'
import { describe , it , expect , 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 { convertDocument , createDraft , issueDocument , markStatus , type Doc } from '../src/repos-documents'
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 , setSetting, upsertReminder ,
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'
@ -27,6 +32,7 @@ const deps: ScanDeps & SendReminderDeps = {
function world() {
const db = openDb ( ':memory:' ) ; seedIfEmpty ( db )
saveAccount ( db , 'us@tecnostac.com' , encrypt ( 'refresh-token' , KEY ) )
setSetting ( db , 'u1' , 'share.base_url' , 'https://hq.example.in' ) // sends refuse a dead relative link without it
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' , { module Id : m . id , kind : 'yearly' , pricePaise : 10_000_00 , effectiveFrom : '2026-01-01' } )
@ -135,6 +141,31 @@ describe('runDailyScan — quote_followup escalation', () => {
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 } = world ( )
const q = sentQuote ( db , c , m , '2026-07-01T09:00:00Z' )
await runDailyScan ( db , deps , '2026-07-04' ) // d3 queued
convertDocument ( db , 'u1' , q . id , 'PROFORMA' )
expect ( 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 ( 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 } = world ( )
const q = sentQuote ( db , c , m , '2026-07-01T09:00:00Z' )
convertDocument ( db , 'u1' , q . id , 'PROFORMA' )
db . prepare ( ` UPDATE document SET status='sent' WHERE id=? ` ) . run ( 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 } = world ( )
sentQuote ( db , c , m , '2026-06-01T09:00:00Z' )
db . prepare ( ` UPDATE client SET status='lost' WHERE id=? ` ) . run ( 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 } = world ( )
setSetting ( db , 'u1' , 'quote.followup.policy' , 'auto' )
@ -195,6 +226,53 @@ describe('quote_followup context, preview and send', () => {
const { ctx } = reminderContext ( db , followups ( db ) [ 0 ] ! , 'Tecnostac' )
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 } = world ( )
// A dated row that takes over on 2026-07-11: resolving on 07-10 must not see it.
db . prepare (
` 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}' ) ` ,
) . run ( )
sentQuote ( db , c , m , '2026-07-01T09:00:00Z' )
await runDailyScan ( db , deps , '2026-07-04' )
const rem = followups ( db ) [ 0 ] !
const before = reminderContext ( db , rem , 'Tecnostac' , '2026-07-10' )
expect ( reminderEmail ( 'quote_followup' , before . ctx ) . subject ) . not . toContain ( 'FUTURE' )
const after = reminderContext ( db , rem , 'Tecnostac' , '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 } = world ( )
db . prepare ( ` DELETE FROM setting WHERE key='share.base_url' ` ) . run ( )
sentQuote ( db , c , m , '2026-07-01T09:00:00Z' )
await runDailyScan ( db , deps , '2026-07-04' )
const rem = followups ( db ) [ 0 ] !
await expect ( sendReminder ( db , deps , rem . id , 'u1' ) ) . rejects . toThrow ( /share\.base_url/ )
expect ( shareCount ( db ) ) . toBe ( 0 ) // nothing public left behind by the refused send
expect ( 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 } = world ( )
db . prepare ( ` DELETE FROM setting WHERE key='share.base_url' ` ) . run ( )
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' )
expect ( res . created [ 'quote_followup' ] ) . toBe ( 1 )
expect ( res . autoFailed ) . toBe ( 1 )
const row = followups ( db ) [ 0 ] !
expect ( row . status ) . toBe ( 'failed' )
expect ( row . error ) . toContain ( 'share.base_url' )
expect ( shareCount ( db ) ) . toBe ( 0 )
} )
it ( 'resolves the recipient BEFORE minting — a client without email leaves no share behind' , async ( ) = > {
const { db , m } = world ( )
const bare = createClient ( db , 'u1' , { name : 'NoMail Co' , stateCode : '32' } )
sentQuote ( db , bare , m , '2026-07-01T09:00:00Z' )
await runDailyScan ( db , deps , '2026-07-04' )
const rem = followups ( db ) . find ( ( r ) = > r . clientId === bare . id ) !
await expect ( sendReminder ( db , deps , rem . id , 'u1' ) ) . rejects . toThrow ( /recipient/i )
expect ( shareCount ( db ) ) . toBe ( 0 )
} )
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' )
@ -242,6 +320,12 @@ describe('quote_followup STOP cleanup', () => {
convertDocument ( db , 'u1' , q . id , 'PROFORMA' )
expect ( 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 ( )
issueDocument ( db , 'u1' , q . id ) // only issued documents can cancel
cancelDocument ( db , 'u1' , q . id )
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' )
@ -291,4 +375,61 @@ describe('listQueue — quote_followup labelling, owner scope, pagination', () =
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 } = world ( )
sentQuote ( db , c , m , '2026-07-01T09:00:00Z' , 'staff-b' ) // someone else's quote nudge
await runDailyScan ( db , deps , '2026-07-04' )
upsertReminder ( db , {
ruleKind : 'renewal_due' , subjectId : 'cm1' , duePeriod : '2026-08-01' ,
clientId : c.id , now : '2026-07-04T00:00:00Z' ,
} )
const a = 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 ( queueCounts ( db , 'staff-a' ) ) . toEqual ( { queued : 1 , failed : 0 } )
const boss = 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=' , ( ) = > {
const { db , c , m } = world ( )
createStaff ( db , { email : 'owner@test.in' , displayName : 'Owner' , role : 'owner' , password : 'owner-password' } )
const staff = 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 `
afterAll ( ( ) = > server . close ( ) )
const tokenOf = async ( email : string , password : string ) = >
( await ( await fetch ( ` ${ 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 ( ` ${ 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 = createClient ( db , 'u1' , { name : 'Beta' , stateCode : '32' , contacts : [ { name : 'S' , email : 's@beta.in' } ] } )
const mine = sentQuote ( db , c , m , '2026-07-01T09:00:00Z' , staff . id )
sentQuote ( db , c2 , m , '2026-07-01T09:00:00Z' , 'someone-else' )
await runDailyScan ( 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 )
} )
} )