Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
12 KiB
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:
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:
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:
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:
- confirm
contract_lines.cadence_owner - confirm
billing_timing - identify the invoice window used by the run
- identify the canonical due service period for the line
- 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
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_ownerwas normalized back toclient - recurring product or license detail row missing:
check that the emitted charge carried
config_id,servicePeriodStart, andservicePeriodEnd - 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
- identify the disputed
contract_line_id - confirm the stored
cadence_owner,billing_frequency, andbilling_timing - confirm whether the line was authored before contract cadence enablement or normalized during rollout
- compare the active client billing window to the contract-owned due invoice window
- inspect the resulting
invoice_charge_detailsrow that was persisted for the disputed invoice line
Operator questions to answer explicitly
- was the line stored as
clientcadence orcontractcadence 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 toclientcadence because mixed cadence remained staged?
Useful cadence-owner query
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
- inspect
invoices.billing_period_startandinvoices.billing_period_end - inspect the canonical recurring
invoice_charge_details.service_period_startandservice_period_end - confirm whether the invoice is historical/manual or detail-backed recurring
- confirm whether the reader is supposed to use header grouping dates or canonical recurring detail dates
- 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
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
- confirm whether the invoice charge has
invoice_charge_detailsrows - inspect the parent-charge projection fields and any stored
recurring_projectionmetadata - confirm whether the consumer is documented as:
- canonical-detail-first
- flattened summary
- historical/header fallback only
- compare stored export or preview payload provenance before blaming live reader hydration
Useful projection query
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
- 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
- inspect the stored recurring fields on every surface involved in that path
- confirm whether the path should have normalized through the shared recurring authoring policy or recurrence storage model helpers
- compare the stored line against the source template or preset snapshot instead of assuming UI defaults were persisted correctly
Useful drift queries
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_ownerandbilling_timingshould 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
- disable
RECURRING_BILLING_COMPARISON_MODEif it is enabled outside test runs - keep contract cadence blocked on live write paths
- keep client cadence as the only supported authoring mode
- rerun source and DB-backed sanity checks
- investigate drift or persistence mismatches before re-enabling rollout steps
What not to do
- do not delete canonical
invoice_charge_detailsrows from already-generated invoices - do not revert
cadence_ownerdefaults on existing rows - do not force
billing_cycle_alignmentback 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
billingCycleIdrun path cannot represent - invoice readers disagree about header periods versus canonical detail periods for the same recurring line