# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 import hashlib import ipaddress import secrets from urllib.parse import urlparse from odoo import api, fields, models from odoo.exceptions import ValidationError class FusionBillingService(models.Model): """A source app that pushes billing data (NexaCloud / NexaDesk / NexaMaps). The bearer API key is shown ONCE on generation and stored only as a SHA-256 hash. This record is the auth + routing boundary for the inbound API and the target for outbound webhooks. See spec §5.1 / §7 / §8. """ _name = "fusion.billing.service" _description = "Fusion Billing — Source Service" _order = "name" name = fields.Char(required=True) code = fields.Char( required=True, index=True, help="Stable code the app identifies itself with, e.g. nexacloud / nexadesk / nexamaps.", ) active = fields.Boolean(default=True) api_key_hash = fields.Char( string="API Key (SHA-256)", help="Hash of the bearer key. The raw key is displayed once at generation time.", ) webhook_url = fields.Char(help="Endpoint this app exposes to receive billing webhooks.") webhook_secret = fields.Char(help="Shared secret for HMAC-SHA256 webhook signatures.") account_link_ids = fields.One2many( "fusion.billing.account.link", "service_id", string="Customer Links", ) account_link_count = fields.Integer(compute="_compute_account_link_count") _code_uniq = models.Constraint("unique(code)", "Service code must be unique.") @api.depends("account_link_ids") def _compute_account_link_count(self): for rec in self: rec.account_link_count = len(rec.account_link_ids) @api.constrains("webhook_url") def _check_webhook_url(self): """Reject SSRF-prone webhook targets: a non-empty URL must be https and must not point at localhost or a private / link-local / loopback IP literal. Empty is allowed (no webhook configured).""" for rec in self: url = (rec.webhook_url or "").strip() if not url: continue parsed = urlparse(url) if parsed.scheme != "https": raise ValidationError("Webhook URL must use https.") host = parsed.hostname or "" if not host or host.lower() in ("localhost", "ip6-localhost", "ip6-loopback"): raise ValidationError( "Webhook URL must not target localhost or a private address.") try: ip = ipaddress.ip_address(host) except ValueError: ip = None if ip is not None and ( ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified or ip.is_multicast ): raise ValidationError( "Webhook URL must not target a private or loopback address.") def action_generate_api_key(self): """Generate a fresh bearer key, store only its hash, return the raw key. TODO(spec §7): surface the raw key once in the UI (wizard/notification). """ self.ensure_one() raw = secrets.token_urlsafe(32) self.api_key_hash = hashlib.sha256(raw.encode()).hexdigest() return raw @api.model def _match_api_key(self, raw_key): """Return the active service whose stored hash matches raw_key, else empty recordset.""" if not raw_key: return self.browse() key_hash = hashlib.sha256(raw_key.encode()).hexdigest() return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1) def _api_upsert_customer(self, payload): """Resolve/create the partner link for an external account. Defensive: a non-dict payload or a missing/empty ``external_id`` returns a 4xx-shaped error instead of raising (C3). """ self.ensure_one() if not isinstance(payload, dict): return {'status': 'error', 'error': 'invalid payload'} ext = payload.get('external_id') if not ext: return {'status': 'error', 'error': 'external_id required'} link = self.env['fusion.billing.account.link']._resolve_or_create_partner( self, ext, name=payload.get('name'), email=payload.get('email')) return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext} def _api_record_usage(self, payload): """Ingest a batch of usage events. Authorization (C2/C4): each event must target a subscription sale.order that (a) exists, (b) is actually a subscription, and (c) belongs to a customer THIS service is linked to. Any failing event is rejected and stops processing for that event without writing a usage row. Validation (C3): a non-dict payload, a non-list ``events``, missing required keys, or non-numeric ``quantity``/ids return a 4xx-shaped error instead of raising (no 500s). """ self.ensure_one() if not isinstance(payload, dict): return {'status': 'error', 'error': 'invalid payload'} events = payload.get('events') if events is None: events = [] if not isinstance(events, list): return {'status': 'error', 'error': 'events must be a list'} Usage = self.env['fusion.billing.usage'] linked_partners = self.account_link_ids.mapped('partner_id') accepted = 0 for ev in events: if not isinstance(ev, dict): return {'status': 'error', 'error': 'invalid event'} for key in ('subscription_external_id', 'metric_code', 'quantity', 'period_start', 'period_end'): if ev.get(key) in (None, ''): return {'status': 'error', 'error': 'missing %s' % key} try: sub_id = int(ev['subscription_external_id']) except (TypeError, ValueError): return {'status': 'error', 'error': 'invalid subscription_external_id'} try: quantity = float(ev['quantity']) except (TypeError, ValueError): return {'status': 'error', 'error': 'invalid quantity'} sub = self.env['sale.order'].browse(sub_id) if not sub.exists() or not sub.is_subscription \ or sub.partner_id not in linked_partners: return {'status': 'error', 'error': 'unknown subscription'} try: Usage._record_usage( sub, ev['metric_code'], quantity, ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key')) except ValueError as e: return {'status': 'error', 'error': str(e)} accepted += 1 return {'status': 'ok', 'accepted': accepted} def _api_catalog(self): self.ensure_one() charges = self.env['fusion.billing.charge'].search([('active', '=', True)]) return {'status': 'ok', 'charges': [{ 'plan_code': c.plan_code, 'metric': c.metric_id.code, 'included_quota': c.included_quota, 'price_per_unit': c.price_per_unit, 'unit_batch': c.unit_batch, 'charge_model': c.charge_model, } for c in charges]} def _api_create_subscription(self, payload): """Create and confirm a subscription sale.order for an external customer. The product on each line must have recurring_invoice=True so that Odoo recognises the order as a subscription with has_recurring_line and action_confirm() reaches subscription_state='3_progress'. Validation (C3): a non-dict payload, a missing/unknown customer, a missing ``plan_id``, a non-list ``lines``, or a non-numeric product id/quantity return a 4xx-shaped error instead of raising (no 500s). """ self.ensure_one() if not isinstance(payload, dict): return {'status': 'error', 'error': 'invalid payload'} if not payload.get('external_customer_id'): return {'status': 'error', 'error': 'external_customer_id required'} if not payload.get('plan_id'): return {'status': 'error', 'error': 'plan_id required'} try: plan_id = int(payload['plan_id']) except (TypeError, ValueError): return {'status': 'error', 'error': 'invalid plan_id'} link = self.env['fusion.billing.account.link'].search([ ('service_id', '=', self.id), ('external_id', '=', payload.get('external_customer_id')), ], limit=1) if not link: return {'status': 'error', 'error': 'unknown customer'} lines = payload.get('lines') if lines is None: lines = [] if not isinstance(lines, list): return {'status': 'error', 'error': 'lines must be a list'} order_lines = [] for line in lines: if not isinstance(line, dict) or line.get('product_id') in (None, ''): return {'status': 'error', 'error': 'invalid line'} try: product_id = int(line['product_id']) quantity = float(line.get('quantity', 1)) except (TypeError, ValueError): return {'status': 'error', 'error': 'invalid line'} order_lines.append((0, 0, { 'product_id': product_id, 'product_uom_qty': quantity, })) sub = self.env['sale.order'].sudo().create({ 'partner_id': link.partner_id.id, 'plan_id': plan_id, 'order_line': order_lines, }) sub.action_confirm() return {'status': 'ok', 'subscription_id': sub.id, 'subscription_state': sub.subscription_state}