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
293 lines
12 KiB
Markdown
293 lines
12 KiB
Markdown
# 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.
|
|
|
|
1. Confirm cadence-owner authoring and selector-input recurring generation are already deployed in the target environment.
|
|
2. Validate that the tenant has the new `billing.recurring_service_periods` permissions granted to the operator roles that will inspect or repair service periods.
|
|
3. Run the coverage assessment for active recurring schedules to identify any obligation whose persisted future rows do not reach the target horizon.
|
|
4. Backfill active obligations that have no persisted future rows yet.
|
|
5. Regenerate untouched future rows for obligations whose cadence-owner, billing timing, or activity-window rules changed during rollout.
|
|
6. Re-run the due-work reader and confirm canonical persisted rows now appear for the expected invoice windows instead of compatibility billing-cycle fallbacks.
|
|
7. Exercise one contract-cadence invoice end-to-end through preview, generate, and invoiced history.
|
|
8. 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.
|
|
9. Only after both paths validate should billing ops rely on the `AutomaticInvoices` due-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:
|
|
|
|
```bash
|
|
cd server
|
|
pnpm exec vitest run src/test/unit/billing/recurringDueWorkReader.integration.test.ts -t "T075" --coverage.enabled=false
|
|
```
|
|
|
|
### 2. Inspect persisted rows directly
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
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`.
|
|
|
|
```bash
|
|
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: false` means the schedule does not extend far enough ahead
|
|
- `needsReplenishment: true` means 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.json` with the candidate rows to materialize
|
|
- `tmp/recurring-service-period-existing.json` with any already-persisted rows for the same schedule
|
|
|
|
```bash
|
|
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:
|
|
|
|
- `backfilledRecords` are new future rows to persist
|
|
- `realignedRecords` are regenerated replacements for mismatched untouched rows
|
|
- `skippedHistoricalCandidates` confirms 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.json`
|
|
- `tmp/recurring-service-period-candidates.json`
|
|
|
|
```bash
|
|
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:
|
|
|
|
- `preservedRecords` remain authoritative because they were billed, locked, or user-edited
|
|
- `regeneratedRecords` replace untouched future rows
|
|
- `supersededRecords` are the rows that should no longer be considered active
|
|
- `conflicts` must 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`, or `locked` rows in place
|
|
- insert new generated/regenerated rows first
|
|
- only supersede untouched future rows that the plan explicitly replaced
|
|
- keep `invoice_id`, `invoice_charge_id`, and `invoice_charge_detail_id` intact 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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
1. query `recurring_service_periods` for the original `invoice_id`
|
|
2. confirm `invoice_id`, `invoice_charge_id`, and `invoice_charge_detail_id` were cleared
|
|
3. confirm `lifecycle_state = 'locked'`
|
|
4. rerun due-work validation
|
|
|
|
For reverse/delete validation during tenant rollout:
|
|
|
|
1. capture the recurring row's `schedule_key`, `period_key`, `invoice_id`, and `executionIdentityKey`
|
|
2. perform the reverse or hard-delete action from the billing UI
|
|
3. confirm invoiced history reflects the new invoice state
|
|
4. confirm the linked `recurring_service_periods` rows either reopen to `locked` or remain correctly linked for post-drop client-cadence behavior
|
|
5. rerun the due-work reader to confirm the same execution window is visible again when it should be invoiceable
|
|
6. 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`, or `activity_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_details` and not in persisted `recurring_service_periods`
|
|
|
|
In those cases, preserve the existing records, capture the conflicting rows, and repair the source cadence/timing inputs first.
|