Centralize billing for all NexaSystems services (NexaCloud, NexaDesk, NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance, replacing Lago. The module adds only the metering + integration layer; native sale_subscription / account_accountant / payment_stripe do all the financial work (invoicing, HST, dunning, portal, credit notes, Stripe). Includes: - Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md): 6 locked decisions, architecture, data model, usage engine, Lago-shaped API, webhook control loop, NexaCloud pilot, phased dual-run migration. - Module scaffold: 7 fusion.billing.* models (service, account.link, metric, charge, usage, webhook, reconciliation), bearer-auth API controller shell, security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs pending the implementation plan. - CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription is a sale.order(is_subscription) + sale.subscription.plan (verified live). Task 0 verified: a single Stripe account is shared across NexaCloud and all Lago providers, so no Stripe account/card migration is required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
57 lines
2.0 KiB
Python
57 lines
2.0 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
|