← all branches

fix/rpu-links

needs attention
e8828c7 · fullPR #319reviewed 2026-07-16 18:19 UTC1H · 2M · 6L · 3I
The branch
Purpose
Implement the 'site links are per-chapter and permanent' rule from the RPU agreements model, fixing bills visibility in Recibos/exports for terminated contract chapters, and hide terminated chapters from the Contratos overview list.
Goal
Per-chapter SUC permanence: terminated/demoted contract chapters keep their site links so their bills remain reachable via the org-scoping path (bills → contract → SUC → site). Contratos list shows only the active (current) agreement per RPU.
Sub-goals
  • SG-1: Replace reassignContractId + reassignContractIdDeduped with copyLinksToContract (copy-not-move semantics)
  • SG-2: Add ne(contractStatus, 'terminated') to buildUniverseWhere to hide terminated chapters from all Contratos list surfaces
  • SG-3: Update shells (reactivation + terminate-and-replace) to use copyLinksToContract for SUC, keep reassignContractId/Deduped for subscriptions (subscriptions follow active agreement only)
  • SG-4: Update integration tests to verify new per-chapter semantics and overview filter
The changes (whole branch)
What
5 files: site-utility-contract.queries.ts (new copyLinksToContract replaces two removed functions), utility-contract-overview.queries.ts (terminated filter in universe where), utility-contract.shells.ts (two call sites updated), cfe-job-intent.integration.test.ts (tests updated + new overview test), docs/development/rpu-agreements-model.md (invariant 4 refined).
Why
Post-#314 smoke test (Diego, 2026-07-16) revealed that terminated chapters were losing their site links on status swaps, making their bills unreachable in Recibos/historial and CSV/ZIP exports. The fix changes the SUC hand-off from move to copy and aligns the list filter to hide terminated chapters by status rather than by absence of a link.
Areas
domains/utility+14388domains/cross-domain+8216docs/development+197
Blast
5 files, +244/−111 lines. Touches the core contract status-transition shells (reactivation + terminate-and-replace) and the Contratos overview query layer. No schema migration, no API surface change. Impact confined to the SUC copy semantics and the overview list filter.
no-migration-needed behavioral-change-in-shells test-gap-terminate-and-replace
ci· CI status API not accessible via runner tokencoderabbit· No .coderabbit.yaml in repo

Findings · 12

correctness2

medium

`ne(contractStatus, 'terminated')` in universe may suppress legitimate lifecycle-phase picker entries

domains/utility/src/utility-contract-overview/utility-contract-overview.queries.ts:300

Adding the filter to `buildUniverseWhere` (shared by statusCounts) means any org that has contracts with `contractStatus = 'terminated'` AND some lifecycle phase like 'cancelado' will see zero counts for that phase in the picker — even if the phase was previously populated by those terminated rows. Intentional per the agreements model, but worth confirming with the product team that the lifecycle picker is expected to only count active-regime chapters (this is a UX decision, not a code bug).

low

`createdBy` on copied SUC rows attributes action to original user, not pipeline actor

domains/utility/src/site-utility-contract/site-utility-contract.queries.ts:352

The new SUC rows inherit `createdBy` from the source row — the original human actor who linked the site to the predecessor contract. The actual operation is performed by the CFE pipeline's system actor. Audit queries on SUC `created_by` will attribute the per-chapter copy to an end-user who never explicitly linked the site to the new contract. Low risk today (no known logic filters on SUC `created_by`), but misleading for forensic audit trails.

security2

low

Terminated contract transiently visible in Contratos UI via CDC before invalidation

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_hooks/useContractOverviewRealtime.ts

When a contract transitions to 'terminated', Supabase CDC delivers the updated read-model row and the realtime flush() path patches it into the cached list without checking `contractStatus`. The row stays visible in the UI for up to DEBOUNCE_MS (1500ms) / MAX_WAIT_MS (8000ms) before the debounced `invalidateQueries` re-fetches and the `ne(contractStatus, 'terminated')` filter removes it. API data is always correct; this is a UI convergence gap. Mitigation: drop rows where `contract_status === 'terminated'` inside the `flush()` cache-update path.

info

No cross-org guard inside `copyLinksToContract` — latent risk for future callers

domains/utility/src/site-utility-contract/site-utility-contract.queries.ts:337

The function accepts two raw internal contract UUIDs without verifying they belong to the same org. Both current callers supply IDs exclusively derived from rows the shell already fetched within the same org context, so misuse is not possible today. A future caller that sources `fromContractId` and `toContractId` from different orgs could silently copy site links across org boundaries. Consider adding an org-equality guard or documenting the assumption.

