# -*- 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