docs(billing): spec + TDD plan for 2d NexaCloud dual-run reconciliation
This commit is contained in:
288
docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md
Normal file
288
docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# NexaCloud Dual-Run Reconciliation (Sub-project #2d) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Checkbox steps.
|
||||
|
||||
**Goal:** Compute, per shadow subscription + period, Odoo's would-be charge vs NexaCloud's actual charge and record the delta in `fusion.billing.reconciliation`, so the dual-run can prove parity before any flip.
|
||||
|
||||
**Architecture:** A pure `_compute_reconciliation(...)` (testable) + `_reconcile_rows(rows)` (resolves the shadow sub → flat + charge, upserts recon rows) + a read-only `_read_reconciliation_rows()` (psycopg2, integration glue). Triggered from the import wizard + cron. Odoo-only; reads NexaCloud, writes only reconciliation rows.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise, `psycopg2`. Tests: `TransactionCase` on odoo-trial (`bash scripts/fcb_test_on_trial.sh`, pass = `FCB_EXIT=0`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-reconciliation-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 2a amendment — store the NexaCloud plan id on the shadow subscription
|
||||
|
||||
**Files:** `models/sale_order.py`, `wizards/import_wizard.py`, `tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: failing test** (append to `TestImporterSubscriptions` in `tests/test_importer.py`):
|
||||
```python
|
||||
def test_subscription_records_nexacloud_plan_id(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
sub1 = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
self.assertEqual(sub1.x_fc_nexacloud_plan_id, 'p-1')
|
||||
```
|
||||
- [ ] **Step 2: run** `bash scripts/fcb_test_on_trial.sh` → FAIL (field missing).
|
||||
- [ ] **Step 3: add the field** to `models/sale_order.py` (next to the other `x_fc_*`):
|
||||
```python
|
||||
x_fc_nexacloud_plan_id = fields.Char(index=True, copy=False)
|
||||
```
|
||||
- [ ] **Step 4: set it in the importer.** In `wizards/import_wizard.py` `_import_subscription`, add the plan id to both the `shadow_vals` dict (so re-runs keep it current) :
|
||||
```python
|
||||
shadow_vals = {
|
||||
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
|
||||
"x_fc_nexacloud_plan_id": str(srow.get("plan_id") or ""),
|
||||
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
|
||||
}
|
||||
```
|
||||
- [ ] **Step 5: run** → PASS.
|
||||
- [ ] **Step 6: commit** `feat(billing): record NexaCloud plan id on shadow subscription (for reconciliation)`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: pure reconciliation math
|
||||
|
||||
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py` (new), `tests/__init__.py`
|
||||
|
||||
- [ ] **Step 1:** append `from . import test_reconciliation` to `tests/__init__.py`.
|
||||
- [ ] **Step 2: failing test** `tests/test_reconciliation.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconciliationMath(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'CPU', 'plan_code': 'p-1', 'metric_id': self.metric.id,
|
||||
'included_quota': 18000.0, 'price_per_unit': 0.0075,
|
||||
'unit_batch': 3600.0, 'charge_model': 'standard'})
|
||||
|
||||
def test_match_within_tolerance(self):
|
||||
# flat 20 + 0 overage (under quota) vs external 20.00 -> match
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 10000.0, 20.0, 0.01)
|
||||
self.assertAlmostEqual(odoo_amt, 20.0)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_overage_match(self):
|
||||
# flat 20 + 2 core-hours overage (7200s -> $0.015) = 20.015 vs external 20.015
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0 + 7200.0, 20.015, 0.01)
|
||||
self.assertAlmostEqual(odoo_amt, 20.015, places=4)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_delta_flags_mismatch(self):
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0, 25.0, 0.01) # external 25 vs odoo 20
|
||||
self.assertAlmostEqual(delta, -5.0, places=2)
|
||||
self.assertEqual(status, 'delta')
|
||||
```
|
||||
- [ ] **Step 3: run** → FAIL (`_compute_reconciliation` missing).
|
||||
- [ ] **Step 4: implement** in `models/reconciliation.py` (add `from odoo import api, fields, models`):
|
||||
```python
|
||||
@api.model
|
||||
def _compute_reconciliation(self, flat_amount, charge, cpu_seconds, external_amount,
|
||||
tolerance=0.01):
|
||||
"""Return (odoo_amount, delta, status). odoo = flat + overage(cpu_seconds);
|
||||
delta = odoo - external; status 'match' if |delta| <= tolerance else 'delta'."""
|
||||
_units, overage = charge._compute_billable(cpu_seconds) if charge else (0.0, 0.0)
|
||||
odoo_amount = round((flat_amount or 0.0) + (overage or 0.0), 2)
|
||||
delta = round(odoo_amount - (external_amount or 0.0), 2)
|
||||
status = 'match' if abs(delta) <= (tolerance or 0.0) else 'delta'
|
||||
return odoo_amount, delta, status
|
||||
```
|
||||
- [ ] **Step 5: run** → PASS.
|
||||
- [ ] **Step 6: commit** `feat(billing): reconciliation math (odoo-computed vs external)`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `_reconcile_rows` — resolve shadow sub and upsert recon rows
|
||||
|
||||
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py`
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileRows(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
from odoo.addons.fusion_centralize_billing.tests.test_importer import _fixture
|
||||
self.Wizard._import_rows(_fixture()) # creates shadow subs + p-1 charge
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
|
||||
def test_creates_one_row_per_subscription_with_status(self):
|
||||
# s-1 monthly flat 20, no overage; external 20.00 -> match.
|
||||
# s-2 yearly flat 200; external 250 -> delta -50.
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0},
|
||||
{'subscription_external_id': 's-2', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 250.0},
|
||||
])
|
||||
rows = self.Recon.search([('period', '=', '2026-05')])
|
||||
self.assertEqual(len(rows), 2)
|
||||
s1 = rows.filtered(lambda r: r.odoo_amount == 20.0)
|
||||
self.assertEqual(s1.status, 'match')
|
||||
s2 = rows.filtered(lambda r: r.odoo_amount == 200.0)
|
||||
self.assertEqual(s2.status, 'delta')
|
||||
self.assertAlmostEqual(s2.delta, -50.0, places=2)
|
||||
self.assertEqual(summary['match'], 1)
|
||||
self.assertEqual(summary['delta'], 1)
|
||||
|
||||
def test_rerun_upserts(self):
|
||||
row = [{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0}]
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.assertEqual(self.Recon.search_count(
|
||||
[('period', '=', '2026-05'),
|
||||
('partner_id', '=', self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-1')]).partner_id.id)]), 1)
|
||||
|
||||
def test_unknown_subscription_is_skipped(self):
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 'nope', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 1.0}])
|
||||
self.assertTrue(any(s['id'] == 'nope' for s in summary['skipped']))
|
||||
```
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
- [ ] **Step 3: implement** in `models/reconciliation.py`:
|
||||
```python
|
||||
@api.model
|
||||
def _reconcile_rows(self, rows, tolerance=0.01):
|
||||
SaleOrder = self.env['sale.order']
|
||||
Charge = self.env['fusion.billing.charge']
|
||||
Service = self.env['fusion.billing.service']
|
||||
service = Service.search([('code', '=', 'nexacloud')], limit=1)
|
||||
summary = {'match': 0, 'delta': 0, 'skipped': [], 'failed': []}
|
||||
for r in rows:
|
||||
sub_ext = str(r.get('subscription_external_id') or '')
|
||||
period = str(r.get('period') or '')
|
||||
try:
|
||||
sub = SaleOrder.search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
|
||||
if not sub:
|
||||
summary['skipped'].append({'id': sub_ext, 'reason': 'unknown subscription'})
|
||||
continue
|
||||
charge = Charge.search(
|
||||
[('plan_code', '=', sub.x_fc_nexacloud_plan_id)], limit=1)
|
||||
plan_line = sub.order_line.filtered(
|
||||
lambda l: l.product_id.default_code
|
||||
and l.product_id.default_code.startswith('NC-PLAN-'))
|
||||
flat = plan_line[:1].price_unit
|
||||
odoo_amount, delta, status = self._compute_reconciliation(
|
||||
flat, charge, float(r.get('cpu_seconds') or 0.0),
|
||||
float(r.get('external_amount') or 0.0), tolerance)
|
||||
vals = {
|
||||
'service_id': service.id if service else False,
|
||||
'partner_id': sub.partner_id.id, 'period': period,
|
||||
'odoo_amount': odoo_amount,
|
||||
'external_amount': float(r.get('external_amount') or 0.0),
|
||||
'delta': delta, 'status': status,
|
||||
}
|
||||
existing = self.search([
|
||||
('service_id', '=', vals['service_id']),
|
||||
('partner_id', '=', sub.partner_id.id), ('period', '=', period)], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
self.create(vals)
|
||||
summary['match' if status == 'match' else 'delta'] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||
summary['failed'].append({'id': sub_ext, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||
return summary
|
||||
```
|
||||
- [ ] **Step 4: run** → PASS.
|
||||
- [ ] **Step 5: commit** `feat(billing): reconcile shadow subscriptions -> fusion.billing.reconciliation`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: read NexaCloud actuals + wizard trigger
|
||||
|
||||
**Files:** `wizards/import_wizard.py`, `views/import_wizard_views.xml`
|
||||
|
||||
- [ ] **Step 1: add the reader** in `wizards/import_wizard.py` (reuses the DSN + the same connect/guard pattern as `_read_nexacloud_rows`). Aggregate usage cpu_hours per (subscription, period) and the invoice subtotal per (subscription, period); return rows shaped for `_reconcile_rows`:
|
||||
```python
|
||||
def _read_reconciliation_rows(self):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
# period label = YYYY-MM of the usage period_start; cpu_seconds = cpu_hours*3600
|
||||
cur.execute("""
|
||||
SELECT u.subscription_id::text AS subscription_external_id,
|
||||
to_char(u.period_start, 'YYYY-MM') AS period,
|
||||
COALESCE(SUM(u.cpu_hours), 0) * 3600.0 AS cpu_seconds
|
||||
FROM usage_records u
|
||||
GROUP BY u.subscription_id, to_char(u.period_start, 'YYYY-MM')""")
|
||||
usage = {(r['subscription_external_id'], r['period']): r for r in cur.fetchall()}
|
||||
cur.execute("""
|
||||
SELECT i.subscription_id::text AS subscription_external_id,
|
||||
to_char(ii.period_start, 'YYYY-MM') AS period,
|
||||
COALESCE(SUM(i.subtotal), 0) AS external_amount
|
||||
FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id
|
||||
GROUP BY i.subscription_id, to_char(ii.period_start, 'YYYY-MM')""")
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
key = (r['subscription_external_id'], r['period'])
|
||||
rows.append({
|
||||
'subscription_external_id': r['subscription_external_id'],
|
||||
'period': r['period'],
|
||||
'cpu_seconds': float((usage.get(key) or {}).get('cpu_seconds') or 0.0),
|
||||
'external_amount': float(r['external_amount'] or 0.0)})
|
||||
return rows
|
||||
except psycopg2.Error as e:
|
||||
raise UserError("Failed reading NexaCloud actuals — schema may have changed:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def action_run_reconciliation(self):
|
||||
self.ensure_one()
|
||||
rows = self._read_reconciliation_rows()
|
||||
summary = self.env['fusion.billing.reconciliation']._reconcile_rows(rows)
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
self.failed_count = len(summary.get('failed') or [])
|
||||
if summary.get('delta') or summary.get('failed'):
|
||||
_logger.error("NexaCloud reconciliation: %s delta / %s failed row(s): %s",
|
||||
summary.get('delta'), len(summary.get('failed') or []), summary)
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
```
|
||||
- [ ] **Step 2: add the button** to `views/import_wizard_views.xml` footer:
|
||||
```xml
|
||||
<button name="action_run_reconciliation" type="object"
|
||||
string="Run Reconciliation" class="btn-secondary"/>
|
||||
```
|
||||
- [ ] **Step 3:** `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (module upgrades; reader is integration-only, not unit-tested).
|
||||
- [ ] **Step 4: commit** `feat(billing): NexaCloud reconciliation reader + wizard trigger`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: full suite + static checks
|
||||
|
||||
- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0`.
|
||||
- [ ] `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean` → clean.
|
||||
- [ ] `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan"` → only docstring.
|
||||
- [ ] commit any fixes.
|
||||
|
||||
## Done = 2d complete
|
||||
|
||||
The dual-run can be run each cycle (button/cron): it reads NexaCloud usage + invoice subtotals, computes Odoo's would-be charge, and records per-subscription `match`/`delta` rows. Flip happens (manually) once a cycle is all-match.
|
||||
@@ -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