Hermes 284313f908
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
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

319 lines
12 KiB
Markdown

# Service-Period-First Billing Runbook
## Purpose
Use this runbook during staged rollout of service-period-first recurring billing. It is for two audiences:
- operators validating parity or investigating live invoice behavior
- developers reproducing mixed-cadence or canonical-service-period issues locally
This runbook assumes the current rollout posture from the plan artifacts:
- client cadence is the only supported live write path until contract cadence is explicitly enabled
- comparison mode is additive and must not change persisted invoice outputs
- canonical recurring detail periods are authoritative for migrated recurring fixed, product, and license charges
## Parity Checks
### Source validation
Run these checks before rollout changes or after a cleanup branch lands:
```bash
npx vitest run src/test/unit/billing/billingEngine.cleanupSource.test.ts --coverage.enabled false
npx vitest run src/test/unit/docs/servicePeriodFirstBillingPlan.contract.test.ts --coverage.enabled false
```
What this proves:
- migrated recurring execution no longer depends on `resolveServicePeriod`
- duplicated recurring proration helpers are gone from live fixed, product, and license timing paths
- plan appendix and source inventory still match the repo
### DB-backed sanity validation
Use the Docker Postgres listener on `127.0.0.1:57433` when reproducing locally:
```bash
DB_PORT=57433 npx vitest run src/test/integration/billingInvoiceTiming.integration.test.ts -t "T171|T172|T173|T174" --coverage.enabled false
```
What this proves:
- monthly and quarterly fixed recurring invoices still generate on canonical service periods
- recurring product and recurring license invoices persist canonical `invoice_charge_details`
- the live `generateInvoice(...)` path still works after the cutover for all migrated recurring families
### Comparison mode
Use comparison mode when you want drift signals without changing persisted outputs:
```bash
RECURRING_BILLING_COMPARISON_MODE=legacy-vs-canonical npx vitest run src/test/unit/billing/invoiceGeneration.recurringSelection.test.ts --coverage.enabled false
```
Operational expectation:
- canonical billing remains the returned and persisted result
- legacy billing runs only as a comparison snapshot
- drift logging is a rollout signal, not a second invoice-generation path
## Mixed-Cadence Troubleshooting
### Quick diagnosis
When a user asks why mixed cadence lines grouped or split the way they did, check these in order:
1. confirm `contract_lines.cadence_owner`
2. confirm `billing_timing`
3. identify the invoice window used by the run
4. identify the canonical due service period for the line
5. for contract cadence, identify the contract-owned due invoice window
If two lines land on the same `[start, end)` invoice window, cadence owner alone does not force a split.
If their due windows differ, they must not be grouped into one invoice candidate.
### Useful local inspection queries
```sql
select contract_line_id, cadence_owner, billing_frequency, billing_timing
from contract_lines
where tenant = :tenant
order by contract_line_id;
select billing_cycle_id, client_id, period_start_date, period_end_date, billing_cycle
from client_billing_cycles
where tenant = :tenant and client_id = :client_id
order by period_start_date;
select item_id, service_id, config_id, service_period_start, service_period_end, billing_timing
from invoice_charge_details
where tenant = :tenant and item_id in (
select item_id from invoice_charges where invoice_id = :invoice_id and tenant = :tenant
)
order by service_period_start, service_id;
```
### Symptoms and likely causes
- recurring line missing from invoice:
due service period did not map to the active invoice window, or coverage intersected to zero
- contract-cadence line billed on client-cycle date:
check whether the line was written before contract cadence enablement or whether `cadence_owner` was normalized back to `client`
- recurring product or license detail row missing:
check that the emitted charge carried `config_id`, `servicePeriodStart`, and `servicePeriodEnd`
- duplicate recurring invoice blocked:
verify billed-through and duplicate checks against canonical recurring service periods, not invoice headers alone
## Cadence-Owner Dispute Investigation
Use this when support, billing, or finance asks why a recurring line followed the client schedule versus the contract anniversary.
### Investigation order
1. identify the disputed `contract_line_id`
2. confirm the stored `cadence_owner`, `billing_frequency`, and `billing_timing`
3. confirm whether the line was authored before contract cadence enablement or normalized during rollout
4. compare the active client billing window to the contract-owned due invoice window
5. inspect the resulting `invoice_charge_details` row that was persisted for the disputed invoice line
### Operator questions to answer explicitly
- was the line stored as `client` cadence or `contract` cadence when the invoice was generated?
- if it was `contract`, did the contract-owned due invoice window exactly match the active run window?
- if it was `client`, was the line normalized back to `client` cadence because mixed cadence remained staged?
### Useful cadence-owner query
```sql
select
cl.contract_line_id,
cl.cadence_owner,
cl.billing_frequency,
cl.billing_timing,
cl.start_date,
cl.end_date,
ic.invoice_id,
icd.service_period_start,
icd.service_period_end
from contract_lines cl
left join invoice_charges ic
on ic.client_contract_line_id = cl.contract_line_id
and ic.tenant = cl.tenant
left join invoice_charge_details icd
on icd.item_id = ic.item_id
and icd.tenant = ic.tenant
where cl.tenant = :tenant
and cl.contract_line_id = :contract_line_id
order by icd.service_period_start nulls last, ic.invoice_id;
```
## Service-Period Mismatch Investigation
Use this when invoice header dates, portal views, exports, or support summaries appear to disagree with canonical recurring detail periods.
### Investigation order
1. inspect `invoices.billing_period_start` and `invoices.billing_period_end`
2. inspect the canonical recurring `invoice_charge_details.service_period_start` and `service_period_end`
3. confirm whether the invoice is historical/manual or detail-backed recurring
4. confirm whether the reader is supposed to use header grouping dates or canonical recurring detail dates
5. compare the consumer output against the documented flattening or fallback rule
### Expected interpretation
- invoice headers remain the invoice-window grouping dates
- canonical recurring detail rows remain the authoritative recurring coverage dates for migrated recurring lines
- historical or manual rows may still fall back to header or financial dates where canonical detail periods do not exist
### Useful mismatch query
```sql
select
i.invoice_id,
i.billing_period_start,
i.billing_period_end,
ic.item_id,
ic.description,
ic.client_contract_line_id,
icd.service_period_start,
icd.service_period_end,
icd.billing_timing
from invoices i
join invoice_charges ic
on ic.invoice_id = i.invoice_id
and ic.tenant = i.tenant
left join invoice_charge_details icd
on icd.item_id = ic.item_id
and icd.tenant = ic.tenant
where i.tenant = :tenant
and i.invoice_id = :invoice_id
order by ic.item_id, icd.service_period_start nulls first;
```
If the header window is correct but the detail period is wrong, investigate recurring timing selection and persistence.
If the detail period is correct but the consumer output is wrong, investigate reader hydration, flattening, or export adapter logic.
## Projection Mismatch Investigation
Use this when one reader, renderer, portal surface, or export shows invoice-header periods while another surface shows canonical recurring detail periods for the same invoice.
### Investigation order
1. confirm whether the invoice charge has `invoice_charge_details` rows
2. inspect the parent-charge projection fields and any stored `recurring_projection` metadata
3. confirm whether the consumer is documented as:
- canonical-detail-first
- flattened summary
- historical/header fallback only
4. compare stored export or preview payload provenance before blaming live reader hydration
### Useful projection query
```sql
select
i.invoice_id,
ic.item_id,
ic.description,
ic.service_period_start as parent_service_period_start,
ic.service_period_end as parent_service_period_end,
count(icd.detail_id) as detail_period_count,
min(icd.service_period_start) as canonical_detail_start,
max(icd.service_period_end) as canonical_detail_end
from invoices i
join invoice_charges ic
on ic.invoice_id = i.invoice_id
and ic.tenant = i.tenant
left join invoice_charge_details icd
on icd.item_id = ic.item_id
and icd.tenant = ic.tenant
where i.tenant = :tenant
and i.invoice_id = :invoice_id
group by
i.invoice_id,
ic.item_id,
ic.description,
ic.service_period_start,
ic.service_period_end
order by ic.item_id;
```
### Expected interpretation
- if `detail_period_count > 0`, canonical recurring detail periods remain authoritative even when a consumer flattens them to a summary range
- if `detail_period_count = 0`, the invoice may be historical flat data or a financial-only artifact, so header or financial dates may still be the documented fallback
- if a consumer ignores canonical detail periods where they exist, investigate read-model hydration before changing billing outputs
## Authoring-Default Drift Investigation
Use this when templates, presets, contract wizard flows, inline contract-line edits, or custom recurring-line creation appear to store different cadence-owner or timing defaults for the same intended behavior.
### Investigation order
1. identify which authoring path created or last updated the recurring line:
- contract wizard
- inline contract-line edit
- custom line create
- preset create or reuse
- template authoring or template clone
2. inspect the stored recurring fields on every surface involved in that path
3. confirm whether the path should have normalized through the shared recurring authoring policy or recurrence storage model helpers
4. compare the stored line against the source template or preset snapshot instead of assuming UI defaults were persisted correctly
### Useful drift queries
```sql
select
cl.contract_line_id,
cl.billing_timing,
cl.cadence_owner,
cl.enable_proration,
ctl.template_line_id,
ctl.billing_timing as template_billing_timing,
ctl.cadence_owner as template_cadence_owner,
cp.preset_id,
cp.billing_timing as preset_billing_timing,
cp.cadence_owner as preset_cadence_owner
from contract_lines cl
left join contract_template_lines ctl
on ctl.template_line_id = cl.source_template_line_id
and ctl.tenant = cl.tenant
left join contract_line_presets cp
on cp.preset_id = cl.source_preset_id
and cp.tenant = cl.tenant
where cl.tenant = :tenant
and cl.contract_line_id = :contract_line_id;
```
### Expected interpretation
- `cadence_owner` and `billing_timing` should agree across live-line, template, and preset storage once the path is normalized
- legacy compatibility fields may still exist, but they must not be the reason a live recurring line silently changes cadence or timing
- if storage is correct but UI copy or preview text disagrees, investigate the authoring reader or preview builder instead of rewriting persisted fields
## Rollback Posture
Rollback means stopping rollout exposure, not undoing schema or canonical detail persistence blindly.
### Safe rollback steps
1. disable `RECURRING_BILLING_COMPARISON_MODE` if it is enabled outside test runs
2. keep contract cadence blocked on live write paths
3. keep client cadence as the only supported authoring mode
4. rerun source and DB-backed sanity checks
5. investigate drift or persistence mismatches before re-enabling rollout steps
### What not to do
- do not delete canonical `invoice_charge_details` rows from already-generated invoices
- do not revert `cadence_owner` defaults on existing rows
- do not force `billing_cycle_alignment` back into live execution to paper over canonical timing drift
### Escalate when
- DB-backed sanity checks fail on fixed recurring as well as product/license
- mixed-cadence lines appear to require a scheduler identity that the current `billingCycleId` run path cannot represent
- invoice readers disagree about header periods versus canonical detail periods for the same recurring line