fix(funnel): review fixes — stop-chase on convert/cancel/lost, scoped ?status=, send guards, F3/F4 convert hardening

Review findings on the quote-to-close slice, each verified against the code:

- convertDocument: ANY conversion now flips the source to 'invoiced' (previously
  only ->INVOICE), so a QT->PI quote leaves the daily scan's 'sent' set and the
  pipeline's Quoted/Waiting — the chase never resumes after convert (spec 7 stop
  conditions). Scan additionally excludes quotes with a live forward child
  (covers rows converted before this fix) and quotes of lost clients.
- convertDocument rule-4 guard (spec 8 F4): a live non-cancelled forward child
  blocks re-convert — one sale, one legal document; cancelling the child
  re-opens the path. Rule-2 fix (spec 8 F3): ->INVOICE recomputes the carried
  lines through computeBill on the invoice's own doc_date (mirrors
  createCreditNote); QT->PI stays copy-only (both non-legal).
- cancelDocument dismisses a cancelled quotation's open follow-up nudges in the
  same transaction, per-row audited — dead paper is not chased.
- GET /reminders?status= no longer bypasses the owner scope and pagination: the
  status param narrows the same scoped listQueue view. Web badge (Layout) now
  uses the honest scoped total instead of an unscoped flat list length.
- listQueue/queueCounts staff scope keeps doc-less reminders (renewal_due,
  amc_expiring, follow_up, email_bounced) visible — they have no derived owner
  and are shared work; previously they vanished for every staff viewer.
- sendReminder resolves the recipient BEFORE minting a public share (a send
  that can never succeed leaves no live artifact) and refuses quote_followup
  sends loudly when share.base_url is unset instead of emailing a dead relative
  link. The scheduler auto-drain parks a hard-failing send as 'failed' with its
  reason instead of crashing the scan.
- reminderContext takes an injectable today (threaded from deps.now in the send
  path) so dated schedule text resolves deterministically.
- Pipeline: quote-less lost clients (client.status=lost) now appear under
  filter=lost (spec 9); Dashboard queue table marks first-page truncation
  honestly (rule 8).

Typecheck clean; 270 tests green (255 before, +15 covering each fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 062ddd0737
commit 400abc224f

@ -30,10 +30,12 @@ export function Layout() {
}, [])
// Reminder-queue badge (Dashboard nav item) — refreshed on every route change.
// Uses the honest scoped `total` (never rows.length): staff badge counts match
// their own queue, and pagination cannot under-count.
useEffect(() => {
if (!hasSession()) return
Promise.all([getReminders('queued'), getReminders('failed')])
.then(([q, f]) => setQueueCounts({ queued: q.length, failed: f.length }))
.then(([q, f]) => setQueueCounts({ queued: q.total, failed: f.total }))
.catch(() => { /* badge is best-effort */ })
}, [location.pathname])

@ -333,8 +333,10 @@ export interface DashboardView {
export const getDashboard = (): Promise<DashboardView> =>
apiFetch<{ view: DashboardView }>('/dashboard').then((r) => r.view)
export const getReminders = (status?: ReminderStatus): Promise<Reminder[]> =>
apiFetch<{ reminders: Reminder[] }>(`/reminders${status !== undefined ? `?status=${status}` : ''}`).then((r) => r.reminders)
/** Viewer-scoped, paginated queue slice + honest total (staff see only their rows). */
export const getReminders = (status?: ReminderStatus): Promise<{ reminders: Reminder[]; total: number }> =>
apiFetch<{ reminders: Reminder[]; total: number }>(`/reminders${status !== undefined ? `?status=${status}` : ''}`)
.then((r) => ({ reminders: r.reminders, total: r.total }))
export const sendReminder = (id: string): Promise<Reminder> =>
apiFetch<{ reminder: Reminder }>(`/reminders/${id}/send`, { method: 'POST', body: '{}' }).then((r) => r.reminder)
export const dismissReminder = (id: string): Promise<Reminder> =>

@ -59,6 +59,12 @@ export function Dashboard() {
}))}
/>
)}
{v.queueTotal > v.queue.length && (
// No silent caps (rule 8): the table is the first page only — say so.
<p style={{ marginTop: 6, fontSize: 13, color: 'var(--text-muted, #666)' }}>
Showing {v.queue.length} of {v.queueTotal} handle these to surface the rest.
</p>
)}
<Section title="Overdue" empty="No overdue invoices." rows={v.overdue}
columns={[{ key: 'no', label: 'Invoice' }, { key: 'client', label: 'Client' }, { key: 'days', label: 'Days', numeric: true }, { key: 'due', label: 'Outstanding', numeric: true }]}

