# 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