feat(billing): 2d dual-run reconciliation (Odoo-computed vs NexaCloud-actual)
fusion.billing.reconciliation gains the compute: _compute_reconciliation (flat + charge overage vs external, status match/delta at a tolerance) and _reconcile_rows (resolve shadow sub -> flat + charge, upsert one row per service/partner/period, per-row isolated). The wizard gains a read-only _read_reconciliation_rows (NexaCloud usage cpu_hours*3600 + invoice-item subtotals per YYYY-MM) and a "Run Reconciliation" button. 2a amended to stamp x_fc_nexacloud_plan_id on shadow subs so reconciliation can find the charge. Read-only on NexaCloud; writes only reconciliation rows (shadow guarantees intact). 8 new tests, full suite green on odoo-trial.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,3 +4,4 @@ from . import test_usage
|
||||
from . import test_api
|
||||
from . import test_webhook
|
||||
from . import test_importer
|
||||
from . import test_reconciliation
|
||||
|
||||
@@ -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(
|
||||
|
||||
91
fusion_centralize_billing/tests/test_reconciliation.py
Normal file
91
fusion_centralize_billing/tests/test_reconciliation.py
Normal file
@@ -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']))
|
||||
@@ -23,6 +23,8 @@
|
||||
string="Test Connection" class="btn-secondary"/>
|
||||
<button name="action_run_import" type="object" string="Run Import"
|
||||
class="btn-primary"/>
|
||||
<button name="action_run_reconciliation" type="object"
|
||||
string="Run Reconciliation" class="btn-secondary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -76,6 +76,26 @@ class FusionBillingImportWizard(models.TransientModel):
|
||||
"type": "success", "sticky": False},
|
||||
}
|
||||
|
||||
def action_run_reconciliation(self):
|
||||
"""Read NexaCloud usage + invoice actuals and record per-subscription/period
|
||||
Odoo-vs-NexaCloud deltas in fusion.billing.reconciliation. Read-only on
|
||||
NexaCloud; writes only reconciliation rows (shadow-safe)."""
|
||||
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 [])
|
||||
self.skipped_count = len(summary.get("skipped") or [])
|
||||
if summary.get("delta") or summary.get("failed"):
|
||||
_logger.error(
|
||||
"NexaCloud reconciliation: %s delta, %s failed, %s skipped row(s): %s",
|
||||
summary.get("delta"), len(summary.get("failed") or []),
|
||||
len(summary.get("skipped") or []), summary)
|
||||
return {
|
||||
"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new",
|
||||
}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_rows(self):
|
||||
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
||||
@@ -122,6 +142,55 @@ class FusionBillingImportWizard(models.TransientModel):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _read_reconciliation_rows(self):
|
||||
"""Read-only: per (subscription, YYYY-MM period), NexaCloud's CPU usage
|
||||
(cpu_hours*3600 = cpu_seconds) and its actual pre-tax invoice amount. Shaped for
|
||||
fusion.billing.reconciliation._reconcile_rows. Reuses the 2a DSN + guards.
|
||||
(Integration glue — validate the SQL against the live schema, like the importer
|
||||
reader; the reconciliation math itself is unit-tested.)"""
|
||||
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)
|
||||
cur.execute(
|
||||
"SELECT subscription_id::text AS sub, "
|
||||
"to_char(period_start, 'YYYY-MM') AS period, "
|
||||
"COALESCE(SUM(cpu_hours), 0) * 3600.0 AS cpu_seconds "
|
||||
"FROM usage_records "
|
||||
"GROUP BY subscription_id, to_char(period_start, 'YYYY-MM')")
|
||||
usage = {(r["sub"], r["period"]): float(r["cpu_seconds"] or 0.0)
|
||||
for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"SELECT i.subscription_id::text AS sub, "
|
||||
"to_char(ii.period_start, 'YYYY-MM') AS period, "
|
||||
"COALESCE(SUM(ii.amount), 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["sub"], r["period"])
|
||||
rows.append({
|
||||
"subscription_external_id": r["sub"], "period": r["period"],
|
||||
"cpu_seconds": usage.get(key, 0.0),
|
||||
"external_amount": float(r["external_amount"] or 0.0)})
|
||||
return rows
|
||||
except psycopg2.Error as e:
|
||||
raise UserError(
|
||||
"Failed reading NexaCloud actuals — the source schema may have changed. "
|
||||
"Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- import side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _import_rows(self, data, dry_run=False):
|
||||
@@ -346,6 +415,7 @@ class FusionBillingImportWizard(models.TransientModel):
|
||||
# order that may since have been confirmed.
|
||||
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,
|
||||
}
|
||||
existing = SaleOrder.search(
|
||||
|
||||
Reference in New Issue
Block a user