Files
Odoo-Modules/fusion_centralize_billing/models/reconciliation.py
gsinghpal a82f09ea50 fix(billing): reconciliation review fixes — per-subscription key, IDOR guard
- 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.
2026-05-27 14:51:43 -04:00

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