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>
75 lines
3.6 KiB
Python
75 lines
3.6 KiB
Python
# -*- 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
|