`npm run backup` ran scripts/backup.mjs, which read process.env.DATABASE_URL
directly and exited when it was unset. On the production box DATABASE_URL is NOT
exported -- the connection is resolved (D19) -- so the backup command could never
reach the real data.
The dump now lives in apps/hq (src/backup.ts + a thin scripts/backup.ts CLI, the
same split the other maintenance scripts use, so it is typechecked and unit
tested) and resolves its target through the shared resolveDbUrl. It logs the
resolved host/db -- never the password -- before pg_dump runs, and a SQLite
outcome is a loud error naming backup-hq.sh instead of a dump of nothing.
Behaviour otherwise unchanged: -Fc dump under ./backups (or HQ_BACKUP_DIR),
rotation to the newest HQ_BACKUP_KEEP (default 14), same pg_dump discovery.
The .mjs entry could not import the TS resolver, and duplicating the resolution
would have re-introduced the copy of the production connection string that
6f50799 deliberately removed -- hence the move to TS. `npm run backup` is
unchanged for the operator; prod already runs `npx tsx apps/hq/scripts/...` for
maintenance (see DEPLOY-HQ.md), so tsx is on the box.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs in the same tick path.
1. installed_on / trained_on were written only as a side effect of a status
UPGRADE. The ~300 APEX-imported projects are already at 'live' -- the furthest
status -- so ticking Installation or Training upgraded nothing and therefore
recorded nothing, and the UI's own date writers were removed in the Client
Detail redesign, leaving those columns unfillable and the Module Directory's
"Installed" column blank forever. The date is now stamped whenever the step is
ticked, independent of any status move: same transaction, same audit payload,
still write-once (an existing date is never overwritten) and still no downgrade.
A tick that changes neither status nor a date now writes nothing at all.
2. setMilestone wrote payment_id/document_id as `done ? linked : null`, so any tick
that did not resend the ids cleared them. bulkSetMilestone routes through it and
never passes ids (one key, many projects), so a single bulk re-tick silently cut
every project's step loose from the payment or quotation it had been linked to.
An already-done step now keeps the link it carries when nothing is supplied.
An explicit empty id is still an unlink, and unticking still clears both.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
apply-onboarding-pipeline.ts documented its own command with no `DATABASE_URL=`
prefix -- the only hardened data script missing it. Followed literally on the
production host it resolves to SQLite, not the real database. Header corrected to
match its siblings.
The deeper problem is that landing on SQLite was a DEFAULT, not a decision: with no
DATABASE_URL, resolution quietly returned '' and the run was only saved by the
"never CREATE a database" guard -- which passes as soon as any stale ./data/hq.db
exists under the operator shell's cwd. requireDbUrlForEnv now refuses a SQLite
outcome unless it was explicitly asked for (HQ_DB_TARGET=sqlite, a --sqlite flag,
or HQ_DATA_DIR=:memory:), and the error names both escape hatches. The create guard
is unchanged and still applies after the opt-in. server.ts calls resolveDbUrl
directly, so first-boot DB creation is untouched.
All five maintenance-script headers now state the SQLite opt-in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
client_module.created_at was added and stamped in assignModule, and
stalledProjects now correctly excludes no-tick projects whose created_at
is NULL — but the APEX importer (the path that created the real ~300
clients' projects) inserted client_module WITHOUT created_at. Every
imported project with zero ticks was therefore permanently invisible on
the "Onboarding stalled" tile, exactly the population it exists for.
Three parts:
- Stamp created_at in the APEX importer's client_module insert.
- Backfill existing NULL rows from installed_on where known (both
db.ts migrate() for SQLite and migrations-pg.ts for Postgres); never
fabricate a date when installed_on is also unknown.
- stalledProjects now returns { rows, unknownAgeExcluded } instead of a
bare array, so projects excluded for unprovable age are counted, not
silently dropped. GET /projects/stalled responds with
{ ok, projects, unknownAgeExcluded }; UI wiring left to the parallel
agent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- packages/ui/src/tokens.css: bound .wf-table-wrap to a viewport-derived max-height
and matching overflow-y so it's a real scroll container again. overflow-x: auto
alone left overflow-y at its computed "auto" (per the CSS overflow spec, a
visible/non-visible axis pairing promotes visible to auto) with no height bound,
so its scrollTop was permanently 0 and table.wf th's `position: sticky` never had
anything to stick against — the header just scrolled away with the page on every
table screen. Chose the bounded-wrapper fix (over deleting the dead sticky rule)
since it keeps the header pinned as advertised; short tables render unchanged,
long ones scroll inside the box exactly like the existing .dash-list pattern.
Also moved the .wf-table-wrap rule out of apps/hq-web/src/app.css into tokens.css
next to the other table.wf rules, since it's part of the @sims/ui component.
- apps/hq-web/src/pages/Dashboard.tsx: the "Onboarding stalled" card treated
"still loading" the same as "genuinely empty", flashing "Nothing stalled."
before the fetch resolved. Card now takes a loading prop and shows the usual
skeleton placeholder while pending; the positive empty copy and error state
are unchanged.
- apps/hq-web/src/pages/ClientDetail.tsx: getTicketTypes was fetched unconditionally
on every Client 360 load for the tickets tab's Type column. Gated it behind the
tickets tab being active (moved the tab computation earlier so it's available to
gate the loader); NewTicketDialog's own getTicketTypes call for the new-ticket
pre-fill is untouched.
- STATUS.md: prose under the verification table still said "The 416 figure..." while
the table itself says 528 — updated the prose to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alreadyNew was set false for EVERY row whose key wasn't a template key,
including custom_* rows that the same function deliberately preserves
verbatim. So a project with even one ad-hoc "+ Add step" entry was
never "identical" and got DELETE-and-rebuilt on every single run (new
row ids, a fresh audit row each time) — contradicting the documented
"safe to re-run / a second run does nothing".
Idempotency is now computed over non-custom rows only, using the same
custom_* discriminator addProjectMilestone's key generation relies on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guard only threw when NODE_ENV==='production' AND resolution came
back empty. But NODE_ENV is exactly what isn't reliably set for the
maintenance scripts this guards (they're run by hand from an operator
shell with no service-definition env and no dotenv), so on the real
prod box the guard never fired: resolution silently returned '', the
script opened a brand-new empty SQLite file under <cwd>/data, and
printed a false "0 project(s) rebuilt" success.
Now the guard fires on the OUTCOME instead: whenever resolution
selects SQLite, refuse to run unless the target file already exists —
a migration/maintenance script must never CREATE a database. Applies
only to requireDbUrlForEnv (the script entry path); server.ts calls
resolveDbUrl directly and is untouched, so first boot still creates
its DB as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test never connects to Postgres, it only asserts resolution
behaviour, so the real prod URL had no reason to be duplicated here.
Swapped for a dummy postgres://u:p@h:5432/db throughout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same pre-ship audit fix as apply-onboarding-pipeline.ts and migrate-onboarding-
templates.ts: replace the raw `DATABASE_URL ?? ''` read with the shared
requireDbUrlForEnv() resolver, log the resolved engine/target before mutating,
hard-fail instead of silently opening a throwaway SQLite file in production, and
close the DB handle in a finally block.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-ship audit swarm findings on the Client Detail redesign:
- StatusLine's isPaymentStep now also matches the legacy advance_paid/
balance_paid keys (the generic fallback template's vocabulary on modules
with no seeded per-module tail — WHATSAPP, ATM, AMC, ...), so ticking the
payment step there opens the picker instead of silently stamping a date.
- Every milestone-tick path (plain toggle, payment link, credentials,
document link) now reloads the client-modules list so the module block
header, summary table and status select never go stale after a tick.
- A done payment/document milestone step now surfaces its link (linked
document -> /documents/<id>; linked payment -> date/amount from the
loaded ledger) instead of showing no evidence the link landed.
- Tickets: guard TICKET_STATUS_LABEL lookups against non-status chip
values (e.g. the synthetic "overdue" chip) that were throwing and
blanking the whole console.
- Dashboard: distinguish a failed "Onboarding stalled" fetch from a
genuine empty result instead of reporting "Nothing stalled." on error.
- Client 360 tickets tab: add the Type column, same rendering as the
Tickets desk.
- Drop the redundant read-only status badge next to the editable
ClientModuleStatusSelect in the module block header.
- Only mount NewTicketDialog while actually open, so it stops firing
getModules/getTicketTypes on every Client 360 load.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FIX D: stalledProjects treated a no-tick project's missing done_on as the
sentinel '0000-00-00', always older than any cutoff, so every
freshly-assigned project immediately showed up on the "Onboarding stalled"
tile. It's now measured against client_module.created_at (stamped by
assignModule); when that's also unknown (predates the column, or an import
path that doesn't stamp it) the project is excluded rather than assumed
ancient.
FIX E: STEP_STATUS only mapped the new template's keys, but TAIL_FALLBACK
(any module with no seeded/owner-configured tail -- WHATSAPP, ATM, AMC, ...)
still uses the legacy 'installed' key, so ticking "Installation done" on
those modules left status at 'quoted' forever. Added installed->installed
to STEP_STATUS (checked: no legacy payment key needs a mapping, since
payment never drove status even in the new vocabulary).
FIX F: bulkSetMilestone did a bare UPDATE, so a bulk go_live never advanced
client_module.status and a bulk untick never cleared a stale
payment_id/document_id link. Each id now routes through setMilestone itself
(same status-advance + link-clearing path a single tick uses), inside the
existing transaction -- nested transaction() calls become savepoints on
both engines, so a project without the milestone key is caught and skipped
without losing "bulk ops only touch projects that have the milestone".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
advance_paid/balance_paid (and the interim advance_payment/balance_payment)
all collapse onto the single new payment_received key, and the carry used
Map.set -- whichever row was visited second silently overwrote the first's
done_on/payment_id, counted as neither carried nor dropped, with the audit
`before` left `undefined` so the loss was unreconstructible.
pickSurvivingTick() now picks deterministically (prefers the tick with a
real linked payment_id; otherwise the later done_on), the losing tick is
counted in a new `collapsed` field, and the audit `before` payload is now
the full pre-migration row set instead of `undefined` -- any discarded tick
is reconstructible from the append-only log.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /projects/stalled had no try/catch, and a caller-supplied ?days= flowed
straight into `new Date(...).toISOString()`. Confirmed the exact crash:
Number('1e300') passes Number.isFinite (so the old guard let it through),
but the resulting cutoff math overflows the valid Date range and
.toISOString() throws RangeError. Uncaught in an async Express handler this
is an unhandled rejection, which kills the whole process on Node 22 -- any
authenticated role, including plain staff, could take the console down with
one GET.
`days` is now validated (integer, 0-3650) with an explicit 4xx on anything
hostile, falling back to the configured default only when the param is
absent (matches the frontend, which never sends ?days= at all). Audited
every other route this feature added to the same section: /projects/board
and /projects/pending/:key had the identical no-try/catch shape and are now
wrapped too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
apply-onboarding-pipeline.ts and migrate-onboarding-templates.ts resolved the
engine as `DATABASE_URL ?? ''`, diverging from server.ts's production
fallback. On the prod box (DATABASE_URL unset) each script opened a brand-new
empty SQLite file, "succeeded" against it, and Postgres was never touched.
resolveDbUrl()/requireDbUrlForEnv()/pgTargetDescription() now live in
db-pg.ts as the one place this is decided; server.ts and both scripts call
it. The scripts additionally hard-fail (never fall back to SQLite) when
NODE_ENV=production resolves nothing, log their target engine before doing
any work, and close the DB handle in a finally.
Also lands client_module.created_at (nullable, additive) — schema groundwork
FIX D uses to measure a no-tick project's age from its real creation date
instead of treating a missing done_on as infinitely old.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Red-team pass on the redesigned Client 360 / Tickets / Dashboard / Modules screens found
real overlap/clipping bugs, all fixed:
- Client header still broke at 768px (tablet): the button cluster only stacked below the
name under 640px, so at 768 the name got squeezed to ~38px and force-wrapped mid-word.
Bumped the stacking breakpoint to 860px.
- Per-module Documents rows and the Payments Outstanding-panel rows were plain nowrap flex
rows with no scroll; at 375px the amount/status badge silently clipped past the card
edge with no way to reach them. Both now wrap.
- Biggest one: every DataTable silently lost its rightmost columns at desktop width
(>=901px) — table.wf's display:block/table toggle assumed overflow-x:auto still worked
once display flipped to table, but Chromium computes overflow-x as visible on a
display:table box, so wide tables (Tickets' 11 columns) overflowed the viewport with no
scrollbar. Fixed at the root: DataTable now wraps its <table> in a .wf-table-wrap scroll
container instead of relying on the table element to scroll itself.
Verified against screenshots at 375/768/1440, light+dark, before and after; typecheck and
the full test suite (498 passed) stay clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final whole-branch review FIX 2.
The live-deploy script upserted the onboarding template settings with raw
SQL (ON CONFLICT DO UPDATE), bypassing setSetting -> no audit row, violating
the append-only-audit rule -- and it unconditionally overwrote templates an
owner may have already edited in the Modules onboarding-template editor,
making the "Idempotent" header comment false and dangerous for a script that
runs against ~167 real projects.
Upserts now go through setSetting(db, 'system', key, value) (audited), and
only write a key that is still absent, unless run with an explicit --force
argument (documented). Header comment corrected to say idempotency holds
only while the templates haven't been owner-edited, and that --force
overwrites unconditionally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final whole-branch review FIX 3.
migrateOnboardingTemplates previously DELETEd every project_milestone row and
reinserted only template keys, so a project-specific step added via
addProjectMilestone ("+ Add step", custom_* keys) vanished with no trace if
un-ticked, or was miscounted as `dropped` if ticked.
Old rows are now partitioned before the DELETE into template-mapped vs
ad-hoc custom (not a template key, not in KEY_MAP, and not a known old
generic FRONT_FALLBACK/TAIL_FALLBACK key like amc_start — those keep their
existing drop behavior). Custom rows are re-inserted unchanged after the
template block, sort continuing past it, preserving
label/done/done_on/payment_id/document_id. The return type and the audit
`after` payload both gained a `preserved` count/list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final whole-branch review FIX 1 + FIX 5.
- setFrontTemplate / setModuleTail now reject a key that collides across the
front/tail halves (checked against the default tail, every configured
per-module tail, and the shared front respectively) instead of letting
milestoneTemplate silently compose a duplicate-key list.
- ensureProjectMilestones adds each inserted key to its own `existing` set
inside the loop, so a template that still ends up with a duplicate key
(a pre-existing bad setting) is deduped instead of hitting the
UNIQUE (client_module_id, key) violation a second time.
- GET /client-modules/:id/milestones now wraps ensureProjectMilestones +
listProjectMilestones in try/catch -> 400, matching its POST sibling,
instead of an unhandled async rejection that hung the request.
- advanceStatusForStep now stamps client_module.installed_on / trained_on
from the triggering milestone's own done_on the first time status
advances into 'installed' / 'trained' (never overwriting an existing
date), replacing the UI date-writers the Client Detail redesign removed
without a replacement. Reflected in the same audit row as the status
change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The status-line payment picker only fired for advance_payment/balance_payment,
which no shipped template contains (the composed tails and the migration both
standardise on payment_received) — so payment linking was unreachable and
project_milestone.payment_id was never written by the UI. Keeps the legacy keys
for projects not yet migrated. Also refreshes the stale STATUS test count.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend half of the Client Detail redesign follow-ups (#7, #6, #4).
#7 — quotation step links its document
- project_milestone gains a nullable `document_id`, landed on BOTH engines:
a PRAGMA-guarded ALTER in db.ts migrate() and pg migration 016.
- setMilestone(..., doneOn?, paymentId?, documentId?) validates the document
belongs to the same client as the project (mirroring the paymentId check),
stores it on tick and clears it on untick; Milestone gains `documentId`.
- POST /client-modules/:id/milestones accepts `documentId`.
- migrateOnboardingTemplates carries document_id across the template swap so a
linked quotation is not lost.
#6 — ticket classification
- ticket gains `type TEXT NOT NULL DEFAULT 'service'` (db.ts SCHEMA + guarded
ALTER, pg migration 017); imported APEX rows fall to the default.
- Config over code: the list is the `ticket.types` setting (seeded on first
boot) resolved by ticketTypes(), with TICKET_TYPE_FALLBACK in code.
- GET /tickets/types; create/update accept + validate `type`; list rows carry
it and GET /tickets?type= filters (blank/omitted = every type).
#4 — cleanup
- Refreshed the stale file comment atop repos-milestones.ts and the
project_milestone comment in db.ts: both now describe the composed
front + per-module tail model (they still described the old flat template).
- Migration tests for the interim keys advance_payment / balance_payment ->
payment_received, plus the document-link carry.
npm run typecheck clean; npm test 473 passed / 5 skipped (was 453/5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frontend half of two Client-Detail follow-ups, plus the StatusLine doc refresh.
Quotation step -> document link:
- Milestone gains documentId; setMilestone threads an optional trailing
documentId (contract: doneOn, paymentId, documentId).
- Ticking `quotation_sent` on the status line now opens DocumentPickerDialog
instead of stamping a bare date. It mirrors PaymentPickerDialog exactly —
same state shape in ClientDetail, same Dialog props — and lists the client's
QUOTATION/PROFORMA documents straight from the already-loaded ledger (no
extra fetch), with "Create new document…" handing off to the composer.
Ticket classification:
- Ticket gains `type`; getTicketTypes() reads the `ticket.types` setting
(config over code) with a code fallback only for the load window.
- New-ticket dialog gets a Type select; Tickets page gets Type filter chips
wired to the ?type= list param and a Type column.
- Every ticket's module cell deep-links to /clients/<id>?tab=modules, and each
Client 360 module block gets "Raise ticket for this module", which opens the
new-ticket dialog pre-filled with that client + module code.
Also refreshes the now-stale StatusLine JSDoc so it describes all three
hand-off dialogs — payment, credentials (account_creation) and document.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The outstanding panel never rendered a footer row — the rule has had no
matching markup since the panel landed. Removing it keeps app.css honest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces stalledProjects() on the Dashboard bento grid, listing active
projects parked past the stall threshold; each row jumps to the
client's Modules tab.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds owner-only, audited routes for the two config settings milestoneTemplate
composes (project.milestone_template.front and project.milestone_template:<CODE>),
plus a Modules page section to edit them: staged label rows with add/remove/
reorder/Save, keys auto-derived from labels when blank, uniqueness enforced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ticking the "Account creation & registration" onboarding step now opens a
small credentials dialog (mirrors the existing payment-step picker) instead
of a bare toggle. Saving reuses ServiceDataCard's exact patchClientModule
call and managerial password gate, then marks the milestone done; "Skip"
marks it done without touching credentials so the step never hard-blocks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RecordHeader's action-button cluster crowded/overlapped the client name on
narrow phones, and DataTable (Tickets, etc.) ran wider than the viewport,
clipping columns with no way to reach them.
Add a <=640px media query: .wf-rec wraps so .wf-rec-actions drops to its
own full-width row below the avatar/name/meta; desktop layout is untouched
outside the query. Wrap DataTable's <table> in a new .wf-table-wrap div
that scrolls horizontally on overflow at that breakpoint (nowrap cells so
the table can exceed 100% instead of being squeezed illegibly), keeping
the page body itself from scrolling sideways.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AssignModuleForm: remove SMS-forced username/password gate at assign time
(creds now captured later via module Access -> Edit); reword to "Add a
module" with a one-line hint on what assigning starts.
- Modules summary table: drop Installed/Completed/Trained columns (dates
live in the per-module status line); keep Module/Kind/Status/Billed/Settled.
- Each module's collapsible block now has an inline ClientModuleStatusSelect
next to its status badge, so status is editable without opening the table.
- Payments tab: remove the Outstanding panel's duplicate "Advance on
account" footer line -- the pre-existing StatCard is the single source.
- Remove RowDate, now unused after the summary-table trim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes the flat per-module onboarding templates: milestoneTemplate(db, code)
now returns [...front, ...tail]. front is a shared setting
(project.milestone_template.front, FRONT_FALLBACK code default: enquiry ->
visit/meeting -> quotation sent -> quotation approved) prepended to every
module's checklist. tail resolves per-module setting -> global setting ->
TAIL_FALLBACK (the old generic list), unchanged in precedence.
- STEP_STATUS gains quotation_approved -> ordered (never-downgrade guard kept).
- seed.ts seeds the front once plus SMS/RTGS/MOBILEAPP tails (advance/balance
payment steps collapsed into a single payment_received tail step); no longer
seeds a bare project.milestone_template default.
- migrate-onboarding-templates.ts KEY_MAP updated so advance_paid, balance_paid,
advance_payment and balance_payment all carry forward to payment_received;
its idempotency check holds against the longer composed list.
- Updated seed-templates, milestones-templates, milestone-status,
migrate-onboarding-templates, milestones and milestone-payment tests to the
composed content/mappings (TDD: reverted the implementation, confirmed the
updated tests red, restored the implementation, confirmed green).
Backend + tests only; the live-DB migration run is a separate step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the work since the last doc pass: employee master import + invited/first-login
flag (D34), client type PACS/Store + directory-sourced LTD numbers and the name-correction
session (D35), login history with IP/device (D36), and the society category matching the
Kerala co-op directory taxonomy (D37). Adds the login_event table, the Login history
screen, and the client type/category fields; bumps the schema table count 31->32. Decisions
D34-D37 appended to 06-DECISIONS.md.
Records the work since the last doc pass: employee master import + invited/first-login
flag (D34), client type PACS/Store + directory-sourced LTD numbers and the name-correction
session (D35), login history with IP/device (D36), and the society category matching the
Kerala co-op directory taxonomy (D37). Adds the login_event table, the Login history
screen, and the client type/category fields; bumps the schema table count 31->32. Decisions
D34-D37 appended to 06-DECISIONS.md.
Adds stalledProjects(db, olderThanDays, today) to repos-milestones.ts:
active projects with a pending step whose last progress (MAX done_on,
or module creation if nothing ticked) is older than N days. GET
/projects/stalled reads ?days= with a project.stall_days setting
fallback (default 30).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Threads an optional `source` through DraftInput/insertDocRow (default 'hq',
preserving current behaviour when omitted), tags module renewals as
'module_renewal', and adds generateBulkSmsPurchaseQuote — an ad-hoc bulk SMS
credit top-up priced via the existing usage rate card, tagged
'bulk_sms_topup' so the client-detail document list can distinguish it from a
subscription renewal. New route: POST /client-modules/:id/bulk-sms-quote.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
clientLedger now attaches allocations (docNo + amount) to each payment and
returns an outstanding[] list — issued invoices with outstandingPaise > 0
plus open (draft/sent/accepted) proformas, oldest-first by docNo. Read-only
extension over the existing settlement math (outstandingPaise, allocations);
no money/allocation logic changed. Mirrors the widened Payment/Ledger shapes
onto apps/hq-web/src/api.ts for the upcoming Payments tab.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add one test case to cover the scenario where old milestone keys that
don't map to the new template (amc_start, setup_done for SMS) are ticked
and should be counted in the migration's dropped tally. Verifies both
dropped and carried counts are accurate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Idempotent migrateOnboardingTemplates(db): for every client_module whose module
has a per-module onboarding template (task 2), rebuilds its milestone rows from
that template, carrying mapped ticks (installed->installation, go_live->go_live,
advance_paid->advance_payment, balance_paid->balance_payment, setup_done->
setup_configuration when present) with done_on and payment_id intact. Unmapped
ticks (amc_start, or setup_done where the new template has no matching step) are
dropped and counted. Audited per project; a second run is a no-op via an
identical-key-set short-circuit. Adds the CLI wrapper (mirrors
apply-name-fixes.ts's open-DB idiom, Postgres or SQLite) for the real one-time
run against production data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>