From 400abc224f2051b142cf4f7ad8dd70720e0ac55a Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 03:36:53 +0530 Subject: [PATCH] =?UTF-8?q?fix(funnel):=20review=20fixes=20=E2=80=94=20sto?= =?UTF-8?q?p-chase=20on=20convert/cancel/lost,=20scoped=20=3Fstatus=3D,=20?= =?UTF-8?q?send=20guards,=20F3/F4=20convert=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/hq-web/src/Layout.tsx | 4 +- apps/hq-web/src/api.ts | 6 +- apps/hq-web/src/pages/Dashboard.tsx | 6 ++ apps/hq/src/api.ts | 12 ++- apps/hq/src/repos-documents.ts | 53 ++++++++-- apps/hq/src/repos-pipeline.ts | 16 ++- apps/hq/src/repos-reminders.ts | 10 +- apps/hq/src/scheduler.ts | 26 ++++- apps/hq/src/send-reminder.ts | 26 +++-- apps/hq/test/documents.test.ts | 48 ++++++++- apps/hq/test/pipeline.test.ts | 21 ++++ apps/hq/test/quote-followup.test.ts | 147 +++++++++++++++++++++++++++- 12 files changed, 338 insertions(+), 37 deletions(-) diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx index 9178d94..a798374 100644 --- a/apps/hq-web/src/Layout.tsx +++ b/apps/hq-web/src/Layout.tsx @@ -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]) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 43577fb..f8181a8 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -333,8 +333,10 @@ export interface DashboardView { export const getDashboard = (): Promise => apiFetch<{ view: DashboardView }>('/dashboard').then((r) => r.view) -export const getReminders = (status?: ReminderStatus): Promise => - 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 => apiFetch<{ reminder: Reminder }>(`/reminders/${id}/send`, { method: 'POST', body: '{}' }).then((r) => r.reminder) export const dismissReminder = (id: string): Promise => diff --git a/apps/hq-web/src/pages/Dashboard.tsx b/apps/hq-web/src/pages/Dashboard.tsx index 91142c3..f38b6a2 100644 --- a/apps/hq-web/src/pages/Dashboard.tsx +++ b/apps/hq-web/src/pages/Dashboard.tsx @@ -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. +

+ Showing {v.queue.length} of {v.queueTotal} — handle these to surface the rest. +

+ )}
{ - 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 diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index 92ead15..96211bd 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -308,7 +308,11 @@ export function markStatus(db: DB, userId: string, docId: string, status: 'sent' const CHAIN: Record = { 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. + * QT→PI 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)! })() } diff --git a/apps/hq/src/repos-pipeline.ts b/apps/hq/src/repos-pipeline.ts index 1bd49bf..f251288 100644 --- a/apps/hq/src/repos-pipeline.ts +++ b/apps/hq/src/repos-pipeline.ts @@ -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, }) } diff --git a/apps/hq/src/repos-reminders.ts b/apps/hq/src/repos-reminders.ts index 613e683..f001c10 100644 --- a/apps/hq/src/repos-reminders.ts +++ b/apps/hq/src/repos-reminders.ts @@ -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])) diff --git a/apps/hq/src/scheduler.ts b/apps/hq/src/scheduler.ts index 12a91c5..2340fe3 100644 --- a/apps/hq/src/scheduler.ts +++ b/apps/hq/src/scheduler.ts @@ -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 } diff --git a/apps/hq/src/send-reminder.ts b/apps/hq/src/send-reminder.ts index 096cf5c..4e315df 100644 --- a/apps/hq/src/send-reminder.ts +++ b/apps/hq/src/send-reminder.ts @@ -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 diff --git a/apps/hq/test/documents.test.ts b/apps/hq/test/documents.test.ts index 0fd8b72..4843177 100644 --- a/apps/hq/test/documents.test.ts +++ b/apps/hq/test/documents.test.ts @@ -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, diff --git a/apps/hq/test/pipeline.test.ts b/apps/hq/test/pipeline.test.ts index b218a92..beddcd6 100644 --- a/apps/hq/test/pipeline.test.ts +++ b/apps/hq/test/pipeline.test.ts @@ -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)', () => { diff --git a/apps/hq/test/quote-followup.test.ts b/apps/hq/test/quote-followup.test.ts index 852a01e..144f9e5 100644 --- a/apps/hq/test/quote-followup.test.ts +++ b/apps/hq/test/quote-followup.test.ts @@ -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 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) + }) })