@ -39,7 +39,7 @@ import {
listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch,
} from './repos-interactions'
import {
dismissReminder, getReminder, listQueue, listReminders, setSetting,
dismissReminder, getReminder, listQueue, setSetting,
type QueueOpts, type ReminderStatus,
} from './repos-reminders'
import { sendReminder, reminderContext, type SendReminderDeps } from './send-reminder'
@ -649,13 +649,15 @@ export function apiRouter(
// ---------- reminders (manual queue) ----------
// Queue view is paginated with an honest total (rule 8) and owner-scoped: staff are
// server-forced to their own rows (derived doc_id → document.created_by, spec A3);
// owner/manager see all and may narrow via ?owner=. `?status=` keeps the flat list.
// server-forced to their own rows (derived doc_id → document.created_by, spec A3;
// doc-less rows are shared work, visible to all); owner/manager see all and may
// narrow via ?owner=. `?status=` narrows the SAME scoped, paginated view.
r.get('/reminders', requireAuth, (req, res) => {
const status = typeof req.query['status'] === 'string' ? req.query['status'] as ReminderStatus : undefined
if (status !== undefined) { res.json({ ok: true, reminders: listReminders(db, { status }) }); return }
const viewer = res.locals['staff'] as { id: string; role: string }
const opts: QueueOpts = { viewerRole: viewer.role, viewerId: viewer.id }
if (typeof req.query['status'] === 'string' && req.query['status'] !== '') {
opts.status = req.query['status'] as ReminderStatus
}
if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner']
const page = Number(req.query['page'])
if (Number.isInteger(page) && page >= 1) opts.page = page

@ -308,7 +308,11 @@ export function markStatus(db: DB, userId: string, docId: string, status: 'sent'
const CHAIN: Record<string, number> = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 }
/** New draft carrying the source's lines/totals verbatim, linked via ref_doc_id. */
/**
* New draft carrying the source's lines forward, linked via ref_doc_id.
* QTPI copies totals verbatim (both non-legal); INVOICE recomputes through
* computeBill on the invoice's own doc_date (spec §8 F3, hard rule 2).
*/
export function convertDocument(db: DB, userId: string, docId: string, to: 'PROFORMA' | 'INVOICE'): Doc {
return db.transaction(() => {
const src = getDocument(db, docId)
@ -319,15 +323,47 @@ export function convertDocument(db: DB, userId: string, docId: string, to: 'PROF
throw new Error(`Cannot convert ${src.docType} to ${to}`)
}
if (src.status === 'cancelled') throw new Error('Cannot convert a cancelled document')
// Rule-4 guard (spec §8 F4): one sale, one forward document. A live (non-cancelled)
// child means this document was already converted — a second convert would mint a
// duplicate for the same sale. Cancelling the child re-opens the path.
const child = db.prepare(
`SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`,
).get(src.id) as { doc_type: string } | undefined
if (child !== undefined) {
throw new Error(`Already converted: a live ${child.doc_type} exists for this document`)
}
const docDate = todayIso()
let totals = src.payload.totals
let payload = src.payload
if (to === 'INVOICE') {
// Rule-2 fix (spec §8 F3): an invoice is legal paper — recompute the carried
// lines on the invoice's date (mirrors createCreditNote), so a dated tax_class
// change between quote/proforma and invoice lands at the rate that is law today.
const client = getClient(db, src.clientId)
if (client === null) throw new Error('Client not found')
const computed = computeBill(
src.payload.lines.map((l): LineInput => ({
itemId: l.itemId, name: l.name, hsn: l.hsn, qty: l.qty, unitCode: l.unitCode,
unitPricePaise: l.unitPricePaise, priceIncludesTax: false, taxClassCode: TAX_CLASS,
})),
{
businessDate: docDate,
supplyStateCode: supplyStateCode(db),
placeOfSupplyStateCode: client.stateCode,
roundToRupee: true,
}, taxRates(db))
totals = computed.totals
payload = { ...src.payload, lines: computed.lines, totals: computed.totals }
}
const doc = insertDocRow(db, userId, {
docType: to, clientId: src.clientId, refDocId: src.id,
docDate: todayIso(), totals: src.payload.totals, payload: src.payload,
docDate, totals, payload,
})
addEvent(db, src.id, 'converted', { to, newDocId: doc.id })
if (to === 'INVOICE') {
db.prepare(`UPDATE document SET status='invoiced' WHERE id=?`).run(src.id)
writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' })
}
// ANY conversion moves the source forward — out of the 'sent' set the follow-up
// scan and the pipeline read, so a converted quote is never chased again (spec §7).
db.prepare(`UPDATE document SET status='invoiced' WHERE id=?`).run(src.id)
writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' })
if (src.docType === 'QUOTATION') {
// STOP cleanup (spec §7): a converted quote has moved forward — dismiss its
// open follow-up nudges in this same transaction, one audited row each.
@ -351,6 +387,11 @@ export function cancelDocument(db: DB, userId: string, docId: string): Doc {
db.prepare(`UPDATE document SET status='cancelled' WHERE id=?`).run(docId)
addEvent(db, docId, 'cancelled')
writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' })
if (before.docType === 'QUOTATION') {
// STOP cleanup (spec §7): cancelled paper is dead — its open follow-up nudges
// must not chase the client. Same transaction, one audited row each.
dismissQuoteFollowups(db, userId, docId)
}
return getDocument(db, docId)!
})()
}

@ -59,7 +59,10 @@ interface QuoteRow {
client_id: string; client_code: string; client_name: string; client_status: string
}
interface LeadRow { client_id: string; client_code: string; client_name: string; owner_id: string | null }
interface LeadRow {
client_id: string; client_code: string; client_name: string
client_status: string; owner_id: string | null
}
const DEFAULT_PAGE_SIZE = 50
const MAX_PAGE_SIZE = 200
@ -97,10 +100,13 @@ export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage {
).all() as QuoteRow[]
// Bare leads: client.status='lead' with no live quotation → derived stage Enquiry.
// Quote-less LOST clients ride the same arm as stage Lost (spec §9: Lost = latest
// quotation lost OR client.status='lost') so the Lost filter can review them.
const leads = db.prepare(
`SELECT c.id AS client_id, c.code AS client_code, c.name AS client_name, c.owner_id
`SELECT c.id AS client_id, c.code AS client_code, c.name AS client_name,
c.status AS client_status, c.owner_id
FROM client c
WHERE c.status = 'lead'
WHERE c.status IN ('lead', 'lost')
AND NOT EXISTS (SELECT 1 FROM document d
WHERE d.client_id = c.id AND d.doc_type = 'QUOTATION'
AND d.status != 'cancelled')`,
@ -138,11 +144,13 @@ export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage {
})
}
for (const l of leads) {
const lost = l.client_status === 'lost'
rows.push({
clientId: l.client_id, clientCode: l.client_code, clientName: l.client_name,
docId: null, docNo: null, docStatus: null, amountPaise: null,
ownerId: l.owner_id, ownerName: nameOf(l.owner_id),
stage: 'enquiry', ageDays: null, nextAction: 'send_quote', band: null,
stage: lost ? 'lost' : 'enquiry', ageDays: null,
nextAction: lost ? 'none' : 'send_quote', band: null,
})
}

@ -102,6 +102,8 @@ export interface QueueOpts {
viewerRole?: string; viewerId?: string
/** Narrow to one owner — managerial viewers only (ignored for staff via ownerScope). */
ownerId?: string
/** Narrow to one status; default is the working set queued|failed. */
status?: ReminderStatus
page?: number; pageSize?: number
}
@ -123,7 +125,11 @@ export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
: opts.ownerId
let where = `r.status IN ('queued','failed')`
const args: unknown[] = []
if (scoped !== undefined) { where += ` AND d.created_by = ?`; args.push(scoped) }
if (opts.status !== undefined) { where = `r.status = ?`; args.push(opts.status) }
// Owner scope derives via doc_id → document.created_by. Rows with NO document
// (renewal_due, amc_expiring, follow_up, email_bounced) have no derived owner —
// they are shared work and stay visible to every viewer, staff included.
if (scoped !== undefined) { where += ` AND (d.created_by = ? OR r.doc_id IS NULL)`; args.push(scoped) }
const total = (db.prepare(
`SELECT COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id WHERE ${where}`,
).get(...args) as { n: number }).n
@ -154,7 +160,7 @@ export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
export function queueCounts(db: DB, ownerId?: string): { queued: number; failed: number } {
const rows = db.prepare(
`SELECT r.status, COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id
WHERE r.status IN ('queued','failed')${ownerId !== undefined ? ` AND d.created_by = ?` : ''}
WHERE r.status IN ('queued','failed')${ownerId !== undefined ? ` AND (d.created_by = ? OR r.doc_id IS NULL)` : ''}
GROUP BY r.status`,
).all(...(ownerId !== undefined ? [ownerId] : [])) as { status: string; n: number }[]
const by = new Map(rows.map((r) => [r.status, r.n]))

@ -4,7 +4,7 @@ import { createDraft, issueDocument } from './repos-documents'
import { getClientModule } from './repos-modules'
import { outstandingPaise } from './repos-payments'
import { CADENCE_KIND, getRecurringPlan } from './repos-recurring'
import { getNumberSetting, getSetting, resolveSchedule, upsertReminder } from './repos-reminders'
import { getNumberSetting, getSetting, resolveSchedule, setReminderStatus, upsertReminder } from './repos-reminders'
import { sendReminder, type SendReminderDeps } from './send-reminder'
/**
@ -161,11 +161,18 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
const followupSchedule = resolveSchedule(db, 'quote_followup', today)
const followupPolicy: 'auto' | 'manual' =
getSetting(db, 'quote.followup.policy') === 'auto' ? 'auto' : 'manual'
// A lost client is dead pipeline — never chase (their queued rows can be dismissed
// by hand). NOT EXISTS covers quotes converted before status-flip-on-convert landed:
// a live forward child means the sale moved on, whatever the quote row still says.
const sentQuotes = db.prepare(
`SELECT d.id, d.client_id,
(SELECT MIN(e.at_wall) FROM document_event e
WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent
FROM document d WHERE d.doc_type='QUOTATION' AND d.status='sent'`,
FROM document d JOIN client c ON c.id = d.client_id
WHERE d.doc_type='QUOTATION' AND d.status='sent'
AND c.status != 'lost'
AND NOT EXISTS (SELECT 1 FROM document ch
WHERE ch.ref_doc_id = d.id AND ch.status != 'cancelled')`,
).all() as { id: string; client_id: string; first_sent: string | null }[]
const followupAuto: string[] = []
for (const q of sentQuotes) {
@ -195,9 +202,18 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
for (const reminderId of autoQueue) {
// Send outside the generation transaction: a failed send never rolls back the
// (already-issued) invoice or the advanced schedule — it just parks the reminder.
const out = await sendReminder(db, deps, reminderId, 'system')
if (out.ok) autoSent += 1
else autoFailed += 1
try {
const out = await sendReminder(db, deps, reminderId, 'system')
if (out.ok) autoSent += 1
else autoFailed += 1
} catch (err) {
// A hard guard (no recipient email, share.base_url unset) must not kill the
// drain: park THIS reminder as failed — loudly, with the reason — and keep going.
setReminderStatus(db, 'system', reminderId, 'failed', {
error: err instanceof Error ? err.message : String(err),
})
autoFailed += 1
}
}
return { today, created, autoSent, autoFailed }

@ -32,7 +32,7 @@ function shareUrlFor(db: DB, token: string): string {
return `${base !== null ? base.replace(/\/+$/, '') : ''}/share/${token}`
}
export function reminderContext(db: DB, reminder: Reminder, companyName: string): ReminderRender {
export function reminderContext(db: DB, reminder: Reminder, companyName: string, today?: string): ReminderRender {
const client = getClient(db, reminder.clientId)
if (client === null) throw new Error('Client not found')
const ctx: ReminderContext = { clientName: client.name, companyName }
@ -44,8 +44,10 @@ export function reminderContext(db: DB, reminder: Reminder, companyName: string)
ctx.amountPaise = doc.payablePaise
ctx.period = reminder.duePeriod
if (reminder.ruleKind === 'quote_followup') {
const today = new Date().toISOString().slice(0, 10)
const schedule = resolveSchedule(db, 'quote_followup', today) // dated message text (rule 3)
// `today` is injectable (deps.now via sendReminder) so the send path resolves the
// SAME dated row as the scan that enqueued it — wall clock only as a fallback.
const onDate = today ?? new Date().toISOString().slice(0, 10)
const schedule = resolveSchedule(db, 'quote_followup', onDate) // dated message text (rule 3)
ctx.subjectTemplate = schedule.subject ?? undefined
ctx.bodyTemplate = schedule.body ?? undefined
// Quotes reach 'sent' without issuance (F10) — never render "quotation null".
@ -77,14 +79,26 @@ export async function sendReminder(
}
const company = deps.company()
const companyName = company['company.name'] ?? ''
const now = deps.now?.() ?? new Date().toISOString()
// Resolve the recipient BEFORE any write: a send that can never succeed must not
// leave a live public share behind as a side effect (rule 6).
const recipient = getClient(db, reminder.clientId)
if (recipient === null) throw new Error('Client not found')
const to = recipient.contacts.find((c) => c.email !== undefined && c.email !== '')?.email
if (to === undefined) throw new Error('No recipient: add a contact email to the client')
if (reminder.ruleKind === 'quote_followup' && reminder.docId !== null) {
// The share link IS the point of this mail (spec §7): with no public base URL the
// body would carry a dead relative path — refuse loudly instead of emailing it.
if (getSetting(db, 'share.base_url') === null) {
throw new Error(`Setting 'share.base_url' is not configured — cannot email a usable share link`)
}
// The ONLY place a follow-up share is minted (rule 6 / F5): reuse a live link,
// else mint with an expiry covering the escalation window — never never-expiring (F12).
if (resolveLiveShare(db, reminder.docId) === null) {
mintShare(db, userId, reminder.docId, { expiresDays: 60 })
}
}
const { ctx, doc, client } = reminderContext(db, reminder, companyName)
const { ctx, doc, client } = reminderContext(db, reminder, companyName, now.slice(0, 10))
let attachment: { filename: string; data: Buffer } | undefined
let documentId: string | undefined
@ -94,16 +108,12 @@ export async function sendReminder(
attachment = { filename: `${(doc.docNo ?? 'draft').replaceAll('/', '-')}.pdf`, data: pdf }
}
const to = client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email
if (to === undefined) throw new Error('No recipient: add a contact email to the client')
const mail = reminderEmail(reminder.ruleKind, ctx)
const out = await sendReminderEmail(db, deps.gmail, {
to, subject: mail.subject, bodyText: mail.bodyText,
...(attachment !== undefined ? { attachment } : {}),
...(documentId !== undefined ? { documentId } : {}),
})
const now = deps.now?.() ?? new Date().toISOString()
if (out.ok) setReminderStatus(db, userId, reminderId, 'sent', { sentAt: now, error: null })
else setReminderStatus(db, userId, reminderId, 'failed', { error: out.error })
return out

@ -3,7 +3,9 @@ import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument, convertDocument, createCreditNote, cancelDocument } from '../src/repos-documents'
import {
createDraft, issueDocument, convertDocument, createCreditNote, cancelDocument, getDocument,
} from '../src/repos-documents'
function setup() {
const db = openDb(':memory:')
@ -80,6 +82,50 @@ describe('documents', () => {
expect(std.payload.lines[0]!.name).not.toContain('—')
})
it('convert is one-shot: a live forward child blocks re-convert; cancelling it re-opens (rule 4 / F4)', () => {
const { db, c, m } = setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const inv = convertDocument(db, 'u1', qt.id, 'INVOICE')
// one sale, one invoice — a double-click cannot mint a second legal document
expect(() => convertDocument(db, 'u1', qt.id, 'INVOICE')).toThrow(/already converted/i)
expect(() => convertDocument(db, 'u1', qt.id, 'PROFORMA')).toThrow(/already converted/i)
// a cancelled child is dead paper: the path re-opens
issueDocument(db, 'u1', inv.id)
cancelDocument(db, 'u1', inv.id)
const inv2 = convertDocument(db, 'u1', qt.id, 'INVOICE')
expect(inv2.refDocId).toBe(qt.id)
})
it('QT→PROFORMA moves the quotation out of the sent set (status → invoiced)', () => {
const { db, c, m } = setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const pi = convertDocument(db, 'u1', qt.id, 'PROFORMA')
expect(pi.docType).toBe('PROFORMA')
expect(pi.status).toBe('draft')
expect(getDocument(db, qt.id)!.status).toBe('invoiced') // never re-enters scan/pipeline 'sent' sets
})
it('→INVOICE recomputes GST on the invoice date (rule 2 / F3); QT→PI copies verbatim', () => {
const { db, c, m } = setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) // 18% → ₹11,800
const pi = convertDocument(db, 'u1', qt.id, 'PROFORMA')
expect(pi.payablePaise).toBe(11_800_00) // copy-only: both are non-legal paper
// The rate changes (new dated tax_class row) before the invoice is drawn.
const today = new Date().toISOString().slice(0, 10)
db.prepare(
`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 2800, ?)`,
).run(today)
const inv = convertDocument(db, 'u1', pi.id, 'INVOICE')
expect(inv.taxablePaise).toBe(10_000_00)
expect(inv.cgstPaise).toBe(1_400_00)
expect(inv.sgstPaise).toBe(1_400_00)
expect(inv.payablePaise).toBe(12_800_00) // the rate that is law on the invoice's own date
expect(getDocument(db, pi.id)!.payablePaise).toBe(11_800_00) // source untouched
})
it('credit note defaults to full value against the invoice; cancel keeps the number', () => {
const { db, c, m } = setup()
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id,

@ -115,6 +115,27 @@ describe('listPipeline — stage derivation (spec §9)', () => {
expect(lost.rows.map((r) => r.stage)).toEqual(['lost', 'lost'])
expect(lost.total).toBe(2)
})
it('a quote-less LOST client is reviewable under filter=lost (spec §9), hidden otherwise', () => {
const { db } = setup()
createClient(db, 'u1', { name: 'Quote-less Lost Co', stateCode: '32', status: 'lost' })
expect(listPipeline(db, { ...OWNER_VIEW }).total).toBe(0)
const lost = listPipeline(db, { ...OWNER_VIEW, filter: 'lost' })
expect(lost.total).toBe(1)
expect(lost.rows[0]).toMatchObject({
clientName: 'Quote-less Lost Co', stage: 'lost', nextAction: 'none', docId: null,
})
})
it('QT→PI conversion moves the client out of Quoted/Waiting — no stuck chase row', () => {
const { db, moduleId } = setup()
const c = createClient(db, 'u1', { name: 'Converted Co', stateCode: '32' })
const q = quote(db, c.id, moduleId, { sentAgeDays: 10 })
convertDocument(db, 'u1', q.id, 'PROFORMA')
const rows = listPipeline(db, { ...OWNER_VIEW }).rows.filter((r) => r.clientId === c.id)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ stage: 'won', nextAction: 'none' }) // moved forward, no chase buttons
})
})
describe('listPipeline — next action + band from resolveSchedule (no hardcoded 3/7/14)', () => {

@ -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', { moduleId: 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 quotations 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)
})
})

Loading…
Cancel
Save