Resolves findings from the post-build review: - C1: a partial import was indistinguishable from success. action_run_import now logs failed rows at ERROR (survives nexa's log_level=warn) and the wizard shows red/amber banners with failed/skipped counts. - H3: an unrecognized billing_cycle silently fell back to monthly (wrong plan AND price). Now raised per-row -> failed[], never silently mis-billed. - M5: a NULL plan price silently became a $0 line. Prices now preserve NULL-vs-0.0; a missing price for the subscription's cycle is failed[]. - H2: post-connect query/schema errors now become a clean UserError, not a raw SQL traceback (matches the connection-error path). - M4: per-row failures now record the exception type and log a traceback. - MED#3: charge plan_id set explicitly False so re-runs re-assert the shadow-safe NULL even if it was changed between runs. - HIGH-edge: re-run only rewrites x_fc_* on existing subs; partner_id/plan_id/ line are set at creation only (never rewrite immutable fields). - account_link: partner email match is now case-insensitive (=ilike) to avoid duplicate partners against a differently-cased pre-existing partner. Shadow-safety invariant unchanged and re-confirmed. 52/52 green on odoo-trial.
60 lines
2.3 KiB
Python
60 lines
2.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
from odoo import api, fields, models
|
|
|
|
|
|
class FusionBillingAccountLink(models.Model):
|
|
"""Identity resolution: maps an app's external account id to one res.partner.
|
|
|
|
Folds the NexaCloud user / NexaDesk tenant / NexaMaps client for the same
|
|
real-world client onto a single partner (the unified customer). See spec §5.1.
|
|
"""
|
|
|
|
_name = "fusion.billing.account.link"
|
|
_description = "Fusion Billing — External Account → Partner Link"
|
|
_order = "service_id, external_id"
|
|
|
|
service_id = fields.Many2one(
|
|
"fusion.billing.service", required=True, ondelete="cascade", index=True,
|
|
)
|
|
external_id = fields.Char(
|
|
required=True, index=True,
|
|
help="The app's own account id (NexaCloud user, NexaDesk tenant, Maps client).",
|
|
)
|
|
external_email = fields.Char()
|
|
partner_id = fields.Many2one(
|
|
"res.partner", required=True, ondelete="restrict", index=True,
|
|
)
|
|
|
|
_service_external_uniq = models.Constraint(
|
|
"unique(service_id, external_id)",
|
|
"An external account can only link to one partner per service.",
|
|
)
|
|
|
|
@api.model
|
|
def _resolve_or_create_partner(self, service, external_id, name=None, email=None, extra=None):
|
|
"""Return the link for (service, external_id), creating partner+link if needed.
|
|
|
|
Unifies customers: if a link for this external_id exists, reuse it; else if a
|
|
partner with the same email already exists (possibly from another service),
|
|
link to it; else create a new partner.
|
|
"""
|
|
existing = self.search(
|
|
[('service_id', '=', service.id), ('external_id', '=', external_id)], limit=1)
|
|
if existing:
|
|
return existing
|
|
partner = self.env['res.partner']
|
|
if email:
|
|
# case-insensitive so a pre-existing partner with a differently-cased email
|
|
# (created via the web UI or another sync) is reused, not duplicated.
|
|
partner = partner.search([('email', '=ilike', email)], limit=1)
|
|
if not partner:
|
|
partner = partner.create({'name': name or external_id, 'email': email, **(extra or {})})
|
|
return self.create({
|
|
'service_id': service.id,
|
|
'external_id': external_id,
|
|
'external_email': email,
|
|
'partner_id': partner.id,
|
|
})
|