Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
25 KiB
25 KiB
Scratchpad — Client-Owned Contracts Simplification
- Plan slug:
client-owned-contracts-simplification - Created:
2026-03-16
What This Is
Keep a lightweight, continuously-updated log of discoveries and decisions made while implementing this plan.
Prefer short bullets. Append new entries as you learn things, and also update earlier notes when a decision changes or an open question is resolved.
Decisions
- (2026-03-16) Target model: keep
contracts, but make every non-template contract client-owned; templates remain the only reusable contract-definition layer. - (2026-03-16) Invariant choice: one owning client per non-template contract, not one assignment row ever.
- (2026-03-16) Enforcement choice: add
contracts.owner_client_idinstead of inferring ownership only fromclient_contracts. - (2026-03-16) Migration strategy for shared contracts with invoice history: preserve the original
contract_idon the invoiced assignment and clone for the other assignment(s). - (2026-03-16) Migration strategy for shared contracts without invoice history: preserve the original
contract_idon the earliest-starting assignment and clone for the other assignment(s). - (2026-03-16) Scope choice: this pass includes the data migration plus backend guardrails and targeted UI/reporting cleanup needed to stop reinforcing the shared-contract model.
- (2026-03-16) Migration sequencing: keep the existing
20260316120000_add_contract_owner_client_id.cjsschema migration forowner_client_id, and run the shared-contract split as a follow-up migration (20260316121000_client_owned_contracts_simplification.cjs) so Knex ordering is deterministic. - (2026-03-16) Safety boundary: clone-target invoice history is allowed and handled by preserved-assignment selection plus
client_contractsrepointing; only contract-scoped docs, pricing schedules, and contract-line history (time_entries/usage_tracking) are treated as unsupported retargeting for this migration helper pass. - (2026-03-16) First implementation batch landed the owner-client invariant in creation/assignment code before the shared-contract split migration: nullable schema column first, code guardrails now, stricter DB not-null/backfill later once migrated data is safe.
- (2026-03-16) Legacy
/contractsAPI create path should fail at validation/service time whenowner_client_idis absent, not silently create another shareable non-template header. - (2026-03-16) Live-status choice: treat
client_contractslifecycle as the contract-facing truth and surface assignment-derived status explicitly in billing loaders/UI instead of recalculating or mutatingcontracts.statuson read. - (2026-03-16) Detail-routing choice: resolve client-contract detail from
clientContractIdfirst and backfillcontractIdinto the URL only for header/line/document lookups, keeping live detail assignment-first without rewriting all existing contract loaders at once.
Discoveries / Constraints
- (2026-03-16) Billing engine already reads through
client_contracts -> contracts -> contract_linesand filters by assignment truth (cc.is_active, assignment dates), which is compatible with the target model as long as cross-client sharing is eliminated. - (2026-03-16)
ClientContractsTabcurrently renders status fromcontracts.status, which is why live UI can disagree with billing behavior. - (2026-03-16)
contractsandclient_contractsboth currently carry lifecycle-ish fields, and reports still read “active contracts” fromcontracts.is_active. - (2026-03-16) Schema currently does not prevent a single non-template
contract_idfrom being linked to multiple clients. - (2026-03-16) The contract wizard already behaves like contracts are client-specific instantiated records and includes a comment that old replication became redundant because contracts are “already client-specific via client_contracts.”
- (2026-03-16) Direct/legacy backend paths still need explicit owner-client guardrails:
packages/billing/src/actions/contractActions.ts#createContractforwards raw non-template contract creation without owner semantics.server/src/lib/api/services/ContractLineService.ts#createContractinserts rawcontractsrows directly.packages/clients/src/actions/clientContractActions.ts#createClientContractvalidates contract existence and active state but not client ownership.packages/billing/src/actions/renewalsQueueActions.ts#createRenewalDraftForQueueIteminserts draftcontractsrows directly and must carry owner client onto the draft.
- (2026-03-16) Contract-backed client line helpers in both billing and clients packages still derive lines by joining
client_contractstocontract_linesoncontract_id; they become safe only once shared non-template contracts are eliminated and owner-client enforcement exists. - (2026-03-16)
ContractDetailandContractDetailSwitcherstill usecontractIdas the primary live-contract identity and show assignment status fromcontract.status, so detail routing and status presentation both reinforce the old shared-contract model. - (2026-03-16)
ClientContractsTaband the top-levelContractshub both mutate live state throughupdateContract(...status...), so assignment lifecycle actions are still wired to contract-header status changes. - (2026-03-16) Client portal document visibility for contract-linked documents still resolves ownership by joining
document_associations -> contracts -> client_contracts, so this path needs an explicit regression check after shared contracts are split. - (2026-03-16) Production has 5 inactive
client_contractsrows remaining after flipping the previously investigated ConnectWise row active; none of those appear to be historical predecessors of currently active rows. - (2026-03-16) Production has exactly 2 genuinely shared non-template contracts with multiple contract lines and multiple distinct client assignments:
Managed IT Servicesin tenantCross Industries, LLCWorry-Free Essentialsin tenantWorryNot Works IT Services
- (2026-03-16) Accurate blast radius for the two known shared contracts:
- both have 2 client assignments and 2 contract lines
- neither has pricing schedules
- neither has document associations
- neither has direct
time_entriesorusage_trackingtied to those contract-line IDs - only
Managed IT Serviceshas invoice history, and only on theThe Green Thumbassignment
- (2026-03-16) Contract revenue reporting had one more legacy seam after the live-status UI work:
packages/billingaction code was already mostly assignment-aware, but both report-definition copies (serverandpackages/reporting) still counted live contracts fromcontracts.is_active. - (2026-03-16) The
/api/v1/contractsand/api/v2/contractssurface had drifted behind the new ownership model: the controller still advertised a generic contract list and the list implementation was stubbed. The fix was to return real non-template headers filtered toowner_client_idand exclude templates from that resource entirely. - (2026-03-16)
docs/billing/billing.mdstill describedcontractsas a reusable sellable library. Updated it to make templates the only reusable layer,contractsthe client-owned header table, andclient_contractsthe live lifecycle table. - (2026-03-16)
BillingEngine#getClientContractLinesAndCycleremains the core post-migration resolution path for invoiceable contract lines;T021now covers preserved-vs-cloned assignments by asserting the same client can only resolve the contract-line set attached to its currentclient_contracts.contract_id. - (2026-03-16)
server/src/test/unit/billingEngine.test.tshas broader stale harness failures around the newercalculateMaterialCharges()query shape, so the reliable validation forT021is a targeted Vitest run of the new assignment-resolution regression instead of the whole legacy file. - (2026-03-16) The reliable harness for contract-wizard draft resume coverage is a server-side action test with mocked
createTenantKnex/auth/repository seams; package-localpackages/billingVitest still misses the shared@alga-psa/dbexport wiring for this branch. - (2026-03-16) The renewals queue draft-link regression is likewise best covered from
serverby mocking the transactional knex insert/update path directly; it verifies the owner-client invariant without depending on the package-local alias setup. - (2026-03-16)
ContractLineService.addContractLine()is the highest-signal API mutation seam for post-migration contract-line safety: it validates the target contract and source line insidewithTransaction()before writing the contract-owned mapping, so a focused service unit test covers the legacy/contracts/:id/linespath without needing the full controller stack. - (2026-03-16) Server Vitest coverage output in this worktree can fail on targeted one-file runs when
coverage/.tmpis missing, even if assertions pass; rerunning the narrow check with--coverage.enabled falseis the reliable workaround for checklist validation. - (2026-03-16)
server/src/test/infrastructure/billing/invoices/contractInvoiceManualCredit.test.tsis the right DB-backed harness for cloned-contract invoice validation, but this workspace does not currently have the test Postgres on127.0.0.1:5432, soT022could only be implemented and smoke-loaded, not executed end-to-end here. - (2026-03-16) The migration helper file is strong enough to model the two known production shared-contract scenarios directly.
server/src/test/unit/migrations/clientOwnedContractsSimplificationMigration.test.tsnow uses named fixtures forManaged IT ServicesandWorry-Free Essentialsto cover preserved assignment choice, clone-target repointing, and clone-line count preservation without needing a live database. - (2026-03-16)
T032is covered at the action layer inserver/src/test/unit/contractReportActions.sharedContractResults.test.ts, which mocksgetContractRevenueReport()inputs as the two known post-migration shared-contract splits and asserts four client-owned rows come back instead of two shared-contract rows. - (2026-03-16) Client portal contract-linked document visibility still inferred ownership through
client_contracts. Updated all 3 portal contract-document branches to resolve throughcontracts.owner_client_idand explicitly exclude templates, so stale shared assignment rows cannot leak documents. - (2026-03-16) The duplicated
ClientContractLinehelper models inpackages/billingandpackages/clientsstill trustedclient_contracts -> contract_linesbycontract_idalone. Addedcontractsjoins plusowner_client_idand non-template guards on overlap and listing queries so contract-derived helper rows cannot bleed across clients. - (2026-03-16) The branch already had
F001/F002/F012-F015runtime wiring in place:IContract.owner_client_id, billing dialog/wizard ownership writes, renewal draft ownership propagation, client-assignment ownership checks, and API schema/service ownership validation were already committed before the migration/helper batch. - (2026-03-16) The missing implementation gap was the shared-contract split itself. Added:
server/migrations/20260316121000_client_owned_contracts_simplification.cjsserver/migrations/utils/client_owned_contracts_simplification.cjsserver/src/test/unit/migrations/clientOwnedContractsSimplificationMigration.test.ts
- (2026-03-16)
ContractDialog,createClientContractFromWizard, andcreateRenewalDraftForQueueItemwere all writing rawcontractsrows withoutowner_client_id; those paths now stamp the client owner explicitly. - (2026-03-16)
ClientContract.assignContractToClientandcreateClientContractneeded separate ownership checks because they do not share a single repository helper today. - (2026-03-16)
updateClientContractalready ignoredcontract_idchanges; guardrail now rejects cross-client repoint attempts explicitly instead of silently dropping them. - (2026-03-16)
Contract.getAllWithClientswas still contract-first and still calledcheckAndUpdateExpiredStatus, so the live client-contract list was mutating header state and renderingcontracts.statusinstead of assignment lifecycle. - (2026-03-16)
ContractDetailalready fetched assignment summaries, but the header/overview panels and list actions still treatedcontracts.statusas the visible live status andupdateContract(...status...)as the lifecycle mutation path. - (2026-03-16) Renewal/default-date normalization in both
shared/billingClients/clientContracts.tsandpackages/clients/src/models/clientContract.tsalso needed to stop suppressing renewal dates based oncontract_status; inactive assignment state is the correct lifecycle gate for those computed fields.
Commands / Runbooks
- (2026-03-16) Find the billing-engine contract-line resolution path:
rg -n "getClientContractLinesAndCycle|Found .* contract lines for client|client_contracts as cc" packages/billing server packages/clients
- (2026-03-16) Inspect contract vs assignment status/UI usage:
rg -n "contracts.status|client_contracts.is_active|ClientContractsTab|getContractsWithClients" packages server
- (2026-03-16) Production query used to find inactive assignment rows:
- query
client_contractsforis_active = false, grouped by tenant/client/contract
- query
- (2026-03-16) Production query used to find genuinely shared contracts:
- group
client_contractsby(tenant, contract_id)and filter tocount(distinct client_id) > 1
- group
- (2026-03-16) Production query used to measure blast radius:
- join shared contract targets to
client_contracts,contracts,contract_lines,invoices,time_entries,usage_tracking,document_associations, andcontract_pricing_schedules
- join shared contract targets to
- (2026-03-16) Structural plan validation:
python3 /Users/roberisaacs/.codex/skills/alga-plan/scripts/validate_plan.py ee/docs/plans/2026-03-16-client-owned-contracts-simplification
- (2026-03-16) Verify assignment-first report wiring:
cd packages/billing && npx vitest run tests/contractReportActions.revenue.assignmentFact.test.ts tests/contractReportActions.summary.wiring.test.ts tests/contractReportActions.expiration.wiring.test.tscd server && npx vitest run src/test/client-owned-contract-report-definitions.test.ts
- (2026-03-16) Verify contracts-resource semantics and server compile health:
cd server && npx vitest run src/test/client-owned-contracts-resource-semantics.test.ts src/test/client-owned-contract-report-definitions.test.tsnpx tsc --noEmit -p server/tsconfig.json
- (2026-03-16) Verify client-owned portal/helper invariants:
cd packages/billing && npx vitest run tests/clientContractLine.ownerInvariant.wiring.test.tscd server && npx vitest run src/test/integration/clientPortalDocuments.integration.test.ts
- (2026-03-16) Verify billing-engine preserved vs cloned assignment resolution:
cd server && npx vitest run src/test/unit/billingEngine.test.ts -t "T021: resolves preserved and cloned assignment lines from each assignment contract after migration"
- (2026-03-16) Verify cloned-assignment invoice generation:
cd server && npx vitest run src/test/infrastructure/billing/invoices/contractInvoiceManualCredit.test.ts -t "T022: invoice generation succeeds for a cloned assignment with duplicated contract-line configuration after migration"
- (2026-03-16) Verify known shared-contract migration sanity cases:
cd server && npx vitest run src/test/unit/migrations/clientOwnedContractsSimplificationMigration.test.ts
- (2026-03-16) Verify report results keep known split contracts separated:
cd server && npx vitest run src/test/unit/contractReportActions.sharedContractResults.test.tsnpx tsc --noEmit -p packages/client-portal/tsconfig.jsonnpx tsc --noEmit -p packages/clients/tsconfig.jsonnpx tsc --noEmit -p packages/billing/tsconfig.json
- (2026-03-16) Local validation note:
server/src/test/integration/clientPortalDocuments.integration.test.tsis now updated with real contract-association cases for T045/T046, but it could not run in this workspace because the expected test Postgres on127.0.0.1:5438was not available (ECONNREFUSED). - (2026-03-16) Focused ownership + migration verification:
cd server && npx vitest run ../packages/billing/tests/contract.test.ts ../packages/billing/tests/renewalsQueueActions.createDraft.wiring.test.ts ../packages/clients/src/models/clientContract.ownerGuardrails.test.ts ../packages/types/src/interfaces/contractOwnerClient.typecheck.test.ts src/test/unit/api/contractCreateOwnerClientSchema.test.ts src/test/unit/migrations/clientOwnedContractOwnerMigration.test.ts src/test/unit/migrations/clientOwnedContractsSimplificationMigration.test.ts
- (2026-03-16) Guardrail verification:
npx vitest run tests/contract.test.ts tests/renewalsQueueActions.createDraft.wiring.test.ts(workdirpackages/billing)npx vitest run src/models/clientContract.ownerGuardrails.test.ts(workdirpackages/clients)mkdir -p coverage/.tmp && npx vitest run src/test/unit/api/contractCreateOwnerClientSchema.test.ts src/test/unit/migrations/clientOwnedContractOwnerMigration.test.ts src/test/unit/migrations/clientOwnedContractsSimplificationMigration.test.ts(workdirserver)npx vitest run src/interfaces/contractOwnerClient.typecheck.test.ts(workdirpackages/types)npx tsc -p packages/types/tsconfig.json --noEmitnpx tsc -p packages/billing/tsconfig.json --noEmitnpx tsc -p packages/clients/tsconfig.json --noEmitnpx tsc -p server/tsconfig.json --noEmit
- (2026-03-16) Live-status/UI verification:
npx vitest run tests/contractsHub.templateSeparation.wiring.test.ts tests/clientContractWorkflowEvents.wiring.test.ts tests/contract.assignmentFirst.wiring.test.ts tests/ClientContractsTab.assignmentLifecycle.test.ts tests/ContractDetail.clientOwnedSemantics.wiring.test.ts tests/clientContractStatus.shared.test.ts tests/clientContractEffectiveRenewalSettings.test.ts(workdirpackages/billing)npx vitest run src/models/clientContract.ownerGuardrails.test.ts(workdirpackages/clients)cd server && npx vitest run ../packages/types/src/interfaces/contractOwnerClient.typecheck.test.ts src/test/unit/api/contractCreateOwnerClientSchema.test.ts src/test/unit/migrations/clientOwnedContractOwnerMigration.test.ts src/test/unit/migrations/clientOwnedContractsSimplificationMigration.test.tsnpm run typecheck(workdirpackages/billing)npm run typecheck(workdirpackages/clients)npm run typecheck(workdirshared)
- (2026-03-16) Assignment-resolution/report follow-up verification:
cd server && npx vitest run src/test/unit/billingEngine.test.ts -t "T021: resolves preserved and cloned assignment lines from each assignment contract after migration"cd server && npx vitest run src/test/unit/migrations/clientOwnedContractsSimplificationMigration.test.tscd server && npx vitest run src/test/unit/contractReportActions.sharedContractResults.test.tscd server && npx vitest run --coverage.enabled false src/test/unit/contractWizardResume.clientOwnedDraft.test.tscd server && npx vitest run src/test/unit/renewalsQueueActions.createDraftBehavior.test.tscd server && npx vitest run src/test/unit/api/contractLineService.clientOwnedMutation.test.ts
Progress Log
- (2026-03-16) Completed
F001/T001: added migrationserver/migrations/20260316120000_add_contract_owner_client_id.cjsto add nullablecontracts.owner_client_idwith tenant/client FK and index, without rewriting template rows. - (2026-03-16) Completed
F002/T002: addedowner_client_idto shared/server contract interfaces and API contract create/response schemas; added a type contract test. - (2026-03-16) Completed
F012/T013/T014:Contract.createnow rejects non-template contracts without an owner, while supported billing UI/wizard flows pass the selected client through asowner_client_id. - (2026-03-16) Completed
F013/T015/T016: client-contract assignment create/update now reject non-template contracts whoseowner_client_idis missing or belongs to a different client. - (2026-03-16) Completed
F014/T017: renewal draft creation now copies the source assignment client onto the draft contract header asowner_client_id. - (2026-03-16) Completed
T021: added a billing-engine regression that exercises the realclient_contracts -> contracts -> contract_linesresolution path and proves preserved and cloned assignments resolve different contract IDs and contract-line IDs after the migration split. - (2026-03-16) Completed
T022: added a DB-backed invoice-generation regression that seeds preserved and cloned contract headers with duplicated line configuration and asserts the cloned assignment invoices against fresh cloned contract-line IDs with the same billable totals as the preserved assignment. - (2026-03-16) Completed
T035-T041: added production-like migration helper coverage for the two known shared contracts, proving the invoicedManaged IT Servicesassignment stays preserved, both clone targets move to fresh contract IDs, clone line counts stay aligned, and the preflight assumption of zero unsupported downstream references still holds for those clone targets. - (2026-03-16) Completed
T032: added a report-action runtime test showing the knownManaged IT ServicesandWorry-Free Essentialssplits now appear as four assignment-owned revenue rows, not as two tenant-shared contract rows. - (2026-03-16) Completed
T042: added a server-side draft-resume regression showinggetDraftContractForResume()still returns a client-owned draft contract when the related assignment lifecycle isrenewing, without inferring that live assignment state back onto the draft header. - (2026-03-16) Completed
T043: added a renewals-queue regression showingcreateRenewalDraftForQueueItem()still creates the linked draft contract/client-contract pair after owner-client enforcement and persistsowner_client_idon the draft header. - (2026-03-16) Completed
T044: added a focusedContractLineService.addContractLine()regression proving the API/service mutation path still validates the client-owned contract and source line inside the transaction and writes the expected contract-line mapping for cloned non-template contracts. - (2026-03-16) Completed
F015/T018: legacy API/service contract creation now requiresowner_client_idvia Zod validation plus a service-level runtime check. - (2026-03-16) Completed
F003-F011andT003-T012: addedserver/migrations/20260316121000_client_owned_contracts_simplification.cjsplusserver/migrations/utils/client_owned_contracts_simplification.cjsto detect shared non-template contracts, pick the preserved assignment by invoice/earliest-start rules, clone contract headers/lines/configuration rows for clone targets, repoint assignments, backfillowner_client_id, and fail closed on invoice/document/pricing/time/usage references that would need explicit historical retargeting. - (2026-03-16) Completed
F016/F017/F019-F026andT019/T020/T023-T029: billing live-contract loaders now read assignment-first rows fromclient_contracts, requireowner_client_idfor normal live rows, derive explicitassignment_statusthrough a shared helper, route detail fromclientContractId, render assignment-first status in list/detail/header UI, mutate lifecycle viaupdateClientContractForBilling, and keep templates visibly separate as the only reusable contract-definition path.
Links / References
- Core contract model:
/Users/roberisaacs/alga-psa/packages/billing/src/models/contract.ts - Contract actions:
/Users/roberisaacs/alga-psa/packages/billing/src/actions/contractActions.ts - Contract wizard:
/Users/roberisaacs/alga-psa/packages/billing/src/actions/contractWizardActions.ts - Billing engine:
/Users/roberisaacs/alga-psa/packages/billing/src/lib/billing/billingEngine.ts - Client contract lifecycle helper:
/Users/roberisaacs/alga-psa/packages/clients/src/lib/clientContractWorkflowEvents.ts - Shared assignment status helper:
/Users/roberisaacs/alga-psa/shared/billingClients/clientContractStatus.ts - Client contracts UI:
/Users/roberisaacs/alga-psa/packages/billing/src/components/billing-dashboard/contracts/ClientContractsTab.tsx - Contracts hub UI:
/Users/roberisaacs/alga-psa/packages/billing/src/components/billing-dashboard/contracts/Contracts.tsx - Contract detail routing:
/Users/roberisaacs/alga-psa/packages/billing/src/components/billing-dashboard/contracts/ContractDetailSwitcher.tsx - Contract reports:
/Users/roberisaacs/alga-psa/packages/billing/src/actions/contractReportActions.ts - Report definitions:
/Users/roberisaacs/alga-psa/server/src/lib/reports/definitions/contracts/revenue.ts - Client portal documents:
/Users/roberisaacs/alga-psa/packages/client-portal/src/actions/client-portal-actions/client-documents.ts - API controller/service:
/Users/roberisaacs/alga-psa/server/src/lib/api/controllers/ApiContractLineController.ts,/Users/roberisaacs/alga-psa/server/src/lib/api/services/ContractLineService.ts - Client contract line helpers:
/Users/roberisaacs/alga-psa/packages/billing/src/models/clientContractLine.ts,/Users/roberisaacs/alga-psa/packages/clients/src/models/clientContractLine.ts - Renewal queue actions:
/Users/roberisaacs/alga-psa/packages/billing/src/actions/renewalsQueueActions.ts
Open Questions
- Should a later follow-up fully remove live client semantics from
contracts.status, or is limiting it to draft/header workflow sufficient? - Should
/contractsremain the long-term resource name for client-owned instantiated contract headers, or should a later API cleanup rename that concept?