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.
111 lines
5.0 KiB
Python
111 lines
5.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
import logging
|
|
|
|
from odoo import api, fields, models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FusionBillingReconciliation(models.Model):
|
|
"""Dual-run shadow-mode comparison: Odoo-computed vs the app's actual billing.
|
|
|
|
During phased cutover (NexaCloud first), Odoo computes invoices while the app
|
|
keeps charging. This row records the per-customer, per-period delta so we only
|
|
flip once deltas are within tolerance. See spec §10.
|
|
"""
|
|
|
|
_name = "fusion.billing.reconciliation"
|
|
_description = "Fusion Billing — Dual-Run Reconciliation"
|
|
_order = "period desc, service_id"
|
|
|
|
service_id = fields.Many2one(
|
|
"fusion.billing.service", required=True, ondelete="cascade", index=True,
|
|
)
|
|
partner_id = fields.Many2one("res.partner", required=True, ondelete="cascade", index=True)
|
|
period = fields.Char(required=True, help="Billing period label, e.g. 2026-05.")
|
|
odoo_amount = fields.Monetary()
|
|
external_amount = fields.Monetary(string="App-actual Amount")
|
|
delta = fields.Monetary(help="odoo_amount - external_amount.")
|
|
currency_id = fields.Many2one(
|
|
"res.currency", required=True,
|
|
default=lambda self: self.env.company.currency_id,
|
|
)
|
|
status = fields.Selection(
|
|
[
|
|
("match", "Within tolerance"),
|
|
("delta", "Delta — investigate"),
|
|
("resolved", "Resolved"),
|
|
],
|
|
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
|