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.
This commit is contained in:
gsinghpal
2026-05-27 14:51:43 -04:00
parent a5144a925c
commit a82f09ea50
3 changed files with 49 additions and 4 deletions

View File

@@ -4,6 +4,7 @@
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
@@ -25,6 +26,11 @@ class FusionBillingReconciliation(models.Model):
)
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.")
@@ -42,6 +48,11 @@ class FusionBillingReconciliation(models.Model):
)
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):
@@ -67,6 +78,10 @@ class FusionBillingReconciliation(models.Model):
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 '')
@@ -89,14 +104,17 @@ class FusionBillingReconciliation(models.Model):
flat, charge, float(r.get('cpu_seconds') or 0.0),
external_amount, tolerance)
vals = {
'service_id': service.id if service else False,
'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', '=', vals['service_id']),
('partner_id', '=', sub.partner_id.id),
('service_id', '=', service.id),
('external_subscription_id', '=', sub_ext),
('period', '=', period)], limit=1)
if existing:
existing.write(vals)