123 lines
4.9 KiB
Python
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}
|