Files
Odoo-Modules/fusion_centralize_billing/models/service.py
2026-05-27 08:42:08 -04:00

123 lines
4.9 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
import hashlib
import secrets
from odoo import api, fields, models
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)
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):
self.ensure_one()
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):
self.ensure_one()
events = payload.get('events') or []
Usage = self.env['fusion.billing.usage']
accepted = 0
for ev in events:
sub = self.env['sale.order'].browse(int(ev['subscription_external_id']))
Usage._record_usage(
sub, ev['metric_code'], float(ev['quantity']),
ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key'))
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'.
"""
self.ensure_one()
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'}
order_lines = [(0, 0, {
'product_id': line['product_id'],
'product_uom_qty': line.get('quantity', 1),
}) for line in payload.get('lines', [])]
sub = self.env['sale.order'].sudo().create({
'partner_id': link.partner_id.id,
'plan_id': payload['plan_id'],
'order_line': order_lines,
})
sub.action_confirm()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}