- CRITICAL: reconciliation upsert keyed on (service, partner, period) collided when one customer has two deployments (two subs) in a period — the second overwrote the first. Add external_subscription_id to the model + a UNIQUE(service_id, external_subscription_id, period) constraint, and key the upsert per subscription. New test proves two subs for one partner keep two rows. - raise a clear error if the nexacloud service is missing (was a confusing per-row failure). - _fc_resolve_subscription: the integer fallback no longer reaches a different service's tagged subscription (latent multi-service IDOR); live untagged subs stay resolvable and the partner-link authz is unchanged. Full suite green on odoo-trial.
129 lines
5.9 KiB
Python
129 lines
5.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
import logging
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
_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.")
|
|
external_subscription_id = fields.Char(
|
|
index=True,
|
|
help="Source-app subscription id this row reconciles (NexaCloud sub UUID). Part of "
|
|
"the upsert key so a customer with multiple deployments gets one row PER "
|
|
"subscription per period, not a single colliding row.")
|
|
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()
|
|
|
|
_service_sub_period_uniq = models.Constraint(
|
|
"UNIQUE(service_id, external_subscription_id, period)",
|
|
"One reconciliation row per service, subscription, and period.",
|
|
)
|
|
|
|
@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)
|
|
if not service:
|
|
raise UserError(
|
|
"NexaCloud billing service not found — run the importer first so the "
|
|
"service, catalog, and shadow subscriptions exist.")
|
|
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,
|
|
'partner_id': sub.partner_id.id, 'period': period,
|
|
'external_subscription_id': sub_ext,
|
|
'odoo_amount': odoo_amount, 'external_amount': external_amount,
|
|
'delta': delta, 'status': status,
|
|
}
|
|
# Upsert per (service, subscription, period) — NOT per partner — so a
|
|
# customer with two deployments gets a row for each, no overwrite.
|
|
existing = self.search([
|
|
('service_id', '=', service.id),
|
|
('external_subscription_id', '=', sub_ext),
|
|
('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
|