Files
Odoo-Modules/docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md

14 KiB

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):
    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_*):
    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) :
        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:
# -*- 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):
    @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):
@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:
    @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:
    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:
                    <button name="action_run_reconciliation" type="object"
                            string="Run Reconciliation" class="btn-secondary"/>
  • Step 3: bash scripts/fcb_test_on_trial.shFCB_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.shFCB_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.