feat(billing): design + scaffold fusion_centralize_billing

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>
This commit is contained in:
gsinghpal
2026-05-27 02:14:19 -04:00
parent 5764d439c3
commit 2b47bd8b10
16 changed files with 785 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
# Fusion Centralized Billing (`fusion_centralize_billing`)
Centralized billing engine that makes this Odoo 19 **Enterprise** instance the single
billing brain for every NexaSystems service — **NexaCloud** hosting, **NexaDesk** chat,
**NexaMaps** API, custom apps, and memberships. It replaces Lago and absorbs NexaCloud's
home-grown Stripe billing into one customer ledger and one accounting system.
> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md)
>
> **Status:** **SCAFFOLD.** Models + security + the API auth shell are in place and the
> module installs. The usage engine, full inbound API, and webhook processor are stubs
> to be implemented from the writing-plans output.
## Why this module is small
We build **only** the metering + integration layer. Everything financial — recurring
invoicing, HST tax, proration, dunning, customer portal, credit notes, Stripe — is
**native Odoo Enterprise** (`sale_subscription`, `account_accountant`, `payment_stripe`),
already installed and running.
## Design decisions (locked)
1. Odoo fully replaces Lago (we build the metered-billing engine; Lago is decommissioned last).
2. One unified `res.partner` per client; **separate invoice per service**.
3. **Apps drive**, Odoo is the billing system of record — apps call the inbound API (as they call Lago today); Odoo bills and webhooks back.
4. Odoo owns the **billing catalog**; apps own **feature entitlements** (shared `plan_code`).
5. Pilot = **NexaCloud**, phased dual-run cutover.
6. **Aggregate-push** usage ingestion (periodic counters, not a raw-event firehose).
## Models (`fusion.billing.*`)
| Model | Purpose |
|---|---|
| `fusion.billing.service` | One source app; bearer API key (hashed) + webhook config. |
| `fusion.billing.account.link` | External account id → one `res.partner` (identity resolution). |
| `fusion.billing.metric` | Billable metric + aggregation (sum/max/last/unique). |
| `fusion.billing.charge` | Plan + metric → included quota + overage pricing. |
| `fusion.billing.usage` | Aggregated per-period usage rollups (idempotent). |
| `fusion.billing.webhook` | Outbound lifecycle event queue (HMAC + retry). |
| `fusion.billing.reconciliation` | Dual-run Odoo-vs-app delta during cutover. |
> **Odoo 19 note (verified):** a subscription is a `sale.order` with `is_subscription=True`
> (`plan_id` → `sale.subscription.plan`). There is **no** `sale.subscription` model.
> `fusion.billing.usage.subscription_id` therefore points at `sale.order`.
## Inbound API
Lago-shaped REST under `/api/billing/v1/*`, bearer auth. Endpoints mirror NexaDesk's
existing `lago-client.ts` so migration is a thin client swap. `/health` works today;
the rest return `501` until implemented.
## Relationship to `fusion_api`
`fusion_api` manages **outbound** provider keys (OpenAI, Maps, Twilio) + cost tracking —
i.e. COGS. This module tracks **customer** revenue. Complementary: feed `fusion_api`
cost into margin reporting; reuse its daily-rollup aggregation pattern.
## Dependencies
`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`.
## Local dev
```bash
docker exec odoo-nexa-app odoo -d nexamain -u fusion_centralize_billing --stop-after-init
# tests (once added):
docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init
```
Canadian English, CAD, HST via `account.tax`. New fields on native models use the `x_fc_*` prefix.

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
"name": "Fusion Centralized Billing",
"version": "19.0.1.0.0",
"category": "Accounting/Subscriptions",
"summary": "Centralized billing engine for all NexaSystems services — metered usage, "
"per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.",
"description": """
Fusion Centralized Billing
==========================
Makes this Odoo Enterprise instance the single billing brain for every NexaSystems
service (NexaCloud hosting, NexaDesk chat, NexaMaps API, custom apps, memberships).
It adds ONLY the metering + integration layer; all financial behaviour (invoicing,
HST tax, proration, dunning, portal, credit notes, Stripe) is native Odoo Enterprise.
Capabilities
------------
* Service registry — one record per source app (NexaCloud / NexaDesk / NexaMaps) with
bearer API key + webhook config.
* Identity links — fold each app's external account into one ``res.partner``.
* Metric + Charge catalog — billable metrics with quota + overage pricing, keyed by a
shared ``plan_code`` (apps own feature entitlements; Odoo owns money).
* Usage engine — aggregate-push: apps send periodic counters; a pre-invoice cron feeds
billable quantities onto the subscription ``sale.order``.
* Inbound API — Lago-shaped REST (``/api/billing/v1/*``), bearer auth.
* Outbound webhooks — HMAC-signed lifecycle events (payment failed/succeeded,
subscription terminated) so apps suspend / restore / deprovision.
Design spec: docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md
Status: SCAFFOLD. Model fields are in place; engine/API/webhook bodies are stubs to be
implemented via the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
reference files from the container before implementing subscription/account internals.
""",
"author": "Nexa Systems Inc.",
"website": "https://nexasystems.ca",
"license": "OPL-1",
"depends": [
"account_accountant",
"sale_subscription",
"sale_management",
"payment_stripe",
],
"data": [
"security/ir.model.access.csv",
],
"installable": True,
"application": False,
"auto_install": False,
}

