docs(billing): spec + TDD plan for 2d NexaCloud dual-run reconciliation
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
# Sub-project #2d — NexaCloud Dual-Run Reconciliation (Design)
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Status:** Design (proceeding straight to build — approach determined by parent spec §10)
|
||||
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise; tested on odoo-trial)
|
||||
- **Parent:** Sub-project #2. Depends on **2a** (the importer creates the shadow subscriptions + the `cpu_seconds` charge catalog this reconciles against).
|
||||
- **Model already exists:** `fusion.billing.reconciliation` (`service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` ∈ match/delta/resolved, `note`).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Prove, for ≥ 1 billing cycle, that Odoo's billing engine computes the **same charge** as
|
||||
NexaCloud already does — per subscription, per period — before any real billing is flipped.
|
||||
Read-only against NexaCloud; writes only `fusion.billing.reconciliation` rows in Odoo.
|
||||
|
||||
## 2. What gets compared
|
||||
|
||||
For each imported shadow subscription and period:
|
||||
|
||||
- **`external_amount`** = NexaCloud's **actual** pre-tax charge for that subscription+period
|
||||
(the NexaCloud invoice **subtotal**, i.e. flat plan + its own metered overage, before HST).
|
||||
- **`odoo_amount`** = what **Odoo would charge** for the same period:
|
||||
`flat + overage`, where
|
||||
- `flat` = the shadow subscription's plan-product line `price_unit` (the imported flat price), and
|
||||
- `overage` = `charge._compute_billable(cpu_seconds)[1]` for the period's CPU usage, with
|
||||
`cpu_seconds = Σ usage_records.cpu_hours × 3600` (the 2a unit convention).
|
||||
- **`delta`** = `odoo_amount − external_amount`.
|
||||
- **`status`** = `match` if `abs(delta) ≤ tolerance` (default $0.01, configurable), else `delta`.
|
||||
|
||||
Comparing **pre-tax subtotals** keeps it apples-to-apples — HST is native Odoo and not what
|
||||
we're validating; the metered math + catalog mapping is.
|
||||
|
||||
## 3. Architecture (mirrors 2a: pure compute split from the read)
|
||||
|
||||
- **`_compute_reconciliation(flat_amount, charge, cpu_seconds, external_amount, tolerance)`**
|
||||
→ `(odoo_amount, delta, status)`. Pure, deterministic, unit-tested with fixtures. This is
|
||||
the reconciliation core.
|
||||
- **`_reconcile_rows(rows, tolerance=0.01)`** — pure Odoo: for each input row
|
||||
`{subscription_external_id, period, cpu_seconds, external_amount}`, resolve the shadow
|
||||
`sale.order` (by `x_fc_nexacloud_subscription_id`), its `flat` (plan-line `price_unit`) and
|
||||
its `charge` (by `x_fc_nexacloud_plan_id` → `charge.plan_code`), call
|
||||
`_compute_reconciliation`, and **upsert** one `fusion.billing.reconciliation` row keyed by
|
||||
`(service_id, partner_id, period)`. Returns a summary `{match, delta, skipped, failed}`.
|
||||
- **`_read_reconciliation_rows(period=None)`** — read-only `psycopg2` (reuses the 2a DSN):
|
||||
per subscription+period, `Σ usage_records.cpu_hours` and the NexaCloud invoice **subtotal**.
|
||||
Integration glue (validated manually, like 2a's reader); not unit-tested against a foreign DB.
|
||||
- **Trigger:** a button on the existing import wizard (**“Run Reconciliation”**) and a model
|
||||
method suitable for an `ir.cron`. A non-zero `delta`/`failed` count is surfaced loudly
|
||||
(banner + ERROR log), same as the importer.
|
||||
|
||||
## 4. 2a amendment (small, required)
|
||||
|
||||
Add **`x_fc_nexacloud_plan_id`** (`Char`) to `sale.order` and set it in the importer's
|
||||
`_import_subscription` (from `subscription.plan_id`). Reconciliation needs sub → plan → charge,
|
||||
and parsing it out of the product `default_code` would be fragile.
|
||||
|
||||
## 5. Idempotency / re-runnability
|
||||
|
||||
Reconciliation rows upsert on `(service_id, partner_id, period)`, so re-running a period
|
||||
updates its row rather than duplicating — the dual-run is run every cycle.
|
||||
|
||||
## 6. Shadow-safety
|
||||
|
||||
Reconciliation is pure measurement: it reads NexaCloud and writes only
|
||||
`fusion.billing.reconciliation`. It never touches subscriptions, invoices, payments, or the
|
||||
charge catalog, so the 2a shadow guarantees are untouched.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
`TransactionCase` on odoo-trial with fixtures:
|
||||
- `_compute_reconciliation`: under-quota match; overage match; a real delta flips status to
|
||||
`delta`; tolerance boundary.
|
||||
- `_reconcile_rows`: creates one recon row per subscription; `match` vs `delta` set correctly;
|
||||
re-run upserts (no duplicate); a row for an unknown subscription/charge lands in
|
||||
`skipped`/`failed`, not a crash.
|
||||
- amendment: importer sets `x_fc_nexacloud_plan_id`.
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- The **flip** (set `charge.plan_id`, attach tokens, confirm subs) — happens once deltas are
|
||||
within tolerance for ≥ 1 cycle; not automated here.
|
||||
- Reading NexaCloud RAM/disk/bandwidth (CPU is the only metered-overage metric in v1, per 2a).
|
||||
- A reconciliation dashboard/report view beyond the list of `fusion.billing.reconciliation`.
|
||||
|
||||
## 9. Success criteria
|
||||
|
||||
- For fixture data where Odoo's math equals NexaCloud's, every row is `match`; where it
|
||||
diverges beyond tolerance, the row is `delta` with the correct signed `delta`.
|
||||
- Re-running a period upserts (no duplicate rows).
|
||||
- Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||
Reference in New Issue
Block a user