# 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: 1. **Employee foundation (D-EMP, build step 1).** Promote `staff_user` into a first-class *Employee* entity (management UI, `owner|manager|staff` roles, ownership + role-gated visibility). Everything else depends on it. 2. **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 | Owner` filters. Stages derived, not stored. 3. **Escalating quote follow-up (D-REMIND).** A `sent` quote that isn't accepted nudges the owning employee (dashboard) and emails the client (Gmail, share link) at dated intervals; stops on accept/lost/convert. 4. **Module → client roster (D-ROSTER).** Pick a banking module → every client on it, what they pay, next renewal, plus *Notify all* / *Export CSV*. 5. **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_by` for v1 (simplicity critic #4, accepted).** No `document.owner_id` column, no reassignment/handoff endpoint. A quote's owner *is* its creator; the pipeline "Mine/Owner" filter and quote-follow-up routing read `created_by`. This does **not** break D-EMP (every ownable thing still carries an owner). The reassignable `document.owner_id` + `PATCH /documents/:id/owner` are deferred until a handoff feature is actually requested. - **A4 — `client.owner_id` is the one genuinely new owner column.** `client` has 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 no `due_date` column and `payload.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 __new ( …newDDL… ); INSERT INTO
__new () SELECT FROM
; // explicit column list, never SELECT * DROP TABLE
; ALTER TABLE
__new RENAME TO
; ``` - `staff_user__new` omits the `role` CHECK (validate in repo); preserves PK + `email UNIQUE`. - `reminder__new` keeps the `status`/`policy_applied` CHECKs, widens `rule_kind` to include `quote_followup`, and preserves `UNIQUE (rule_kind, subject_id, due_period)` verbatim (the idempotency key). - No FK references either table, so no `foreign_key_check` dance. - **Postgres (prod, D15):** each is a one-line `ALTER TABLE … DROP/ADD CONSTRAINT` in 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 no `docNo` (quotes reach `sent` without issuance — F10): use client-facing date/reference, never render "quotation null". Send policy (`auto|manual`, default **manual**) is a flat operational `setting` (`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 once `age < 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 `manual` keeps the burst review-gated regardless. **Owner nudge vs client email:** - **Owner nudge** = the queued row, surfaced by `listQueue` with a `quote_followup` label. Routing to the owner is **derived** (A3): `reminder.doc_id → document.created_by`. No `owner_id` on `reminder`. - **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 `/share` route renders a `QUOTATION` (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 by `mintShare`. - **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`:** 1. 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). 2. `db.transaction(() => { d = convertDocument(db,uid,id,'INVOICE'); return issueDocument(db,uid,d.id) })` — convert carries the proforma's lines forward, links `ref_doc_id`, flips proforma → `invoiced`; issue assigns `INV/FY-NNNN`. better-sqlite3 savepoints make it all-or-nothing. 3. **After commit** (never inside the txn): render PDF, send via Gmail, which marks the invoice `draft→sent` and logs `email_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 `