- C1/H4: rating cron only rates subs on the charge's own plan_id
- C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing)
- C2/C4: /usage authorizes each event (exists + is_subscription + linked customer)
- C3: API handlers validate input and return 4xx-shaped errors instead of raising;
controller maps status=='error' to HTTP 400
- H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date)
- H2: _aggregate uses half-open window anchored on period_start
- H3: idempotency scoped to (subscription_id, metric_id, idempotency_key)
- H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id,
caps backoff at 2**min(attempts,10)
- H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url
- M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume)
- L1: currency_id required on charge + reconciliation
- L2: charge price non-negative + unit_batch positive DB constraints
Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
9.7 KiB
Python
226 lines
9.7 KiB
Python
# -*- 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}
|