Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
12 KiB
Service-Driven Invoicing Cutover Runbook
Purpose
Use this runbook when recurring due rows are missing or stale during the service-driven invoicing cutover.
It is the operator/developer entrypoint for:
- confirming whether a ready-to-invoice gap is caused by missing persisted
recurring_service_periods - assessing whether future service periods reach the required generation horizon
- running backfill or regeneration planning with the shared cutover helpers
- validating that repaired rows reappear in the due-work reader
Until a dedicated billing-maintenance UI exists, the supported maintenance entrypoint is the shared helper layer under shared/billingClients/*.ts, invoked through pnpm exec tsx.
Cutover Sequence
Use this sequence when moving a tenant from billing-cycle-driven recurring operations to service-driven recurring invoicing.
- Confirm cadence-owner authoring and selector-input recurring generation are already deployed in the target environment.
- Validate that the tenant has the new
billing.recurring_service_periodspermissions granted to the operator roles that will inspect or repair service periods. - Run the coverage assessment for active recurring schedules to identify any obligation whose persisted future rows do not reach the target horizon.
- Backfill active obligations that have no persisted future rows yet.
- Regenerate untouched future rows for obligations whose cadence-owner, billing timing, or activity-window rules changed during rollout.
- Re-run the due-work reader and confirm canonical persisted rows now appear for the expected invoice windows instead of compatibility billing-cycle fallbacks.
- Exercise one contract-cadence invoice end-to-end through preview, generate, and invoiced history.
- Exercise one post-drop client-cadence invoice end-to-end to confirm the compatibility obligation label still resolves through the surviving client-owned contract structure during cutover.
- Only after both paths validate should billing ops rely on the
AutomaticInvoicesdue-work table as the primary recurring surface for that tenant.
Quick Diagnosis
1. Confirm the gap is materialization-related
If the due-work reader returns a row with:
reason: missing_service_period_materialization- a
billing_cycle_id - no persisted canonical row for the same execution identity
then the system is falling back to a compatibility billing-cycle row because the recurring service-period ledger is missing for that window.
The first validation step is the focused due-work reader harness:
cd server
pnpm exec vitest run src/test/unit/billing/recurringDueWorkReader.integration.test.ts -t "T075" --coverage.enabled=false
2. Inspect persisted rows directly
select
record_id,
schedule_key,
period_key,
lifecycle_state,
cadence_owner,
charge_family,
service_period_start,
service_period_end,
invoice_window_start,
invoice_window_end,
invoice_id,
invoice_charge_detail_id
from recurring_service_periods
where tenant = :tenant
and client_id = :client_id
order by invoice_window_end desc, service_period_start desc;
3. Inspect the recurring obligations that should have generated rows
select
cc.client_id,
cl.contract_line_id,
cl.cadence_owner,
cl.billing_frequency,
cl.billing_timing,
cc.start_date,
cc.end_date
from client_contracts cc
join contracts ct
on ct.contract_id = coalesce(cc.template_contract_id, cc.contract_id)
and ct.tenant = cc.tenant
join contract_lines cl
on cl.contract_id = ct.contract_id
and cl.tenant = ct.tenant
where cc.tenant = :tenant
and cc.client_id = :client_id
and cc.is_active = true
and cl.is_recurring = true
order by cl.contract_line_id;
If the recurring obligation exists but the matching recurring_service_periods row does not, continue with coverage assessment and backfill/regeneration planning.
Migration Checklist
Run this checklist before declaring the tenant ready for service-driven recurring invoicing:
- backfill active recurring obligations that still have zero future persisted rows
- replenish schedules whose generated horizon no longer reaches the target billing-ops window
- regenerate untouched future rows after cadence-owner, timing, or activity-window changes
- validate due-work selection for both contract-cadence and post-drop client-cadence windows
- validate preview/generate behavior for one unbridged contract-cadence window
- validate reverse/delete repair for one billed recurring invoice so reopened service periods reappear correctly
- keep compatibility billing-cycle monitoring in place until the due-work reader no longer reports missing-materialization fallbacks for the tenant
Coverage Assessment Entry Point
Use this when you already have the current future records for one schedule or contract line and need to know whether the generated horizon is sufficient.
Prepare a JSON file with the current future rows, for example tmp/recurring-service-period-existing.json.
pnpm exec tsx <<'TS'
import fs from 'node:fs';
import { assessRecurringServicePeriodGenerationCoverage } from './shared/billingClients/recurringServicePeriodGenerationHorizon.ts';
const existingRecords = JSON.parse(
fs.readFileSync('./tmp/recurring-service-period-existing.json', 'utf8'),
);
const coverage = assessRecurringServicePeriodGenerationCoverage({
existingRecords,
generatedThrough: '2026-03-18T00:00:00Z',
});
console.log(JSON.stringify(coverage, null, 2));
TS
Interpretation:
meetsTargetHorizon: falsemeans the schedule does not extend far enough aheadneedsReplenishment: truemeans the remaining future horizon has dropped below the operational threshold- continuity issues mean fix the stored rows before running generation again
Backfill Entry Point
Use this when active recurring obligations have no persisted future rows yet and the system is falling back to compatibility billing-cycle rows.
Prepare:
tmp/recurring-service-period-candidates.jsonwith the candidate rows to materializetmp/recurring-service-period-existing.jsonwith any already-persisted rows for the same schedule
pnpm exec tsx <<'TS'
import fs from 'node:fs';
import { backfillRecurringServicePeriods } from './shared/billingClients/backfillRecurringServicePeriods.ts';
const candidateRecords = JSON.parse(
fs.readFileSync('./tmp/recurring-service-period-candidates.json', 'utf8'),
);
const existingRecords = JSON.parse(
fs.readFileSync('./tmp/recurring-service-period-existing.json', 'utf8'),
);
const plan = backfillRecurringServicePeriods({
candidateRecords,
existingRecords,
backfilledAt: '2026-03-18T00:00:00Z',
sourceRuleVersion: 'service-driven-invoicing-cutover-v1',
sourceRunKey: 'manual-backfill-2026-03-18',
});
console.log(JSON.stringify(plan, null, 2));
TS
What to look for:
backfilledRecordsare new future rows to persistrealignedRecordsare regenerated replacements for mismatched untouched rowsskippedHistoricalCandidatesconfirms the helper did not rewrite billed history- any overlap error means the candidate set crosses the billed-history boundary and must be corrected before persistence
Regeneration Entry Point
Use this when rows already exist but cadence-owner, timing, anchor, or activity-window changes mean untouched future rows must be refreshed.
Prepare:
tmp/recurring-service-period-existing.jsontmp/recurring-service-period-candidates.json
pnpm exec tsx <<'TS'
import fs from 'node:fs';
import { regenerateRecurringServicePeriods } from './shared/billingClients/regenerateRecurringServicePeriods.ts';
const existingRecords = JSON.parse(
fs.readFileSync('./tmp/recurring-service-period-existing.json', 'utf8'),
);
const candidateRecords = JSON.parse(
fs.readFileSync('./tmp/recurring-service-period-candidates.json', 'utf8'),
);
const plan = regenerateRecurringServicePeriods({
existingRecords,
candidateRecords,
regeneratedAt: '2026-03-18T00:00:00Z',
sourceRuleVersion: 'service-driven-invoicing-cutover-v1',
sourceRunKey: 'manual-regeneration-2026-03-18',
});
console.log(JSON.stringify(plan, null, 2));
TS
What to look for:
preservedRecordsremain authoritative because they were billed, locked, or user-editedregeneratedRecordsreplace untouched future rowssupersededRecordsare the rows that should no longer be considered activeconflictsmust be reviewed before persisting because they mean a preserved override no longer matches regenerated candidates
Persistence Rules
When applying a backfill or regeneration plan to the database:
- do not mutate
billed,edited, orlockedrows in place - insert new generated/regenerated rows first
- only supersede untouched future rows that the plan explicitly replaced
- keep
invoice_id,invoice_charge_id, andinvoice_charge_detail_idintact for billed history - if a deleted recurring invoice has already reopened linked rows to
locked, treat those rows as preserved history-aware records, not disposable generated rows
Post-Repair Validation
Due-work reader validation
Run the focused due-work reader check after persisting repaired rows:
cd server
pnpm exec vitest run src/test/unit/billing/recurringDueWorkReader.integration.test.ts --coverage.enabled=false
The repaired execution window should either:
- appear as a canonical persisted due-work row, or
- disappear entirely because the row is not actually due
It should not continue surfacing only as a compatibility billing-cycle fallback unless materialization is still missing.
DB-backed recurring linkage validation
Use the local Postgres listener on 127.0.0.1:57433 for focused recurring cutover checks:
cd server
DB_HOST=127.0.0.1 DB_PORT=57433 DB_NAME=server DB_NAME_SERVER=server DB_USER_SERVER=app_user DB_USER_ADMIN=postgres DB_PASSWORD_SERVER=postpass123 DB_PASSWORD_ADMIN=postpass123 pnpm exec vitest run --coverage.enabled=false src/test/integration/billingInvoiceTiming.integration.test.ts -t "T019|T020|T021"
What this proves:
- billed recurring service periods link back to canonical invoice detail rows
- unbridged contract-cadence invoices still link correctly
- deleting a linked recurring invoice reopens the affected service period instead of leaving it permanently hidden
Reverse/Delete Repair Notes
If a recurring invoice was deleted during cutover testing and the due row did not come back:
- query
recurring_service_periodsfor the originalinvoice_id - confirm
invoice_id,invoice_charge_id, andinvoice_charge_detail_idwere cleared - confirm
lifecycle_state = 'locked' - rerun due-work validation
For reverse/delete validation during tenant rollout:
- capture the recurring row's
schedule_key,period_key,invoice_id, andexecutionIdentityKey - perform the reverse or hard-delete action from the billing UI
- confirm invoiced history reflects the new invoice state
- confirm the linked
recurring_service_periodsrows either reopen tolockedor remain correctly linked for post-drop client-cadence behavior - rerun the due-work reader to confirm the same execution window is visible again when it should be invoiceable
- if the row does not reappear, use the linkage and coverage checks above before retrying generation
locked is the expected restored state after delete-repair because the system cannot safely infer whether the prior mutable state was exactly generated or edited.
Escalate Instead Of Forcing A Repair
Stop and investigate before persisting a plan if any of the following are true:
- regeneration conflicts mention
missing_candidate,service_period_mismatch,invoice_window_mismatch, oractivity_window_mismatch - continuity issues show gaps or overlaps inside the active future ledger
- a billed row would need to move service-period boundaries
- the only available history for a disputed invoice exists in
invoice_charge_detailsand not in persistedrecurring_service_periods
In those cases, preserve the existing records, capture the conflicting rows, and repair the source cadence/timing inputs first.