33 KiB
HQ Console — Growth Features Spec (Employee Foundation, Pipeline, Escalating Reminders, Module Roster, Convert/Supersede)
STATUS: DELIVERED 2026-07-17. All ten phases of PART B landed the same day (phases 1–6 + review fixes via the build swarm; 7–10 in the second pass), verified at 293 tests / 57 files with a live boot smoke. Delivery record: D16 in
docs/06-DECISIONS.md; current capabilities:STATUS.md.
Final merged spec. Reconciles the combined WS1–WS5 design against the simplicity, house-rules, and completeness critiques. Where critics conflicted I took the simplest option that breaks no locked decision and no hard rule. Every deviation is flagged inline.
PART A — THE SPEC
1. Overview & goals
Five capabilities, built on the existing HQ Console, in strict "reuse first / derive over store / least surface" spirit:
- Employee foundation (D-EMP, build step 1). Promote
staff_userinto a first-class Employee entity (management UI,owner|manager|staffroles, ownership + role-gated visibility). Everything else depends on it. - Pipeline chase-list (D-PIPE, option B). One cross-client ranked list of enquiries/quotes — most-overdue on top, colour-coded, explicit Next action,
All | Mine | Overdue | Ownerfilters. Stages derived, not stored. - Escalating quote follow-up (D-REMIND). A
sentquote that isn't accepted nudges the owning employee (dashboard) and emails the client (Gmail, share link) at dated intervals; stops on accept/lost/convert. - Module → client roster (D-ROSTER). Pick a banking module → every client on it, what they pay, next renewal, plus Notify all / Export CSV.
- One-click convert + easy supersede (D-CONVERT). One-click Convert proforma → invoice & send; trivially easy proforma cancel/recreate; invoices also get escalating reminder mails. Invoice immutability preserved — corrections are credit notes only.
Non-negotiable throughout: integer paise; GST only via computeBill; dated config rows; immutable issued documents; portable repos; append-only audit in-txn; client.id never re-keyed; no silent caps; strict TS + green tests before landing.
2. Assumptions, defaults, non-goals
Defaulted decisions (user did not override — flagged):
- A1 — Module roster emphasis = balanced. Support and revenue given equal weight in the roster panel (a client's pay + next renewal and a Notify-all/CSV impact action).
- A2 — Employee screen = standard table with Add / Edit / Deactivate and a role dropdown. No org chart, no per-permission matrix.
Other flagged assumptions:
- A3 — Document owner =
created_byfor v1 (simplicity critic #4, accepted). Nodocument.owner_idcolumn, no reassignment/handoff endpoint. A quote's owner is its creator; the pipeline "Mine/Owner" filter and quote-follow-up routing readcreated_by. This does not break D-EMP (every ownable thing still carries an owner). The reassignabledocument.owner_id+PATCH /documents/:id/ownerare deferred until a handoff feature is actually requested. - A4 —
client.owner_idis the one genuinely new owner column.clienthas no creator field, so an enquiry (lead with no quote) is otherwise unroutable. Additive, nullable. - A5 — Notify-all message is owner-typed ad-hoc text (subject + body entered in the panel), not dated config — it is a one-off human broadcast, not recurring automated config text, so rule 3 does not apply to it.
- A6 — Roster is visible to any signed-in employee (support view); Notify-all is owner/manager only (bulk client email is privileged).
- A7 — Invoice-overdue anchor =
doc_date. There is nodue_datecolumn andpayload.terms("Net 30") is free text; v1 escalation counts days since issue, not since a parsed due date. Known limitation, surfaced in the reminder body wording.
Non-goals / explicitly deferred: support-desk/ticketing; HR (leave, payroll, attendance); upsell/expansion engine; WhatsApp or any non-email channel; a client self-service portal; kanban board (D-PIPE is a list, option B); reassignable document ownership/handoff (A3); parsed payment-term due dates (A7); true delete of a wrong unissued draft (pre-existing no-delete gap; superseded drafts are filtered via ref_doc_id).
3. Reused vs genuinely new
Reused (no change or minor extension): staff_user table (already carries id/email/display_name/role/active + scrypt hashes — it is the employee table); @sims/auth scrypt PIN + createStaff; document lifecycle (prepareDraft/createDraft/issueDocument/markStatus/convertDocument/raiseCreditNote/cancelDocument) and ref_doc_id; @sims/billing-engine computeBill; document_event (kind='sent' → age anchor); client.status='lead' + client.notes (= enquiry); the reminder engine (scheduler.ts, upsertReminder, UNIQUE(rule_kind,subject_id,due_period), listQueue, sendReminder, reminderContext, setReminderStatus, dismissReminder); Gmail send path + email_log + bounce handling; document_share (non-revoked/non-expired reuse, expires_at semantics); priceOn + client_module (kind, edition, next_renewal); @sims/ui DataTable; existing GET /documents, POST /documents/:id/status, POST /documents/:id/convert, POST /reminders/:id/send, GET /reminders/:id/preview.
Genuinely new: reminder_schedule table (dated cadence + dated quote-follow-up message text); client.owner_id column; quote_followup rule_kind; repos-employees.ts; repos-pipeline.ts + GET /pipeline; listClientsByModule reverse query + roster endpoints; convert-and-send + supersede endpoints; the Employees and Pipeline pages + Modules roster panel + doc-view buttons + ClientDetail owner dropdown; two auth fixes.
4. Data-model changes
Each earns its place; migrations are additive or run through one shared guarded rebuild helper (see the SQLite note).
| Change | Kind | Justification (one line) |
|---|---|---|
reminder_schedule (id TEXT PK, rule_kind TEXT NOT NULL, effective_from TEXT NOT NULL, effective_to TEXT, day_offsets TEXT NOT NULL, subject TEXT, body TEXT) |
New table | The single dated home (rule 3 + locked D-REMIND) for reminder cadence and quote-follow-up message text; day_offsets = CSV like '3,7,14'; subject/body nullable (dated message text for quote_followup, code-constant fallback elsewhere). Serves both quote_followup and invoice_overdue. |
client.owner_id TEXT (nullable → staff_user.id) |
New column, additive | Account/enquiry owner so a lead with no quote (the derived "Enquiry" stage) is routable; client has no creator field, so it cannot be derived (A4). |
staff_user.role — drop the CHECK (role IN ('owner','staff')) |
Rebuild-once | Add manager; enum validated in the repo (Role = owner|manager|staff), matching how GSTIN/state are validated in code. |
reminder.rule_kind — widen CHECK to add 'quote_followup' |
Rebuild-once | New sendable reminder kind for quote follow-up. |
| Employee columns | None | Grounding confirms staff_user already has id/email/display_name/role/active + scrypt hashes — no additive employee columns are needed. The only employee DDL is the role CHECK relaxation above. |
document.owner_id |
Not added (A3) | Deferred; created_by serves as owner for v1. |
SQLite CHECK-migration note (one shared helper, used for both staff_user and reminder). SQLite cannot ALTER/DROP a CHECK in place, and migrate() is additive-only, so each affected table is rebuilt once via the standard sequence, wrapped in db.transaction (crash-safe; F11) and guarded so it is idempotent and a no-op on fresh DBs (which are born correct from the SCHEMA constant):
rebuildTable(db, table, newDDL, guardToken):
sql := SELECT sql FROM sqlite_master WHERE type='table' AND name=table
if sql does NOT already satisfy guardToken: // e.g. staff_user still has "CHECK (role IN ('owner','staff'))"
db.transaction: // or reminder sql lacks 'quote_followup'
CREATE TABLE <table>__new ( …newDDL… );
INSERT INTO <table>__new (<explicit cols>) SELECT <explicit cols> FROM <table>; // explicit column list, never SELECT *
DROP TABLE <table>;
ALTER TABLE <table>__new RENAME TO <table>;
staff_user__newomits theroleCHECK (validate in repo); preserves PK +email UNIQUE.reminder__newkeeps thestatus/policy_appliedCHECKs, widensrule_kindto includequote_followup, and preservesUNIQUE (rule_kind, subject_id, due_period)verbatim (the idempotency key).- No FK references either table, so no
foreign_key_checkdance. - Postgres (prod, D15): each is a one-line
ALTER TABLE … DROP/ADD CONSTRAINTin the prod migration set;migrate()remains the SQLite-only path.
reminder_schedule and client.owner_id are plain additive (CREATE TABLE IF NOT EXISTS in SCHEMA; ALTER TABLE client ADD COLUMN owner_id TEXT guarded by PRAGMA table_info, matching the existing additive-column pattern).
5. API endpoints
[E] existing/reused · [N] new. All list endpoints paginate with returned total/page/pageSize (rule 8).
| Method | Path | Gate | Repo / notes |
|---|---|---|---|
[N] GET |
/employees |
requireAuth |
listEmployees — any signed-in user needs the name/owner picker |
[N] POST |
/employees |
requireOwner |
createEmployee |
[N] PATCH |
/employees/:id |
requireOwner |
updateEmployee ({displayName?, role?}) |
[N] POST |
/employees/:id/deactivate |
requireOwner |
deactivateEmployee (+ purge sessions) |
[N] POST |
/employees/:id/reactivate |
requireOwner |
reactivateEmployee |
[N] POST |
/employees/:id/password |
requireOwner |
setEmployeePassword |
[N] GET |
/pipeline |
requireAuth, role-gated in query |
listPipeline({filter,ownerId,viewerRole,viewerId,page,pageSize}) |
[N] PATCH |
/clients/:id/owner |
requireAuth; owner/manager only (inline check) |
set client.owner_id; audited |
[N] GET |
/modules/:id/clients |
requireAuth |
listClientsByModule |
[N] GET |
/modules/:id/clients.csv |
requireAuth |
streamed export, no silent cap |
[N] POST |
/modules/:id/notify |
owner/manager (inline check) | bulk notify (A5); audit + email_log per recipient |
[N] POST |
/documents/:id/convert-and-send |
requireAuth |
orchestrates convert→issue→send |
[N] POST |
/documents/:id/supersede |
requireAuth |
supersedeProforma |
[E] POST |
/documents/:id/status, /convert; /reminders/:id/send, /preview; auth routes |
— | reused unchanged by pipeline action buttons and the queue |
requireManager middleware is NOT added (simplicity critic #5 — it would be dead code; role gating lives inside queries and inline checks). Add it later if a route needs it.
6. Screens (ASCII sketches)
(a) Employees — /employees, owner-only nav item (beside Document Template):
Employees [ + Add employee ]
────────────────────────────────────────────────────────────────────────────
Name Email Role Active Actions
Asha Rao asha@tecnostac.com owner ● Edit · Reset pw
Vikram Shah vikram@… manager ● Edit · Reset pw · Deactivate
Priya Nair priya@… staff ○ Edit · Reset pw · Reactivate
────────────────────────────────────────────────────────────────────────────
3 employees (guards: cannot deactivate/demote last owner or yourself)
Add/Edit form: email (immutable on edit — it is the login id), name, role dropdown owner|manager|staff, initial password (create only). Password reset is a separate action (never logs the hash).
(b) Pipeline chase-list — /pipeline, nav right after Dashboard (D-PIPE option B):
Pipeline [ All ] [ Mine ] [ Overdue ] [ Owner ▾ ]
──────────────────────────────────────────────────────────────────────────────────
Client Amount Owner Stage Age Next action
🔴 Sahakari Bank ₹4,20,000 Vikram Quoted/Waiting 22d [Final nudge] ⋯
🔴 Janata CCS ₹1,80,000 Asha Quoted/Waiting 15d [Nudge] ⋯
🟠 Adarsh Credit ₹ 95,000 Priya Quoted/Waiting 5d [Chase] ⋯
🟢 Mahanagar Bank ₹2,10,000 Asha Quoted/Waiting 2d Waiting ⋯
⚪ Nagari Sah. (lead) — Vikram Enquiry — [Send quote] ⋯
🟢 Pragati CCS ₹ 60,000 Priya Won — [Convert→invoice] ⋯
──────────────────────────────────────────────────────────────────────────────────
Showing 1–50 of 137 ‹ Prev · Page 1/3 · Next › (Lost hidden — filter to show)
One DataTable; row tone green/amber/red by age band; Owner dropdown hidden for staff; action buttons fire existing lifecycle endpoints then reload; pager honours total. Staff see only their own rows (server-forced).
(c) Module → client roster panel (inside Modules page):
Modules ▸ Core Banking
──────────────────────────────────────────────────────────────────────
Clients on this module [ Notify all ] [ Export CSV ]
Client Edition Pays / yr Next renewal
Sahakari Bank enterprise ₹1,20,000 2026-09-01
Janata CCS standard ₹ 60,000 2026-08-15
Adarsh Credit standard ₹ 60,000 2027-01-10
──────────────────────────────────────────────────────────────────────
42 clients · total ₹34,80,000/yr ‹ Prev · Page 1/1 · Next ›
Notify all opens a subject+body composer (A5) → bulk send with per-recipient audit; owner/manager only. Export CSV streams all rows (no cap).
(d) Document view — convert+send & supersede (proforma only):
PROFORMA PI/26-27-0007 status: sent Sahakari Bank ₹4,20,000
──────────────────────────────────────────────────────────────────────────────
[ Convert to invoice & send ] [ Supersede & recreate ] [ Download PDF ]
INVOICE INV/26-27-0031 status: sent (immutable)
──────────────────────────────────────────────────────────────────────────────
[ Raise credit note ] [ Cancel ] (only if unpaid & unallocated) [ Download PDF ]
Invoices show no convert/supersede — corrections are credit notes or cancel-if-unpaid only.
(e) Where reminders surface — Dashboard queue (existing component, now paginated):
Reminders queue [ Mine ] [ All ]
──────────────────────────────────────────────────────────────────────
Quote follow-up — chase client Sahakari Bank PI review [Send] [Dismiss]
Invoice overdue (d15) Janata CCS INV/…-31 [Send] [Dismiss]
──────────────────────────────────────────────────────────────────────
Showing 1–25 of 61 ‹ Prev · Next › (staff: only your queue)
ClientDetail gains one Account owner ▾ dropdown writing client.owner_id (critic B — else leads are unroutable).
7. Escalating quote follow-up reminder
Rule kind: new sendable quote_followup. Its queued row is the owner nudge; its send is the client email.
Config (rule 3 + locked D-REMIND — both dated): resolveSchedule(db,'quote_followup',today) reads the active reminder_schedule row (effective_from <= today AND (effective_to IS NULL OR effective_to > today)), yielding day_offsets (default 3,7,14) and the dated subject/body. Code-constant fallback body ships it working:
Subject:
Following up on our quotation {ref} ({companyName})Body:Dear {clientName}, we wanted to check whether you had a chance to review our quotation {ref}. You can view it any time here: {shareUrl}. We'd be glad to answer any questions. Warm regards, {companyName}.{ref}degrades gracefully when the quote has nodocNo(quotes reachsentwithout issuance — F10): use client-facing date/reference, never render "quotation null". Send policy (auto|manual, default manual) is a flat operationalsetting(quote.followup.policy) — a toggle, not date-varying config.
Scan (new block in runDailyScan, injected today/now, deterministic):
- Select quotes
doc_type='QUOTATION' AND status='sent'; age anchor =MIN(document_event.at_wall WHERE kind='sent')(F15 — first-sent). - For each crossed offset
d(loop breaks onceage < d, ascending list → catch-up-safe):upsertReminder({ruleKind:'quote_followup', subjectId: quoteId, duePeriod: 'd'+d, docId: quoteId, clientId, policyApplied}). - Idempotency key =
UNIQUE(rule_kind, subject_id='quote id', due_period='d3'|'d7'|'d14')→ each interval fires at most once per quote, forever. Threshold-day bucket (not index) so reordering offsets never refires consumed buckets. - Catch-up burst guard (F7): on the first scan after deploy (or for any quote already past the last offset), enqueue only the single highest crossed milestone, not the whole ladder — prevents "×3 emails at once". Default policy
manualkeeps the burst review-gated regardless.
Owner nudge vs client email:
- Owner nudge = the queued row, surfaced by
listQueuewith aquote_followuplabel. Routing to the owner is derived (A3):reminder.doc_id → document.created_by. Noowner_idonreminder. - Client email =
sendReminder(...)— same Gmail path as the manual Send button. Share-link handling (fixes rule-6 bug F5):reminderContext(shared with the preview route, which must write nothing) only resolves an existing live share (non-revoked, non-expired) — read-only. Minting happens only inside the real send path, never in the context builder. Confirm the public/shareroute renders aQUOTATION(critic A). Mint with an expiry covering the escalation window (~60 days), not never-expiring (F12), reuse an existing live share rather than duplicating, and it is audited bymintShare. - Auto mode drains its send queue strictly after the scan transaction commits, at-most-once via the unique key.
Stop conditions: the scan reads only status='sent', so accepted/lost (via markStatus), invoiced (via convertDocument), and cancelled all leave the set — no further reminders, no extra stop logic. STOP cleanup: when markStatus sets accepted/lost and when convertDocument produces the invoice, dismiss still-open (queued/failed) quote_followup rows for that quote in the same transaction — looping setReminderStatus per row so each dismissal is audited (F14), not a silent bulk UPDATE (rule 6).
8. Proforma → invoice convert+send; easy cancel/recreate
One-click POST /documents/:id/convert-and-send:
- Load source; guard
docType==='PROFORMA',status !== 'cancelled', and no existing non-cancelled INVOICE child (F4 — see guard below); pre-check Gmail alive (fail before mutating). db.transaction(() => { d = convertDocument(db,uid,id,'INVOICE'); return issueDocument(db,uid,d.id) })— convert carries the proforma's lines forward, linksref_doc_id, flips proforma →invoiced; issue assignsINV/FY-NNNN. better-sqlite3 savepoints make it all-or-nothing.- After commit (never inside the txn): render PDF, send via Gmail, which marks the invoice
draft→sentand logsemail_log. A failed send never rolls back an issued invoice.
Rule 2 fix (F3) — recompute on convert. PROFORMA→INVOICE must build the invoice draft through the same computeBill path a normal draft save uses, recomputing the carried lines on the invoice's own doc_date — mirroring createCreditNote. This guarantees a tax invoice uses the rate that is law on its issue date even if the proforma sat across a dated tax_class boundary. (QT→PI may stay copy-only — both are non-legal.) The server trusts no client numbers.
Rule 4 fix (F4) — no duplicate legal invoices. convertDocument currently only rejects cancelled. Add: reject when a non-cancelled forward child of the target type already exists (SELECT 1 FROM document WHERE ref_doc_id=:src AND doc_type=:to AND status!='cancelled') and/or when src.status='invoiced'. convert-and-send re-checks the same condition before mutating so double-click/retry is idempotent — one sale, one invoice number, one client email.
Easy cancel / recreate — precise matrix (rule 4 firewall):
| Document | Cancel | Recreate | Correct after issue |
|---|---|---|---|
| PROFORMA (draft) | n/a (no number) | new PI draft carrying lines | re-draft |
| PROFORMA (issued, unallocated) | ✅ cancelDocument (number stays consumed) |
✅ supersede = cancel + new PI draft, ref_doc_id-linked |
supersede |
| INVOICE (issued, unpaid, unallocated) | ✅ cancelDocument (number consumed) |
❌ never | credit note |
| INVOICE (allocated/paid) | ❌ | ❌ | credit note only |
POST /documents/:id/supersede + supersedeProforma(db,uid,id): guard docType==='PROFORMA' — reject INVOICE loudly ("Invoices are immutable — cancel if unpaid, or raise a credit note"). In one db.transaction: if issued, cancelDocument (its unpaid/unallocated guard always passes for proformas; number stays consumed), then insert a new PROFORMA draft carrying payload verbatim via insertDocRow with ref_doc_id → the superseded doc, plus a superseded event. convertDocument is not reused (its CHAIN guard is forward-only). This keeps D-CONVERT's "trivially easy" cancel/recreate while honouring immutability.
Invoice reminder mails (reuse invoice_overdue, no CHECK change): switch the scan's due_period from the monthly bucket to day-past-due milestones dN resolved from reminder_schedule('invoice_overdue') (default 7,15,30), upsertReminder firing each milestone once; populate ctx.daysOverdue = today − doc_date (A7 limitation) so the existing "N days past due" template text renders; auto-stop is already handled (scan skips paid|cancelled|lost and outstanding<=0). Apply the same highest-milestone-only backfill (F7) on cutover so aged invoices already reminded under the month scheme don't double-fire; note the due_period scheme change in reminders.test.ts. A freshly issued invoice from convert-and-send enters this scan automatically.
9. Pipeline stage derivation (minimal storage)
Stage is never stored — computed per row from client.status + the client's latest QUOTATION status:
| Stage | Derivation |
|---|---|
| Enquiry | client.status='lead' AND client has no QUOTATION |
| New Project | latest quotation status='draft' |
| Quoted / Waiting | latest quotation status='sent' (the chase target) |
| Won | latest quotation status IN ('accepted','invoiced') |
| Lost | latest quotation status='lost' OR client.status='lost' |
Age = MIN(document_event.at_wall WHERE kind='sent') (F15, no sent_at column). Owner = document.created_by for quote rows (A3), client.owner_id for bare-lead enquiries (A4). Next action reads the same resolveSchedule(db,'quote_followup',today).day_offsets the reminder engine uses (single source — no hardcoded 3/7/14):
| Row state | Next action |
|---|---|
| Enquiry / draft quotation | Send quote |
| sent, age < offset₁ | Waiting |
| sent, age ≥ offset₁ / ₂ / ₃ | Chase / Nudge / Final nudge |
| accepted | Convert to invoice |
Sort: descending age for actionable rows (oldest unanswered on top); Waiting/Won sink; Lost hidden by default (filterable). Colour band by age: green <offset₁, amber offset₁–₃, red ≥offset₃. Server-side LIMIT/OFFSET + total (rule 8). New storage across §8–§9: zero tables, zero columns beyond client.owner_id.
10. Module → client roster
Reverse query: listClientsByModule(db, moduleId, {page,pageSize}) — client_module JOIN client WHERE module_id=? ORDER BY client.name, paginated, total returned (rule 8). State whether active=0 links are included (default: exclude, and say so — no silent drop).
Per-row derivation (A1 balanced): price via existing priceOn(db, moduleId, client_module.kind, client_module.edition, today); next renewal from client_module.next_renewal. Footer sums yearly revenue.
GET /modules/:id/clients (roster panel) and GET /modules/:id/clients.csv (streamed export, no cap).
POST /modules/:id/notify (owner/manager, A6): owner-typed subject+body (A5); resolves each client's primary contact email, sends via the existing Gmail path, writes email_log + one audit row per recipient (rule 6), internally paginated + rate-limited, returns sent/failed counts and warns rather than truncating (rule 8). No new table, no dated template (ad-hoc human broadcast).
11. Employee entity, roles, ownership routing
staff_useris the employee table — not renamed (churnssession.staff_id,interaction.staff_id, auditentity='staff_user'for zero gain). "Employee" in UI/API/repo names only.repos-employees.ts(plain functions, DB first arg, audited in-txn,entity='staff_user', never returns password columns):listEmployees,getEmployee,createEmployee(foldscreateStaff; validatesrole∈{owner,manager,staff}+password.length≥8),updateEmployee,setEmployeePassword(reuseshashPin, auditsreset_password, never logs the hash),deactivateEmployee,reactivateEmployee. Guards: cannot deactivate/demote the last active owner; cannot deactivate yourself.- Roles:
owner(full + employee management),manager(sees everyone in pipeline/queue/roster; may Notify-all),staff(server-forced to own rows). - Ownership routing (A3/A4): quote/enquiry owner =
document.created_by(default) orclient.owner_id(bare lead); reminder owner derived viadoc_id → document.created_by.client.owner_idset on ClientDetail (owner/manager) — needed so the ~300 APEX-imported / unassigned leads (owner-null) can reach a staffer's queue; owner/manager unfiltered view is the safety net forcreated_by='system'recurring invoices and still-unassigned leads (F13). - Role gate (server-side, from
res.locals.staff.{id,role}, never trusted from client):staff→ query forcescreated_by = me(quotes) /client.owner_id = me(leads) and ignores any widening param;owner|manager→ no filter by default, optional?owner=narrows. - Auth fixes (headline capability of the employee screen): (1) add
AND u.active = 1to theverifySessionjoin — a deactivated employee with an unexpired 14-day token is currently still authenticated; (2)deactivateEmployeedoesDELETE FROM session WHERE staff_id=?in the same txn. Deactivation takes effect immediately. - First boot:
seedIfEmptyalready creates the owner viacreateStaff→createEmployee; that UUIDv7 row is the founding employee (appears in the table automatically, satisfies the last-owner guard, can create the first manager/staff). No promote migration.
PART B — THE ROADMAP
TDD-friendly, employee-foundation first, each phase small and independently shippable. npm run typecheck + npm test green before every landing. Money math changes require test changes (locked).
Phase 1 — Employee foundation (D-EMP core). staff_user.role CHECK relaxed via the shared rebuildTable helper (txn-wrapped, guarded); repos-employees.ts (7 fns, folds createStaff, last-owner + self-deactivate guards); auth fixes (verifySession active-check + session purge on deactivate).
Accept: create manager/staff; role validated in repo (bad role rejected); deactivated user's session dies immediately (verifySession denies + rows purged); last active owner cannot be demoted/deactivated; rebuild is a no-op on a fresh DB and idempotent on re-run.
Phase 2 — Employee management surface. Six /employees routes (mutations owner-only); Employees page + owner-only nav + api.ts client.
Accept: owner CRUD round-trips; non-owner gets 403 on mutations; GET /employees works for any signed-in user (picker); count shown, no silent truncate.
Phase 3 — Owner plumbing. client.owner_id additive column; PATCH /clients/:id/owner (owner/manager, audited); ClientDetail Account owner dropdown; shared server-side role-gate helper (staff → self).
Accept: setting an owner writes an audit row; staff request cannot widen scope; a lead gains a routable owner.
Phase 4 — Dated reminder schedule (rule 3 / D-REMIND home). reminder_schedule table + resolveSchedule(db,ruleKind,today) resolver; seed defaults (quote_followup 3/7/14, invoice_overdue 7/15/30) in seed.ts with code fallback.
Accept: resolver returns the row whose effective_from<=today<effective_to|null; a new dated row changes cadence without a code edit; missing-row falls back to the code default.
Phase 5 — Pipeline chase-list (D-PIPE option B). repos-pipeline.ts listPipeline (union of quote-bearing clients + bare leads, derived stage/age/next-action/tone, role-gated, paginated); GET /pipeline; Pipeline page + nav + api.ts. Next-action bands read Phase-4 resolver.
Accept: a bare lead with no quote appears (Enquiry); stages derive correctly from status; staff see only their rows; oldest-overdue sorts top; pager honours total; action buttons hit existing lifecycle endpoints and reload.
Phase 6 — Escalating quote follow-up (D-REMIND). quote_followup rule_kind via shared rebuildTable; TS union extended (forces exhaustive switch); scan block (highest-milestone backfill guard); reminderContext resolves (never mints) share + shareUrl, dated subject/body with graceful null-docNo; mint only in send path with ~60-day expiry, reusing live shares; STOP cleanup dismissing open rows per-row (audited) in markStatus/convertDocument; listQueue paginated + owner-filtered (doc_id → created_by).
Accept: each interval fires at most once per quote (unique key); catch-up fires only the highest crossed milestone; preview writes nothing (no share minted); accept/lost/convert dismisses open nudges (audited); auto-send drains after commit at-most-once; queue paginates.
Phase 7 — Convert + send (D-CONVERT). Re-convert guard in convertDocument (F4); PROFORMA→INVOICE recompute via computeBill on invoice date (F3); POST /documents/:id/convert-and-send (txn convert+issue, send after commit, Gmail pre-check, double-submit idempotent); doc-view button.
Accept: double-click/retry yields exactly one INVOICE + one number + one email; a rate-boundary crossing produces date-correct GST (server recompute); a failed send leaves the issued invoice intact; QT→PI unaffected.
Phase 8 — Proforma supersede (D-CONVERT). supersedeProforma + POST /documents/:id/supersede; doc-view button (proforma only).
Accept: issued proforma cancels (number stays consumed) and a new ref_doc_id-linked PI draft carries the lines; INVOICE supersede is hard-rejected → credit-note guidance; all steps audited in one txn.
Phase 9 — Invoice reminder escalation. Scan invoice_overdue due_period → dN milestones from Phase-4 resolver; ctx.daysOverdue populated; highest-milestone backfill on cutover; update reminders.test.ts.
Accept: d7/d15/d30 fire once each; aged invoices don't double-fire across the month→dN cutover; template renders "N days past due"; auto-stops on paid/cancelled/settled.
Phase 10 — Module → client roster (D-ROSTER). listClientsByModule; GET /modules/:id/clients + .csv; POST /modules/:id/notify (owner/manager, audit+email_log per recipient); Modules roster panel.
Accept: reverse query paginates with total and states active=0 handling; per-row price via priceOn + renewal from client_module; Notify-all writes one audit + email_log per recipient and never silently caps; CSV exports all rows.
Total: 10 phases — employee foundation (1–2), owner plumbing (3), dated config (4), then one phase per feature (5 pipeline, 6 quote reminders, 7 convert, 8 supersede, 9 invoice reminders, 10 roster), each independently shippable with typecheck + tests green.