diff --git a/fusion_centralize_billing/models/reconciliation.py b/fusion_centralize_billing/models/reconciliation.py index e51f6b71..0abfb5a2 100644 --- a/fusion_centralize_billing/models/reconciliation.py +++ b/fusion_centralize_billing/models/reconciliation.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 -from odoo import fields, models +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) class FusionBillingReconciliation(models.Model): @@ -37,3 +41,70 @@ class FusionBillingReconciliation(models.Model): default="delta", required=True, index=True, ) note = fields.Text() + + @api.model + def _compute_reconciliation(self, flat_amount, charge, cpu_seconds, external_amount, + tolerance=0.01): + """Return (odoo_amount, delta, status). + + odoo_amount = flat + CPU overage(cpu_seconds); delta = odoo - external; + status 'match' if |delta| <= tolerance else 'delta'. Amounts are compared at cent + precision (the dual-run cares about cent-level invoice parity).""" + overage = 0.0 + if charge: + _units, overage = charge._compute_billable(cpu_seconds) + 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 + + @api.model + def _reconcile_rows(self, rows, tolerance=0.01): + """For each {subscription_external_id, period, cpu_seconds, external_amount}, + resolve the shadow sale.order, compute Odoo-vs-external, and UPSERT one + reconciliation row keyed by (service_id, partner_id, period). Per-row isolated.""" + SaleOrder = self.env['sale.order'] + Charge = self.env['fusion.billing.charge'] + service = self.env['fusion.billing.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 + external_amount = float(r.get('external_amount') or 0.0) + odoo_amount, delta, status = self._compute_reconciliation( + flat, charge, float(r.get('cpu_seconds') or 0.0), + external_amount, tolerance) + vals = { + 'service_id': service.id if service else False, + 'partner_id': sub.partner_id.id, 'period': period, + 'odoo_amount': odoo_amount, 'external_amount': external_amount, + '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 + _logger.exception("Reconciliation row %s failed", sub_ext) + summary['failed'].append( + {'id': sub_ext, 'error': '%s: %s' % (type(e).__name__, e)}) + return summary diff --git a/fusion_centralize_billing/models/sale_order.py b/fusion_centralize_billing/models/sale_order.py index 19950c39..c53c3c03 100644 --- a/fusion_centralize_billing/models/sale_order.py +++ b/fusion_centralize_billing/models/sale_order.py @@ -11,6 +11,9 @@ class SaleOrder(models.Model): index=True, copy=False, help="Source NexaCloud subscription id — the importer's idempotency key.") x_fc_nexacloud_deployment_id = fields.Char(index=True, copy=False) + x_fc_nexacloud_plan_id = fields.Char( + index=True, copy=False, + help="Source NexaCloud plan id — links the shadow sub to its charge for 2d reconciliation.") x_fc_billing_service_id = fields.Many2one( "fusion.billing.service", index=True, copy=False, ondelete="set null") x_fc_shadow = fields.Boolean( diff --git a/fusion_centralize_billing/tests/__init__.py b/fusion_centralize_billing/tests/__init__.py index 60f36b67..1143c471 100644 --- a/fusion_centralize_billing/tests/__init__.py +++ b/fusion_centralize_billing/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_usage from . import test_api from . import test_webhook from . import test_importer +from . import test_reconciliation diff --git a/fusion_centralize_billing/tests/test_importer.py b/fusion_centralize_billing/tests/test_importer.py index 33f531fe..c36d47bb 100644 --- a/fusion_centralize_billing/tests/test_importer.py +++ b/fusion_centralize_billing/tests/test_importer.py @@ -118,6 +118,11 @@ class TestImporterSubscriptions(TransactionCase): self.assertAlmostEqual(line2.price_unit, 200.0) # price_yearly self.assertEqual(sub2.plan_id.billing_period_unit, 'year') + 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') + def test_subscription_skipped_when_user_or_plan_unresolved(self): data = _fixture() data['subscriptions'].append( diff --git a/fusion_centralize_billing/tests/test_reconciliation.py b/fusion_centralize_billing/tests/test_reconciliation.py new file mode 100644 index 00000000..30927b36 --- /dev/null +++ b/fusion_centralize_billing/tests/test_reconciliation.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo.tests.common import TransactionCase, tagged + +from .test_importer import _fixture + + +@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): + odoo_amt, delta, status = self.Recon._compute_reconciliation( + 20.0, self.charge, 10000.0, 20.0, 0.01) # under quota, no overage + 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; external 20.02 (cent) + odoo_amt, delta, status = self.Recon._compute_reconciliation( + 20.0, self.charge, 18000.0 + 7200.0, 20.02, 0.01) + 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') + + def test_no_charge_is_flat_only(self): + odoo_amt, delta, status = self.Recon._compute_reconciliation( + 20.0, self.env['fusion.billing.charge'], 999999.0, 20.0, 0.01) + self.assertAlmostEqual(odoo_amt, 20.0) + self.assertEqual(status, 'match') + + +@tagged('post_install', '-at_install') +class TestReconcileRows(TransactionCase): + + def setUp(self): + super().setUp() + self.Wizard = self.env['fusion.billing.import.wizard'].sudo() + self.Wizard._import_rows(_fixture()) # shadow subs s-1/s-2 + p-1 charge + self.Recon = self.env['fusion.billing.reconciliation'].sudo() + self.SaleOrder = self.env['sale.order'] + + def _partner_of(self, sub_ext): + return self.SaleOrder.search( + [('x_fc_nexacloud_subscription_id', '=', sub_ext)]).partner_id + + def test_creates_one_row_per_subscription_with_status(self): + summary = self.Recon._reconcile_rows([ + {'subscription_external_id': 's-1', 'period': '2026-05', + 'cpu_seconds': 0.0, 'external_amount': 20.0}, # flat 20 == 20 -> match + {'subscription_external_id': 's-2', 'period': '2026-05', + 'cpu_seconds': 0.0, 'external_amount': 250.0}, # flat 200 vs 250 -> delta + ]) + 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._partner_of('s-1').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'])) diff --git a/fusion_centralize_billing/views/import_wizard_views.xml b/fusion_centralize_billing/views/import_wizard_views.xml index 5883d7a2..6b0e8129 100644 --- a/fusion_centralize_billing/views/import_wizard_views.xml +++ b/fusion_centralize_billing/views/import_wizard_views.xml @@ -23,6 +23,8 @@ string="Test Connection" class="btn-secondary"/>