Owner has no HQ_SECRET_KEY, so the D31 unlock-with-key path was impossible. crypto is
now fully keyless: encrypt/decrypt ignore keyHex (params kept so a key can be wired back
later); decrypt returns '' for any leftover legacy AES value (unreadable → treated as
unset, never fed back as ciphertext). Key-based /admin/migrate-plaintext replaced by
keyless /admin/clear-legacy-credentials (clearLegacyCiphertext) which NULLs unreadable
pre-D31 values. .env.example marks HQ_SECRET_KEY removed.
Ran the cleanup on live data: 76 SMS + 110 client DB passwords were unrecoverable and
cleared (usernames intact) — must be re-populated in plaintext (APEX re-import / re-entry).
Also fixes the one real defect the D31 adversarial review confirmed: maybeRefreshSmsDaily
still had a 'keyHex==="" -> no-op' guard that would silently kill the DAILY auto-refresh
once the key was gone (manual refresh masked it). Removed; the daily pull now runs keyless
like the manual path. Full suite 416 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes credential encryption at rest so the SMS-gateway logins (and other portal/DB
passwords, module secrets, Gmail token) are usable daily without a held-out
HQ_SECRET_KEY. Keystone: crypto.encrypt() stores tagged plaintext ('plain:'),
decrypt() untags keylessly and still reads legacy AES *with* the key to migrate it.
Drops the six 'refuses/cannot without HQ_SECRET_KEY' guards. Scope is the reversible
credential vault ONLY — login passwords stay one-way scrypt-hashed (@sims/auth).
Secrets still kept out of list payloads + audit rows; reveal still managerial+audited.
migrateLegacyCiphertext + POST /admin/migrate-plaintext (owner) rewrite existing AES
rows as plaintext using the key one final time (idempotent, one audit summary), after
which the key is never needed. SECURITY: a DB dump now exposes these in the clear.
Tests updated to the plaintext policy; new migrate-plaintext.test locks decrypt +
migration + idempotency. Full suite 416 green; typecheck clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Security review of 58c9799: (1) block loopback/link-local/private targets for the
admin-set sms.balance_api_url before sending client credentials (assertSafeGatewayUrl
— rejects 127/10/172.16-31/192.168/169.254/localhost/::1/.internal), so the setting
can't be pointed at cloud metadata or internal services; (2) redirect:'manual' so a
3xx can't carry credentials to another host; (3) error messages never interpolate the
credential-bearing URL. http:// transport kept — it is the vendor panel's only
interface and the operator supplied it. Tests cover the block list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pull each SMS client's actual credit balance from the provider panel instead of
manual entry. sms.balance_api_url (dated setting, default 164.52.195.161/API/
BalAlert.aspx) returns pipe-delimited code|STATUS|payload; parseBalanceResponse
reads the trailing number and flags ERROR lines, never guessing. Uses each
subscription's already-imported username/password_enc — the automated path decrypts
directly (no reveal-audit spam) and audits sms_balance ONLY when it changes;
per-poll telemetry lands in the unaudited sms_balance_check table (SQLite schema +
PG migration 010). Managerial 'Refresh from gateway' button (POST /reports/sms-
balances/refresh) + per-client refresh; scheduler does one pass per day
(maybeRefreshSmsDaily). Low-balance alert on the Dashboard + cockpit tile. Needs
HQ_SECRET_KEY (import-time key) to decrypt — no-ops with a clear message otherwise,
never stores plaintext. Parser unit-tested; full suite 408 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Age column on the workbench (days since opened); a still-open ticket aged past
ticket.sla_days (dated setting, default 7) shows red with a warning. New 'Overdue'
filter chip with an honest server-side count (still-open AND opened on/before the
SLA cutoff). Each row gains a 'Bill' action that opens New Document with the client
pre-selected (/documents/new?client=<id>) — the light ticket->invoice path, staff
add the lines. Kept deliberately un-bulky: no kanban, reuses the existing table.
Test locks the overdue filter + count. typecheck + full suite (403) green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New 'SMS balances' screen: every SMS client with its credit balance (the module's
own sms_balance service field), lowest first so the ones about to run dry sit on
top. Low = below sms.low_balance_threshold (dated setting, config over code);
'Low' is the default view = the upsell list. One click raises a top-up proforma
(reuses the module renewal-quote path); client name links to the Modules tab where
the balance is kept. Owner cockpit gains an 'SMS balance low' tile as the at-a-
glance alert. Read-only report, no audit. Test locks parse/flag/sort. typecheck +
web build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
listAuditPage (filters: entity/action/user/entity-id/date; honest total; newest
first) + auditFacets. GET /audit owner-only. New /audit 'Audit log' page: entity/
action filters from server facets, paginated table with a per-row before->after
key diff, actor names mapped via employees. Reading the log writes no audit row.
Test locks filter+pagination. typecheck + web build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
repos-mis misCockpit(today): this-month vs last for billed (issued invoices) +
collections (payments), total outstanding (dues-aging), open pipeline value,
renewals due in 30d, open tickets, reminder queue — composed from the existing
repo aggregates so figures reconcile with Reports/Dashboard. Owner-only GET
/reports/mis. New /mis 'Cockpit' page: KPI tiles with MoM deltas + click-through
to the source pages. Money in tests. typecheck + web build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
renewalsDue(from,to,today): unified list of module (client_module.next_renewal,
priced by the dated price book) + AMC (period_to, fixed amount) renewals with
amount + days-until, earliest-first. generateModuleRenewalQuote: one-click
proforma carrying the client's module kind/edition forward at the price-book
rate (AMC keeps its existing renewal-invoice). Routes GET /renewals,
POST /client-modules/:id/renewal-quote. Renewal reminders already fire from the
scheduler (renewal_due/amc_expiring) — this is the worklist + action. Tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- gstSummary(range): per-month GST filing summary over issued INVOICE (adds) +
CREDIT_NOTE (subtracts, s.34), CGST/SGST/IGST split from stored paise, with a
grand total. Route GET /reports/gst-summary. Money locked in tests.
- listDocuments gains free-text q (doc number OR client name, case-insensitive
both engines); GET /documents ?q=.
- bulkUpdateClients (owner) + bulkSetMilestone (owner) for cleanup at scale —
POST /clients/bulk, POST /projects/bulk-milestone; each client keeps its own
audit; milestone bulk writes one summary audit. Suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Red-team backend-quality highs/mediums:
- Added 30 indexes on the foreign-key / status / date / renewal columns the
lists, reports, joins and scans read — zero existed, so every query full-scanned
(tolerable on dev SQLite, a real problem on production Postgres at 300 clients /
thousands of tickets & documents). Same DDL on both engines: INDEX_DDL in the
SQLite SCHEMA + pg migration 007 (CREATE INDEX IF NOT EXISTS, idempotent).
- Client search + district/sector filters now LOWER(...) LIKE LOWER(?): bare LIKE
is case-insensitive on SQLite but case-SENSITIVE on Postgres, so search silently
broke on the prod engine. Test locks case-insensitivity.
Verified: pg migrations 006+007 apply cleanly on local Postgres; suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Red-team correctness findings:
- SqliteDb.transaction now serializes top-level transactions with an async
mutex and tracks re-entrancy via AsyncLocalStorage (nested calls -> savepoints,
no deadlock). The old shared txnDepth counter let concurrent async callers
interleave BEGIN/COMMIT on the one connection -> silent data loss; a 50-way
concurrent read-modify-write test now lands exactly.
- generateRecurring wraps each plan in try/catch: one misconfigured plan is
parked (audited 'generate_failed') and the scan continues, instead of aborting
all remaining generation and the entire auto-send drain, every scan.
- POST /modules/:id/notify broadcast IIFE gets a trailing catch so a mid-loop
throw answers the request instead of hanging it.
Full suite 388 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Red-team money findings:
- createCreditNote caps the CN to the invoice's remaining creditable value
(invoice payable − already-credited by non-cancelled CNs), read+insert in
one txn. Blocks a CN exceeding its invoice / unlimited CNs flipping it 'paid'.
- nextDocNo allocates via one atomic UPDATE ... RETURNING (row lock) instead of
read-then-bump — no duplicate/burned numbers under concurrent issue (Postgres).
- markStatus rejects accepted/lost on non-QUOTATION docs — an issued invoice can
no longer be hidden from overdue chasing via a quote-only transition.
- computeBill bounds qty magnitude (keeps fractional for weighed Store goods);
HQ buildLines additionally requires whole-unit integer qty (SMS/module seats).
- recordPayment rejects TDS greater than the cash received — no fabricated-TDS
'settlement' with no money.
money-guards test locks all four. Suite money paths green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Red-team authorization findings:
- New requireManagerial middleware. Applied to POST /documents/:id/cancel,
/credit-note (financially destructive — reverse money / consume a number)
and /documents/:id/share (mints an UNAUTHENTICATED public PDF link — the
IDOR exfiltration high). Any signed-in staff could previously do all three.
- PATCH /interactions/:id now 403s unless the viewer is the interaction's
author or managerial — staff can no longer overwrite another employee's
logged call/visit.
New authz-gates test locks both. Note (deliberate): general client read/edit
stays open to staff — a shared support-team book is the intended model; the
genuinely sensitive credential reveals are already managerial + rate-limited.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Red-team highs/mediums (auth surface):
- staff_user gains failed_count/locked_until; login() locks an account for 15min
after 8 consecutive failures, clears the counter on success (both engines).
- login() is now constant-time: a missing email still spends one scrypt verify
against a fixed dummy hash — no timing/enumeration oracle. Returns a typed
{ok:false,reason} instead of null.
- POST /auth/login gets per-IP (20/min) AND per-email (8/15min) fixed-window
rate limiting layered over the lockout; 429 on trip.
- Credential reveal routes (db-password, module password, module secret) get a
per-user reveal limiter (20/min) so one insider cannot bulk-decrypt everything;
added missing getClientModule 404 guards.
- app.set(trust proxy,1) so req.ip is the real client behind nginx/Caddy and the
per-IP limiters actually bucket per client.
- makeRateLimiter extracted to rate-limit.ts (shared, no server<->api cycle).
Tests: lockout, counter-reset, unknown-email, and existing auth all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
project_milestone: each client_module (a project) carries a checklist
seeded from the dated project.milestone_template setting (advance paid /
setup / installed / go-live / balance / AMC — owner-editable). Ticking
stamps the date; audited. ensureProjectMilestones is idempotent and
back-fills imported projects. Cross-project board (per-milestone pending
counts) + drill-down (who's pending go-live). Routes: GET/POST
/client-modules/:id/milestones, GET /projects/board, /projects/pending/:key.
assignModule seeds the checklist for new projects.
Also fixes a latent flaky-ordering bug: uuidv7() now bumps monotonically
within a process, so ORDER BY id is genuine creation order (the guarantee
pagination + the sync cursor already assume) — sub-millisecond ties no
longer fall back to the random tail. Suite 377 green, stable across runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
usage_rate_band: a dated per-module rate card of volume bands (generic;
SMS packs the first user). resolveUsageRate picks the band by count
(highest min_qty <= count); below the lowest band = under the minimum
order. buildLines resolves price as: explicit per-line override >
tiered rate card > dated price book — so an SMS line prices by count
automatically, a save under 50,000 is refused, preview warns. Rates are
integer paise (0.40=40p), a new card is a new dated set of rows so issued
invoices keep their rate. Owner routes GET/POST /modules/:id/rate-card.
Tests lock the founder card to the paisa: 50k→₹23,600, 100k→₹43,660,
200k→₹80,240, 300k→₹1,06,200 incl 18% GST. Suite 372 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ensureModule seeds each module field_spec (SMS/RTGS/MOBILEAPP declared
fields; ATM/WHATSAPP empty — their one datum stays in the D20 provider
column), back-filling only when a module spec is still empty so an
owner-customized spec is never clobbered. SMS/RTGS/MobileApp service data
now writes into client_module.field_values keyed to the spec; provider/
username/portal-password keep the D20 columns; unkeyed values still land in
details so nothing is lost. Tests assert seeded specs + keyed values; suite
363 green. Real dry run unchanged: 273 clients, 150 docs, 167 links.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
module.field_spec (FieldDef[]: key/label/type/options/required) declares a
module's own operational fields, validated on create/update. client_module
gains field_values (JSON map of non-secret values, type-checked against the
owning module's spec) + secrets_enc (one AES-GCM blob over a {key:plaintext}
map; the clear key-list prefix lets the UI show which secrets are set without
decrypting). Routes: PUT /client-modules/:id/fields (staff), POST
/client-modules/:id/secrets + /reveal-secret (managerial, audited, plaintext
never logged). Additive over D20 — provider/username/password stay. Both
engines (SCHEMA + migrate guards + pg 003). 11 new tests; module suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bills are LINE ITEMS not documents: group by INVOICE_NO across three GST
eras (GST pseudo-line / 2026 columns-only / pre-GST zero-tax). 46 raw
ticket statuses -> 5 via ordered pattern map; OLD_ID re-key chains
collapse so superseded rows do not double-import. YES/NO flags respect
real vocabulary (GENERAL ENQUIRY != yes); ATM cell value becomes the
module provider. SID 329 test client + orphan refs skip (counted notes,
distinct from commit-blocking problems). District normalize, NO DATA
scrub, payment normalize, richer-row merge for duplicate service rows,
shared-number-space series seed. Dry run over the real export: 273
clients, 150 documents = Rs 33,16,800.24, 72 payments, 1,826 tickets,
667 interactions, INVOICE seed 10252 (matches APEX next_val). Suite 358
green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parseWorkbookCsvs + stageApexFull (pure validation pass, zero writes) +
commitApexFull (one transaction): clients w/ encrypted DB passwords +
secretary contacts, YES-flags -> live client_module rows for the 5
modules, branches, bill_filed -> QT/PI/INV history with paisa-exact
CGST/SGST split + payment/allocation rows + series seed, 4.5k tickets
with status/date mapping, SMS/RTGS service data -> client_module
provider/username/password_enc/details, project lists -> interaction
notes with tolerant APEX date parsing. Plaintext passwords never stored
or audited; missing HQ_SECRET_KEY is a blocking problem. CLI:
npm run import:full -- --dir <folder> [--commit]. 7 new tests; suite
359 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET/POST/PATCH /tickets (chips filters, mine=1, search, honest pagination,
counts + kind suggestions in one payload); client branches GET/POST/PATCH;
PATCH /client-modules/:id gains the managerial-gated portal password (atomic
with the rest of the patch) + POST /client-modules/:id/reveal-password
(audited per reveal). HTTP gating test: staff 403 on password set/reveal,
full ticket + branch round-trip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New tables ticket + client_branch (both engines: SCHEMA + pg 002-apex-parity);
client_module gains provider/username/password_enc/details/remark with
migrate guards. repos-tickets: team-visible desk, honest pagination, status
lifecycle stamping closed_on, counts + kind suggestions from history.
repos-branches: minimal audited CRUD. repos-modules: service fields on
ClientModule (hasPassword only — ciphertext never leaves), validated details,
setModulePassword/revealModulePassword mirroring the client DB password
discipline. 5 new tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
startServer picks Postgres when DATABASE_URL is set, SQLite file otherwise.
New pg-engine.test.ts (opt-in via HQ_PG_TEST_URL, wipes its target schema):
migrations from zero, invoice issue with series numbering + due-date stamp,
paisa-exact payment to paid, exactly-once reminder upsert via ON CONFLICT,
savepoint-nested rollback (incl. the mixed dbPassword+patch write), and a
full HTTP boot smoke (health/login/clients) on the postgres engine.
Verified locally against PostgreSQL 17: 5/5. Default suite (sqlite)
346/346, typecheck clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P1: every repo, route, scheduler path and test now speaks one async DB
interface (all/get/run/exec/transaction). better-sqlite3 stays underneath
for dev/tests via SqliteDb (statement cache, manual BEGIN/SAVEPOINT
nesting); openDb stays sync so fixtures remain one-liners. Zero behavior
change: 346/346 tests, typecheck clean.
P2: migrations-pg.ts — numbered Postgres migration set (001-init: bigint
paise, integer flags, TEXT ISO dates, TEXT JSON; reminder CHECK born in
final widened form) + schema_migrations runner.
P3: db-pg.ts — pg Pool behind the same interface: ?->$n rewrite outside
string literals, transactions pinned to one pooled client via
AsyncLocalStorage with savepoint nesting, int8/numeric parsed to JS
numbers so paise math is identical on both engines.
Also: reminder upsert INSERT OR IGNORE -> ON CONFLICT DO NOTHING
(portable, same changes-count semantics).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
27 adversarially-confirmed findings from the multi-agent whole-branch review (2 deferred).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MAJOR
- Documents register: Convert & send no longer bubbles into row navigation
(stopPropagation guard on the actions cell)
- Pipeline rows: convert_invoice now uses the same confirmed convert-and-send
as the Documents page (was the old plain draft convert)
- APEX importer carries anydesk/os/district/sector: stg_client columns
(+ migrate guard), optional CSV headers staged, commit INSERT maps them
- Clients page: district/sector filter inputs wired to the server filters
(server side widened to LIKE for narrow-as-you-type)
MINOR
- WhatsApp/secretary typed contact roles: datalist on the contact Role input,
labeled rows (with Copy) on the Client 360 support card
- Dashboard overdue + dues-aging now anchor on COALESCE(due_date, doc_date) —
an unpaid invoice not yet due no longer shows as overdue (test added)
- PATCH /clients/:id is atomic: dbPassword + field patch in one transaction
- Audit same-txn: setClientDbPassword, stageCsv, and the import stage/commit
route audits all wrapped in db.transaction
Suite: 345 tests / 66 files, typecheck clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- POST /import/stage (CSV text), GET /import/status (report + first-50 problem
rows with honest totals), POST /import/commit — all owner-only, audited;
commit still refuses while ANY staged row carries problems (existing guard)
- /import page (ADMIN nav, owner-only): file pickers → problem tables →
verification stats (incl. invoice-value cross-check) → confirm-gated Commit;
re-stage freely, series seeding message surfaced
- 2 route tests (403s for staff; bad→refuse→clean→commit round-trip incl.
series seeding past legacy numbers); suite 344 green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- sidebar collapses to an icon rail (footer toggle, localStorage-persisted;
nav tooltips via title; <900px hamburger untouched)
- dashboardView gains mine-scoping: staff are ALWAYS scoped to their book
(clients they own, docs/payments they created, their follow-ups, their queue);
owner/manager get a Mine/Everyone toggle (?mine=1)
- 1 test; suite green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- additive client columns; toClient never maps db_password_enc (payloads carry
only hasDbPassword); listClients gains district/sector filters
- setClientDbPassword: AES-256-GCM via HQ_SECRET_KEY, refuses loudly without a
key; revealClientDbPassword decrypts + writes one audit row per reveal
- PATCH /clients/🆔 dbPassword is owner/manager-gated inside the same route;
POST /clients/:id/reveal-db-password (owner/manager)
- client dialog gains district/sector/AnyDesk/OS; Client 360 gains the Support
card (copy AnyDesk, •••• + Reveal/Set for managerial)
- 5 tests; suite green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- QUOTATION renders one titled section per line: module name, its what's-
included block, SAC + qty × rate + amount; sections never split mid-page and
each module after the first opens a fresh page in print
- summary line up top ('This proposal covers: …'); totals/terms/signature
unchanged; INVOICE/PROFORMA/CN/RECEIPT keep the compact grid (regression-
asserted); preview-fidelity golden test untouched and green
- 3 tests; suite green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- listClientsByModule rows carry cmId; ClientModulePatch gains kind/edition
(kind validated against module.allowedKinds — same audited write path as
Client 360, so the two views can never disagree)
- Modules roster: inline edit (kind/edition/renewal/status), ✕ unassign with
confirm (deactivates the link, totals shrink), Assign client… type-ahead
- Documents register: issued proformas get a Convert & send quick action
(same convert-and-send endpoint + confirm as the document view)
- 2 tests; suite 333 green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- GET/PATCH /me (self-scoped whitelist — role/email untouchable) + POST /me/password
- changeOwnPassword: current password required; every OTHER session dies in the
same txn, the changing session survives; audited without hashes
- staff_user.phone/title additive columns, surfaced on Employees (columns + edit
dialog) and the new Profile page; user chip in the shell opens /profile
- 7 tests; suite 331/331 across 64 files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- /reminders page: Queued/Sent/Failed/Dismissed chips mapping to ?status=,
managerial-only Mine/All toggle via ?owner= (staff are server-forced to
their own rows), kind/client/doc/due/status table with Send/Preview/
Dismiss on open rows, honest server-driven pager (rule 8).
- Extract the Dashboard queue actions into shared
components/ReminderQueue.tsx (QueueActions, RULE_LABEL, reminderTone)
with a re-entry guard on Send/Dismiss; Dashboard queue card links
"View all" to /reminders.
- api.ts: getReminders now takes { status, owner, page, pageSize } and
returns the full QueueItem rows + page metadata; login stores the
staff id (hq.id) so Mine can narrow by owner.
- Nav item under WORK + route; no backend changes. A contract test pins
the sent/dismissed chips, ?owner= narrowing and cross-chip staff scope
at HTTP level (apps/hq/test/reminders-page.test.ts).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
listDocuments now returns { documents, total, page, pageSize } (default 50,
cap 200, newest first) with the client name joined server-side — no N+1.
GET /documents passes page/pageSize through; type/status/clientId filters
compose against the same WHERE for both count and page (rule 8: no silent
caps). clientLedger walks the paginated repo in 200-row batches so the
ledger still surfaces every document. New /documents screen: type chips,
status filter, mono doc numbers, amount column, pager honouring the server
total; rows open /documents/:id. Nav entry under WORK (exact-match so New
Document keeps its own highlight).
Note: the GET /documents route hunk itself landed via 8861b46, swept into
a concurrent commit from the same working tree; this commit carries the
repo/ledger/web halves and the tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the WS-A coverage gap: due-dates.test.ts proved the manual-issue and
convert paths but relied on code inspection for recurring generation. A manual
recurring plan now runs through runDailyScan and the generated invoice's
due_date is asserted as doc_date + seeded 15-day terms. Suite 310/310.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- document.due_date additive column; billing.payment_terms_days seeded (15)
- issueDocument stamps doc_date+terms on INVOICE when absent — covers manual
issue, convert-and-send and recurring generation in one place
- drafts accept dueDate (INVOICE only, validated); composer date field
auto-filled from GET /settings/billing; invoice PDF prints Due Date
- overdue scan + reminder anchor on COALESCE(due_date, doc_date); email says
'was due on X and is now N day(s) overdue' when a due date exists
- legacy fixtures pinned to due_date=NULL (sanctioned: they test the doc_date
anchor); 9 new tests; suite 309/309 across 60 files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- listClientsByModule: reverse lookup over active links, ordered by client name,
paginated with a true total; per-row dated price via priceOn + next renewal;
footer revenue total spans ALL links (active=0 excluded by design, stated)
- GET /modules/:id/clients (paginated) + /clients.csv (streams every row, no cap)
- POST /modules/:id/notify (owner/manager): resolves the full roster first, sends
via the Gmail path, one email_log + one audit row per attempted recipient,
names every unreachable client in the response (warn, never truncate)
- Modules page roster panel: table + revenue footer + Notify-all composer + CSV
- 8 tests; suite green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- invoice_overdue scan: monthly bucket → d7/d15/d30 milestones resolved via
resolveSchedule (dated config, rule 3); each fires once via the idempotency key
- F7 catch-up guard: scan gaps and the monthly→dN cutover enqueue only the
highest crossed milestone, never the whole ladder
- ctx.daysOverdue populated in reminderContext (A7: anchored on doc_date) so the
'N day(s) past due' template text renders; injectable today for determinism
- daysBetweenIso helper; 5 tests; suite green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- POST /documents/:id/convert-and-send: guards before any write (proforma-only,
Gmail alive, recipient resolved), ONE txn convert+issue, email strictly after
commit — failed send never rolls back the issued invoice (warning instead)
- supersedeProforma: cancel (number stays consumed) + fresh linked PROFORMA
draft carrying the payload verbatim; INVOICE hard-rejected to credit-note path
- POST /documents/:id/supersede route; DocumentView gains both buttons
- 10 tests; suite 280 green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
- reminder.rule_kind gains 'quote_followup': SCHEMA widened + rebuild-once
migration via the shared rebuildTable helper (UNIQUE idempotency key and
status/policy_applied CHECKs preserved verbatim; guarded, idempotent)
- runDailyScan: new scan block over sent quotations anchored on the first
'sent' event; dated day offsets from resolveSchedule; catch-up guard fires
only the single highest crossed milestone; policy from setting
quote.followup.policy (default manual); auto sends drain strictly after
the scan writes commit, at-most-once via the unique key
- reminderContext resolves (never mints) a live share and dated subject/body;
graceful wording when the quote has no docNo; minting happens ONLY in the
send path with a ~60-day expiry, reusing a live share
- STOP cleanup: markStatus(accepted|lost) and convertDocument dismiss open
quote_followup rows per-row (each audited) in the same transaction
- listQueue: paginated with honest total, owner-scoped via
doc_id -> document.created_by, labelled rows; dashboard + /reminders wired
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 5 (D-PIPE option B, spec §6b/§9): repos-pipeline.ts listPipeline unions the
latest non-cancelled QUOTATION per client with bare leads (Enquiry); stage is never
stored; age anchors on the first 'sent' document_event; next action + colour band
read resolveSchedule('quote_followup') day_offsets (no hardcoded 3/7/14); staff are
server-forced to their own rows via ownerScope; Lost hidden unless filtered; sorted
actionable-oldest-first with LIMIT/OFFSET + honest total. GET /pipeline route plus
the Pipeline page (filter chips, managerial owner dropdown, band row tones, lifecycle
action buttons, pager) wired into nav right after Dashboard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ORDER BY effective_from DESC alone picks an engine/plan-dependent winner
when two reminder_schedule rows for the same rule_kind share an
effective_from (a same-day cadence correction under rule 3's
new-row-never-edit makes this likely). Append id DESC — UUIDv7 ids are
time-ordered, so the latest insert wins deterministically on SQLite and
Postgres alike.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings on the Phase 1-2 employee slice:
- setEmployeePassword now deletes the employee's sessions in the same
transaction — resetting a suspected-compromised password must not leave
the attacker's existing 14-day bearer token valid (deactivateEmployee
already did this; the reset path is the natural lockout action).
- updateEmployee throws 'Nothing to update' when the patch resolves to
zero recognized fields, instead of returning 200 and writing a no-op
before==after audit row; PATCH /employees/:id with {} or wrongly-typed
fields now 400s.
- createEmployee pre-checks UNIQUE(email) inside the transaction and
throws 'Email already in use' instead of surfacing the raw
engine-specific 'UNIQUE constraint failed: staff_user.email' text
(would differ on Postgres, the locked production engine — D15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
reminder_schedule (id, rule_kind, effective_from/_to, day_offsets CSV,
subject, body) added to SCHEMA (CREATE TABLE IF NOT EXISTS, exec'd on every
open = the additive migration path). resolveSchedule(db, ruleKind, today)
picks the row active on the business date (from<=today, to NULL or >today,
latest from wins) with code-constant fallbacks (quote_followup 3/7/14 +
spec s7 subject/body; invoice_overdue 7/15/30). seedIfEmpty seeds one
open-ended dated row per kind, audited in the same transaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>