- C1/H4: rating cron only rates subs on the charge's own plan_id
- C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing)
- C2/C4: /usage authorizes each event (exists + is_subscription + linked customer)
- C3: API handlers validate input and return 4xx-shaped errors instead of raising;
controller maps status=='error' to HTTP 400
- H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date)
- H2: _aggregate uses half-open window anchored on period_start
- H3: idempotency scoped to (subscription_id, metric_id, idempotency_key)
- H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id,
caps backoff at 2**min(attempts,10)
- H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url
- M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume)
- L1: currency_id required on charge + reconciliation
- L2: charge price non-negative + unit_batch positive DB constraints
Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
96 lines
4.1 KiB
Python
96 lines
4.1 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 json
|
|
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
|
|
return request.env["fusion.billing.service"].sudo()._match_api_key(auth[7:].strip()) or None
|
|
|
|
def _json(self, payload, status=200):
|
|
return request.make_json_response(payload, status=status)
|
|
|
|
def _read_json(self):
|
|
try:
|
|
raw = request.httprequest.get_data(as_text=True) or "{}"
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return None
|
|
|
|
# ── 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}/customers", type="http", auth="none", methods=["POST"], csrf=False)
|
|
def post_customer(self, **kw):
|
|
service = self._authenticate()
|
|
if not service:
|
|
return self._json({"error": "unauthorized"}, status=401)
|
|
payload = self._read_json()
|
|
if payload is None:
|
|
return self._json({"error": "invalid json"}, status=400)
|
|
result = service._api_upsert_customer(payload)
|
|
if result.get("status") == "error":
|
|
return self._json(result, status=400)
|
|
return self._json(result)
|
|
|
|
@http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False)
|
|
def post_usage(self, **kw):
|
|
service = self._authenticate()
|
|
if not service:
|
|
return self._json({"error": "unauthorized"}, status=401)
|
|
payload = self._read_json()
|
|
if payload is None:
|
|
return self._json({"error": "invalid json"}, status=400)
|
|
result = service._api_record_usage(payload)
|
|
if result.get("status") == "error":
|
|
return self._json(result, status=400)
|
|
return self._json(result, status=202)
|
|
|
|
@http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False)
|
|
def get_plans(self, **kw):
|
|
service = self._authenticate()
|
|
if not service:
|
|
return self._json({"error": "unauthorized"}, status=401)
|
|
return self._json(service._api_catalog())
|
|
|
|
@http.route(f"{API_BASE}/subscriptions", type="http", auth="none", methods=["POST"], csrf=False)
|
|
def post_subscription(self, **kw):
|
|
service = self._authenticate()
|
|
if not service:
|
|
return self._json({"error": "unauthorized"}, status=401)
|
|
payload = self._read_json()
|
|
if payload is None:
|
|
return self._json({"error": "invalid json"}, status=400)
|
|
result = service._api_create_subscription(payload)
|
|
if result.get("status") == "error":
|
|
return self._json(result, status=400)
|
|
return self._json(result)
|