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:
gsinghpal
2026-05-27 14:34:23 -04:00
parent 3ba9f2821e
commit 2bdf4ef6a0
7 changed files with 244 additions and 1 deletions

View File

@@ -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

View File

@@ -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(