diff --git a/docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md b/docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md new file mode 100644 index 00000000..3dccdebc --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md @@ -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 +