conventions3

low

`copyLinksToContract` transactional requirement not documented

domains/utility/src/site-utility-contract/site-utility-contract.queries.ts:337

The function does SELECT + INSERT in two statements. Both current callers pass `tx` from a shell transaction, keeping the pair atomic. But the function signature accepts `DbOrTx` without any comment requiring the caller to use a transaction. A future caller passing bare `db` would expose a READ COMMITTED race where source links added/removed between the SELECT and INSERT are silently missed. Add `// must be called within a shell transaction` to the doc comment.

low

`ensureContractForTariff` path: verify per-chapter SUC model is satisfied

domains/utility/src/utility-contract/utility-contract.shells.ts:495

The `ensureContractForTariff` path uses `siteContractQueries.insertMany` (creates new SUC rows for the replacement contract). If the old contract's existing SUC rows are left in place this is fine — per-chapter model satisfied. If the old contract's SUC rows are deleted or moved elsewhere in this path before `insertMany`, the terminated chapter would lose its links and bills would become inaccessible. Worth a quick audit to confirm the old contract's SUC rows are not removed in this flow.

low

Outbox event not emitted for new SUC rows created by `copyLinksToContract`

domains/utility/src/utility-contract/utility-contract.shells.ts:963

FCIS shells should emit outbox events for observable state changes (ADR-016). Both reactivation and terminate-and-replace call `copyLinksToContract` but emit no `utility.site_utility_contract.created` event for the newly created rows. Acceptable today since the copy is a side-effect of the main contract status-change events that ARE emitted, but worth noting if downstream consumers ever need to react to SUC creation.

tests2

high

TerminateAndReplace path: per-chapter SUC preservation is untested

domains/cross-domain/src/__tests__/cfe-job-intent.integration.test.ts

The terminate-and-replace test never pre-creates SUC rows on the old contract, so `copyLinksToContract` runs against zero source links and the test asserts nothing about (a) the new contract gaining links or (b) the old terminated contract retaining its links. The symmetric reactivation test does verify both correctly. Add a TerminateAndReplace variant that seeds SUC rows on `oldContract` and asserts post-copy that both old (terminated) and new (active) contracts hold the expected site links — this is the critical invariant that keeps a terminated chapter's bills visible in Recibos/exports.

medium

`countOverviewByLifecyclePhase` untested for terminated-exclusion

domains/cross-domain/src/__tests__/cfe-job-intent.integration.test.ts:1089

`buildUniverseWhere` (which now includes `ne(t.contractStatus, 'terminated')`) is consumed by `listOverviewPage`, `listOverviewKeys`, AND `countOverviewByLifecyclePhase`. The new test covers the first two but not the third. The existing overview integration test only uses active contracts. Add an assertion that `countOverviewByLifecyclePhase` excludes terminated-status contracts from phase counts.

improvement3

low

`copyLinksToContract` uses two DB round-trips; `INSERT...SELECT` pattern is available

domains/utility/src/site-utility-contract/site-utility-contract.queries.ts:337

The function SELECTs then INSERTs in two statements. The `copyAllToSite` function in `domains/core/src/site-assignee/site-assignee.queries.ts` uses a single `INSERT INTO ... SELECT generate_public_id('sia'), ... ON CONFLICT ... DO NOTHING RETURNING id` statement. `generate_public_id('suc')` is available (the DB function accepts any TEXT prefix). A single-statement form would eliminate the early-return guard and one network round-trip. Not blocking — both callers run inside a transaction so correctness is unaffected — but a simplification opportunity.

info

Reactivation log cannot distinguish 'all copied' from 'all conflicts skipped'

domains/utility/src/utility-contract/utility-contract.shells.ts:966

The log emits `sucsCopied: sucCopy.copied`. When the reactivated contract already holds all site links (the #298 historical-row scenario), `copied` is 0 — indistinguishable from 'no source links existed'. Returning `{ copied, total: sourceLinks.length }` from `copyLinksToContract` and logging both would surface the skipped-conflict case clearly in production logs.

info

TerminateAndReplace 'Copied site links' log omits old/new contract public IDs

domains/utility/src/utility-contract/utility-contract.shells.ts:1057

The `log.info('Copied site links to new contract', { copied: sucCopy.copied })` at line 1057 doesn't include which contracts were involved. `oldContract.publicId` and `newContract.publicId` are both in scope at that point. Adding them makes the log line self-contained and grep-able without correlating timestamps across surrounding lines.