View File

@@ -0,0 +1 @@
from . import api

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Inbound, Lago-shaped billing API (spec §7).
Auth: bearer API key matched (by SHA-256 hash) against ``fusion.billing.service``.
Routing: ``type="http"`` + ``auth="none"`` + ``csrf=False`` — external apps present
bearer tokens, not Odoo sessions (so NOT ``type="jsonrpc"``).
STATUS: SCAFFOLD. Only auth + /health are wired. Endpoint bodies are stubs (HTTP 501)
to be implemented from the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
references (sale.order subscription flow, account.move, payment_stripe) before
implementing — do NOT code those internals from memory.
"""
import hashlib
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
API_BASE = "/api/billing/v1"
class FusionBillingApi(http.Controller):
# ── helpers ──────────────────────────────────────────────────────────
def _authenticate(self):
"""Return the active fusion.billing.service for the bearer key, else None."""
auth = request.httprequest.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
raw = auth[7:].strip()
if not raw:
return None
key_hash = hashlib.sha256(raw.encode()).hexdigest()
service = request.env["fusion.billing.service"].sudo().search(
[("api_key_hash", "=", key_hash), ("active", "=", True)], limit=1,
)
return service or None
def _json(self, payload, status=200):
return request.make_json_response(payload, status=status)
# ── routes ───────────────────────────────────────────────────────────
@http.route(f"{API_BASE}/health", type="http", auth="none", methods=["GET"], csrf=False)
def health(self, **kw):
return self._json({"status": "ok", "service": "fusion_centralize_billing"})
@http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False)
def post_usage(self, **kw):
"""Hot path: batch aggregated usage counters. Returns 202 once implemented."""
service = self._authenticate()
if not service:
return self._json({"error": "unauthorized"}, status=401)
# TODO(spec §6): idempotent upsert into fusion.billing.usage by idempotency_key.
return self._json({"error": "not_implemented"}, status=501)
# TODO(spec §7): implement the remaining Lago-shaped endpoints, each gated by
# self._authenticate():
# POST /customers upsert res.partner + account.link
# POST /subscriptions create subscription sale.order
# PUT /subscriptions/<id> change / upgrade
# DELETE /subscriptions/<id> cancel
# POST /invoices one-off invoice (token pack, throttle-removal)
# GET /invoices list (filter by external customer)
# GET /invoices/<id> fetch
# POST /invoices/<id>/download PDF
# POST /invoices/<id>/retry_payment retry
# POST /invoices/<id>/void void
# POST /credit_notes refund (account.move reversal)
# GET /plans catalog/pricing for the app
# POST /customers/<id>/checkout_url Stripe payment-method setup

View File

@@ -0,0 +1,7 @@
from . import service
from . import account_link
from . import metric
from . import charge
from . import usage
from . import webhook
from . import reconciliation

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingAccountLink(models.Model):
"""Identity resolution: maps an app's external account id to one res.partner.
Folds the NexaCloud user / NexaDesk tenant / NexaMaps client for the same
real-world client onto a single partner (the unified customer). See spec §5.1.
"""
_name = "fusion.billing.account.link"
_description = "Fusion Billing — External Account → Partner Link"
_order = "service_id, external_id"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
external_id = fields.Char(
required=True, index=True,
help="The app's own account id (NexaCloud user, NexaDesk tenant, Maps client).",
)
external_email = fields.Char()
partner_id = fields.Many2one(
"res.partner", required=True, ondelete="restrict", index=True,
)
_service_external_uniq = models.Constraint(
"unique(service_id, external_id)",
"An external account can only link to one partner per service.",
)

View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingCharge(models.Model):
"""Maps a plan + metric to quota + overage pricing.
This is where "5,000,000 included / $0.10 per 1k overage" (NexaMaps) or a
NexaCloud CPU-seconds quota lives. Keyed by the shared ``plan_code`` the app
references; Odoo owns the money, the app owns feature entitlements. See spec §5.1.
"""
_name = "fusion.billing.charge"
_description = "Fusion Billing — Metered Charge (quota + overage)"
_order = "plan_code, name"
name = fields.Char(required=True)
plan_code = fields.Char(
required=True, index=True,
help="Shared plan_code the source app references (matches a sale.subscription.plan).",
)
plan_id = fields.Many2one(
"sale.subscription.plan",
help="Optional link to the Odoo recurrence/plan for this charge.",
)
metric_id = fields.Many2one(
"fusion.billing.metric", required=True, ondelete="restrict",
)
product_id = fields.Many2one(
"product.product", help="Usage product invoiced for overage.",
)
included_quota = fields.Float(
default=0.0, help="Units included before overage applies, per period.",
)
price_per_unit = fields.Monetary(help="Overage price per unit_batch.")
unit_batch = fields.Float(
default=1.0, help="Batch size for overage pricing, e.g. 1000 = priced per 1k.",
)
charge_model = fields.Selection(
[
("standard", "Standard (per unit)"),
("graduated", "Graduated"),
("package", "Package"),
("volume", "Volume"),
],
default="standard", required=True,
)
currency_id = fields.Many2one(
"res.currency", default=lambda self: self.env.company.currency_id,
)
active = fields.Boolean(default=True)

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingMetric(models.Model):
"""A billable metric (CPU-seconds, API calls, messages, tokens ...).
Defines how raw usage is aggregated within a billing period. See spec §5.1 / §6.
"""
_name = "fusion.billing.metric"
_description = "Fusion Billing — Billable Metric"
_order = "code"
name = fields.Char(required=True)
code = fields.Char(required=True, index=True)
aggregation = fields.Selection(
[
("sum", "Sum"),
("max", "Max"),
("last", "Last value"),
("unique_count", "Unique count"),
],
default="sum", required=True,
)
unit_label = fields.Char(help="e.g. CPU-seconds, API calls, messages, tokens.")
rounding = fields.Float(default=1.0)
active = fields.Boolean(default=True)
_code_uniq = models.Constraint("unique(code)", "Metric code must be unique.")

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingReconciliation(models.Model):
"""Dual-run shadow-mode comparison: Odoo-computed vs the app's actual billing.
During phased cutover (NexaCloud first), Odoo computes invoices while the app
keeps charging. This row records the per-customer, per-period delta so we only
flip once deltas are within tolerance. See spec §10.
"""
_name = "fusion.billing.reconciliation"
_description = "Fusion Billing — Dual-Run Reconciliation"
_order = "period desc, service_id"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
partner_id = fields.Many2one("res.partner", required=True, ondelete="cascade", index=True)
period = fields.Char(required=True, help="Billing period label, e.g. 2026-05.")
odoo_amount = fields.Monetary()
external_amount = fields.Monetary(string="App-actual Amount")
delta = fields.Monetary(help="odoo_amount - external_amount.")
currency_id = fields.Many2one(
"res.currency", default=lambda self: self.env.company.currency_id,
)
status = fields.Selection(
[
("match", "Within tolerance"),
("delta", "Delta — investigate"),
("resolved", "Resolved"),
],
default="delta", required=True, index=True,
)
note = fields.Text()

View File

@@ -0,0 +1,56 @@
# -*- 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

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingUsage(models.Model):
"""Aggregated usage rollup for a (subscription, metric, period).
Aggregate-push model: apps send periodic counters (not raw events). The
``idempotency_key`` makes re-sent counters safe — they never double-count.
A pre-invoice cron sums these and feeds billable quantity onto the subscription.
NOTE (Odoo 19, verified): the subscription is a ``sale.order`` with
``is_subscription=True`` — there is no ``sale.subscription`` model. See spec §5.2.
"""
_name = "fusion.billing.usage"
_description = "Fusion Billing — Aggregated Usage (period rollup)"
_order = "period_start desc"
subscription_id = fields.Many2one(
"sale.order", required=True, ondelete="cascade", index=True,
string="Subscription", domain=[("is_subscription", "=", True)],
)
metric_id = fields.Many2one(
"fusion.billing.metric", required=True, ondelete="restrict", index=True,
)
period_start = fields.Datetime(required=True)
period_end = fields.Datetime(required=True)
quantity = fields.Float(default=0.0)
source = fields.Char(default="push")
idempotency_key = fields.Char(
index=True, help="Dedupe key so re-sent counters never double-count.",
)
_idempotency_uniq = models.Constraint(
"unique(idempotency_key)", "Usage idempotency key must be unique.",
)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
class FusionBillingWebhook(models.Model):
"""Outbound webhook queue: lifecycle events delivered to source apps.
Processed by a cron with exponential backoff + HMAC-SHA256 signing, dead-lettered
after N attempts (mirror the proven retry pattern in NexaDesk's
lago-payment-retry-job). Apps react: suspend / restore / deprovision. See spec §8.
TODO(spec §8): cron processor, HMAC signing, backoff schedule.
"""
_name = "fusion.billing.webhook"
_description = "Fusion Billing — Outbound Webhook Event"
_order = "create_date desc"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
event_type = fields.Char(
required=True, index=True,
help="invoice.payment_failed / invoice.payment_succeeded / "
"subscription.terminated / subscription.reactivated / usage.threshold_reached",
)
payload = fields.Json()
state = fields.Selection(
[
("pending", "Pending"),
("sent", "Sent"),
("failed", "Failed"),
("dead", "Dead-lettered"),
],
default="pending", required=True, index=True,
)
attempts = fields.Integer(default=0)
next_retry_at = fields.Datetime()
signature = fields.Char(help="HMAC-SHA256 of the payload using the service webhook_secret.")
last_error = fields.Text()

View File

@@ -0,0 +1,11 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_billing_service_admin,fusion.billing.service admin,model_fusion_billing_service,base.group_system,1,1,1,1
access_fusion_billing_account_link_admin,fusion.billing.account.link admin,model_fusion_billing_account_link,base.group_system,1,1,1,1
access_fusion_billing_metric_admin,fusion.billing.metric admin,model_fusion_billing_metric,base.group_system,1,1,1,1
access_fusion_billing_charge_admin,fusion.billing.charge admin,model_fusion_billing_charge,base.group_system,1,1,1,1
access_fusion_billing_usage_admin,fusion.billing.usage admin,model_fusion_billing_usage,base.group_system,1,1,1,1
access_fusion_billing_webhook_admin,fusion.billing.webhook admin,model_fusion_billing_webhook,base.group_system,1,1,1,1
access_fusion_billing_reconciliation_admin,fusion.billing.reconciliation admin,model_fusion_billing_reconciliation,base.group_system,1,1,1,1
access_fusion_billing_metric_acct,fusion.billing.metric accountant,model_fusion_billing_metric,account.group_account_manager,1,1,1,0
access_fusion_billing_charge_acct,fusion.billing.charge accountant,model_fusion_billing_charge,account.group_account_manager,1,1,1,0
access_fusion_billing_reconciliation_acct,fusion.billing.reconciliation accountant,model_fusion_billing_reconciliation,account.group_account_manager,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_billing_service_admin fusion.billing.service admin model_fusion_billing_service base.group_system 1 1 1 1
3 access_fusion_billing_account_link_admin fusion.billing.account.link admin model_fusion_billing_account_link base.group_system 1 1 1 1
4 access_fusion_billing_metric_admin fusion.billing.metric admin model_fusion_billing_metric base.group_system 1 1 1 1
5 access_fusion_billing_charge_admin fusion.billing.charge admin model_fusion_billing_charge base.group_system 1 1 1 1
6 access_fusion_billing_usage_admin fusion.billing.usage admin model_fusion_billing_usage base.group_system 1 1 1 1
7 access_fusion_billing_webhook_admin fusion.billing.webhook admin model_fusion_billing_webhook base.group_system 1 1 1 1
8 access_fusion_billing_reconciliation_admin fusion.billing.reconciliation admin model_fusion_billing_reconciliation base.group_system 1 1 1 1
9 access_fusion_billing_metric_acct fusion.billing.metric accountant model_fusion_billing_metric account.group_account_manager 1 1 1 0
10 access_fusion_billing_charge_acct fusion.billing.charge accountant model_fusion_billing_charge account.group_account_manager 1 1 1 0
11 access_fusion_billing_reconciliation_acct fusion.billing.reconciliation accountant model_fusion_billing_reconciliation account.group_account_manager 1 1 1 0