From 2b47bd8b1082363bf7b5e68e5ab0fae902ea0fc0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:14:19 -0400 Subject: [PATCH] feat(billing): design + scaffold fusion_centralize_billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 2 + ...6-05-27-nexa-billing-centralized-design.md | 271 ++++++++++++++++++ fusion_centralize_billing/README.md | 70 +++++ fusion_centralize_billing/__init__.py | 2 + fusion_centralize_billing/__manifest__.py | 54 ++++ .../controllers/__init__.py | 1 + fusion_centralize_billing/controllers/api.py | 74 +++++ fusion_centralize_billing/models/__init__.py | 7 + .../models/account_link.py | 33 +++ fusion_centralize_billing/models/charge.py | 53 ++++ fusion_centralize_billing/models/metric.py | 32 +++ .../models/reconciliation.py | 38 +++ fusion_centralize_billing/models/service.py | 56 ++++ fusion_centralize_billing/models/usage.py | 39 +++ fusion_centralize_billing/models/webhook.py | 42 +++ .../security/ir.model.access.csv | 11 + 16 files changed, 785 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md create mode 100644 fusion_centralize_billing/README.md create mode 100644 fusion_centralize_billing/__init__.py create mode 100644 fusion_centralize_billing/__manifest__.py create mode 100644 fusion_centralize_billing/controllers/__init__.py create mode 100644 fusion_centralize_billing/controllers/api.py create mode 100644 fusion_centralize_billing/models/__init__.py create mode 100644 fusion_centralize_billing/models/account_link.py create mode 100644 fusion_centralize_billing/models/charge.py create mode 100644 fusion_centralize_billing/models/metric.py create mode 100644 fusion_centralize_billing/models/reconciliation.py create mode 100644 fusion_centralize_billing/models/service.py create mode 100644 fusion_centralize_billing/models/usage.py create mode 100644 fusion_centralize_billing/models/webhook.py create mode 100644 fusion_centralize_billing/security/ir.model.access.csv diff --git a/CLAUDE.md b/CLAUDE.md index c5033b04..23784c83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,8 @@ 7. **Search views**: NO `group expand="0"` syntax. 8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file. +15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`. + ## Card Styling — Copy Odoo's Kanban Pattern Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values: ```css diff --git a/docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md b/docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md new file mode 100644 index 00000000..787026d1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md @@ -0,0 +1,271 @@ +# fusion_centralize_billing — Centralized Billing Engine on Odoo 19 + +- **Date:** 2026-05-27 +- **Status:** Design approved — pending written-spec review +- **Author:** Design session (Claude + Gurpreet) +- **Module:** `fusion_centralize_billing` (target: `K:\Github\Odoo-Modules\fusion_centralize_billing`) +- **Host:** odoo-nexa (Proxmox VM 315, worker1), Odoo 19 **Enterprise**, live DB `nexamain` + +## 1. Goal + +Make the Odoo Enterprise instance (`odoo-nexa`) the single billing brain for every +NexaSystems service — hosting (NexaCloud), live chat (NexaDesk/Fusion-Chat), the +metered maps API (NexaMaps), plus custom-app retainers, memberships, and one-off +services. It replaces Lago in the role Lago currently plays, and absorbs NexaCloud's +home-grown Stripe billing, so there is one customer ledger, one accounting system, +one place revenue is recognized. + +## 2. Current state (recon, 2026-05-27) + +Billing is fragmented across **three+ independent engines**: + +| System | Bills for | Engine today | Data home | +|---|---|---|---| +| **NexaCloud** (LXC 102, `10.200.0.250`) | VPS/LXC hosting, Coolify apps, CPU-seconds + throttle-removal fees, snapshots, domains | Own Postgres models + **direct Stripe** (`stripe_service.py`, `billing_service.py`, `usage_metering.py`, `invoice_generator.py`) | `nexacloud` DB (LXC 201) | +| **NexaDesk / Fusion-Chat** (VM 314) | Chat plans (monthly/annual), feature + channel add-ons, message/token overage, token wallets | **Lago** v1.44.0 (VM 318) + Stripe (provider code `nexadesk`) | Lago (VM 318, `192.168.1.117`) | +| **NexaMaps** (`fusionapps.maps_*`) | Metered geocoding/routing API: monthly quota + overage per 1k | Own tables; **~189k usage events / month** for 2 clients | Supabase `fusionapps` | +| Services / memberships | Custom apps, consulting, retainers | ad-hoc / manual | — | + +**Decisive fact:** `odoo-nexa` is **Odoo 19 Enterprise** and already runs the full +Lago-equivalent stack: `sale_subscription` (+ `_stock`, `_timesheet`, +`_external_tax`), `account_accountant`, `payment_stripe`, `website_sale` + +`website_sale_subscription`, `crm/project/industry_fsm_sale_subscription`, plus +custom `nexa_coa_setup`, `fusion_whitelabels`, `fusion_helpdesk_central`, +`fusion_pdf_preview`. So Odoo already does subscriptions, recurring invoicing, full +accounting/GL, Stripe, HST taxes, customer portal, credit notes, and self-serve +checkout. + +**The only capability Lago has that Odoo lacks natively is usage-based metered +billing** (billable metrics → aggregation → quota/overage charges). That, plus the +integration surface, is all we build. + +Prior decision on record (Supabase `fusionapps.decisions`): Lago was deployed as the +centralizer for NexaDesk + NexaCloud. This design **supersedes** that — the billing +brain moves into the Odoo Enterprise already owned and operated. + +## 3. Decisions locked in this session + +1. **Odoo fully replaces Lago.** Build a metered-billing engine inside `fusion_centralize_billing`; decommission Lago VM 318 at the end. +2. **One unified customer, separate invoice per service.** One `res.partner` per real client; each service bills on its own subscription/cycle. No cross-product invoice merging. +3. **Apps drive; Odoo is the billing system of record.** Each app keeps its own signup, provisioning, and entitlement enforcement, and calls Odoo's billing API (the same way it calls Lago today). Odoo invoices, charges Stripe, and emits webhooks back. +4. **Odoo owns the billing catalog; apps own entitlements.** Odoo is SoR for products, prices, recurrence, metric rate/quota/overage, taxes — keyed by a stable `plan_code`. Apps enforce feature limits (max_chatbots, CPU quota, API rate-limit) against the same code. +5. **Pilot = NexaCloud, phased dual-run cutover** (one product at a time, parallel run + reconciliation before flip). +6. **Aggregate-push usage ingestion.** Apps push periodic pre-aggregated counters; Odoo stores rollups and feeds native `sale.subscription` metered lines. No raw-event firehose into Odoo. + +## 4. Architecture + +``` + NexaCloud NexaDesk NexaMaps (apps keep signup + provisioning + entitlements) + │ │ │ + │ customers / subscriptions / usage counters (inbound REST, API-key bearer auth) + ▼ ▼ ▼ +┌──────────────────────────────────────────────┐ +│ fusion_centralize_billing (custom Odoo 19 module) │ +│ • Service registry (one row per app) │ +│ • Identity links (ext acct → res.partner) │ +│ • Metric + Charge catalog (quota/overage) │ +│ • Usage engine (ingest → aggregate → bill) │ +│ • Outbound webhook queue (HMAC + retry) │ +└───────────────┬────────────────────────────────┘ + │ writes billable qty onto + ▼ + sale.order(is_subscription) → account.move → payment_stripe (NATIVE Odoo Enterprise) + │ invoicing, HST tax, proration, + │ invoice paid / failed / sub ended dunning, portal, credit notes + ▼ + outbound webhooks ──► apps suspend / restore / deprovision +``` + +Principle: **build only the metering + integration layer; inherit all financial +behaviour from native Odoo Enterprise.** + +## 5. Data model + +### 5.1 New models (`fusion.billing.*`) + +| Model | Key fields | Purpose | +|---|---|---| +| `fusion.billing.service` | `name`, `code` (nexacloud/nexadesk/nexamaps), `api_key_hash`, `webhook_url`, `webhook_secret`, `active` | One row per source app — the auth + routing boundary. | +| `fusion.billing.account.link` | `service_id`, `external_id`, `partner_id`, `external_email`; unique `(service_id, external_id)` | Identity resolution: folds each app's account into one `res.partner`. | +| `fusion.billing.metric` | `code`, `name`, `aggregation` (sum/max/last/unique_count), `unit_label`, `rounding` | Billable metric definition. | +| `fusion.billing.charge` | `plan_ref`/`product_id`, `metric_id`, `included_quota`, `price_per_unit`, `unit_batch` (e.g. per 1000), `charge_model` (standard/graduated/package/volume) | Maps a plan + metric → quota & overage pricing. Where "5M quota / $0.10 per 1k" lives. | +| `fusion.billing.usage` | `subscription_id`, `metric_id`, `period_start`, `period_end`, `quantity`, `source`, `idempotency_key`; index `(subscription, metric, period)` | **Aggregated** usage rows (rollups, not raw events). | +| `fusion.billing.webhook` | `service_id`, `event_type`, `payload` (JSON), `state` (pending/sent/failed/dead), `attempts`, `next_retry_at`, `signature` | Outbound event queue, processed by cron with backoff + HMAC. | +| `fusion.billing.reconciliation` | `service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` | Dual-run shadow-mode comparison (Odoo-computed vs app-actual). | + +### 5.2 Native models reused as-is + +`res.partner` (customer), **`sale.order` with `is_subscription=True`** (the subscription), +`sale.subscription.plan` (recurrence/plan), `sale.order.line` (metered lines), +`account.move` (invoice + credit note), `payment_stripe`/`payment.transaction` (Stripe), +`account.tax` (HST per province), customer portal. Catalog = `product.template` + +`sale.subscription.plan`, tagged with the shared `plan_code`. + +New fields on native models use the `x_fc_*` prefix (e.g. `res.partner.x_fc_billing_external_ids`). + +> **Odoo 19 modeling note (verified on live `nexamain`, 2026-05-27):** there is **no +> `sale.subscription` model**. A subscription IS a `sale.order` with `is_subscription=True`, +> `plan_id` → `sale.subscription.plan`, plus `subscription_state` / `next_invoice_date` / +> `recurring_monthly`. Every "subscription" reference in this spec means that. The usage +> engine links `fusion.billing.usage.subscription_id` → `sale.order`. + +### 5.3 Relationship to `fusion_api` (reuse, don't duplicate) + +The existing **`fusion_api`** module (`fusion.api.key` / `.consumer` / `.service` / +`.usage` / `.usage.daily`) centralizes **outbound** provider keys (OpenAI, Anthropic, +Google Maps, Twilio) with cost/usage tracking + rate limiting — i.e. what **Nexa pays +providers** (COGS). It is **complementary**, not a substitute: +`fusion_centralize_billing` tracks what **customers owe Nexa**. Two concrete ties: +(a) feed `fusion.api.usage.daily` cost into margin reporting against billed revenue; +(b) mirror its daily-rollup aggregation pattern for `fusion.billing.usage`. The +customer-facing metered billing and the inbound API remain ours to build. + +## 6. Usage engine (aggregate-push) + +1. Apps `POST /usage` with periodic counters and an `idempotency_key` + (e.g. `service:metric:subscription:window`). NexaCloud pushes CPU-seconds per + deployment hourly; NexaMaps pushes api_calls per client daily; NexaDesk pushes + messages/tokens. Upsert into `fusion.billing.usage` keyed by `idempotency_key` so + retries never double-bill. +2. A **pre-invoice cron** (runs ahead of each subscription's invoice date) sums the + period's `fusion.billing.usage` per metric, applies the matching + `fusion.billing.charge` (quota → free, overage → priced by `charge_model`), and + writes the billable quantity/amount onto the subscription's draft invoice line + (usage product). +3. Native subscription invoicing issues the invoice, applies HST, and charges Stripe. + Quota resets per period. + +At ~189k Maps events/month pushed as daily counters, Odoo stores ≈30 rows per client +per metric per month — trivial volume. + +## 7. Inbound API (Lago-shaped, drop-in) + +Base path `/api/billing/v1/*`. Odoo 19 routing: `type="http"`, `auth="none"`, +`csrf=False`, manual **Bearer** API-key check against `fusion.billing.service` +(hashed), JSON request/response via `request.make_json_response`, per-service rate +limiting. (`type="jsonrpc"` is for Odoo session RPC — not used here, because external +apps authenticate with bearer tokens, not Odoo sessions.) + +Endpoints intentionally mirror `Fusion-Chat/src/lib/billing/lago-client.ts` so the +NexaDesk swap is ≈ one file, and NexaCloud's integration is a thin client: + +| Method · Path | Maps to | +|---|---| +| `POST /customers` | upsert `res.partner` + `account.link` (identity resolution) | +| `POST /subscriptions` · `PUT /subscriptions/:id` · `DELETE /subscriptions/:id` | create / change-upgrade / cancel subscription `sale.order` | +| `POST /usage` | batch aggregated counters (hot path → 202 Accepted) | +| `POST /invoices` | one-off invoice (token packs, throttle-removal fee) | +| `GET /invoices` · `GET /invoices/:id` · `POST /invoices/:id/download` | list / fetch / PDF | +| `POST /invoices/:id/retry_payment` · `POST /invoices/:id/void` | payment retry / void | +| `POST /credit_notes` | refund via `account.move` reversal | +| `GET /plans` · `GET /catalog` | apps fetch pricing (as NexaDesk fetches from Lago) | +| `GET /customers/:id/checkout_url` | Stripe payment-method setup | + +## 8. Outbound webhooks (control loop) + +Odoo → app, HMAC-SHA256 signed, retried with exponential backoff, dead-lettered after +N attempts (reuse the proven pattern in `Fusion-Chat/src/lib/billing/lago-payment-retry-job.ts`): + +| Event | App reaction | +|---|---| +| `invoice.payment_failed` (after dunning) | **suspend** — NexaCloud throttle/network-isolate; NexaDesk suspend tenant; NexaMaps disable API key | +| `invoice.payment_succeeded` / `subscription.reactivated` | **restore** service | +| `subscription.terminated` | **deprovision** | +| `usage.threshold_reached` (80% / 100%, optional) | warn / cap | + +## 9. NexaCloud pilot + +- **Identity & catalog mapping:** `nexacloud.users` → `res.partner` via `account.link`; + `nexacloud.products`/`plans` → `product.template` + subscription plans + (`plan_code` = NexaCloud plan id/slug, prices from `price_monthly`/`price_yearly`); + `nexacloud.deployments` + `subscriptions` → one subscription `sale.order` per deployment + (NexaCloud bills per deployment). +- **Metering:** CPU-seconds → `fusion.billing.metric` `cpu_seconds` (sum) + `charge` + (included = plan quota, overage priced). Throttle-removal fee → one-off invoice + (or add-on product). `nexacloud/.../usage_metering.py` pushes counters to `/usage`. +- **Control loop:** `invoice.payment_failed` → NexaCloud suspends using its existing + `network_isolation` / `throttle_checker` / `resource_manager`; `subscription.terminated` + → NexaCloud deprovisions. + +## 10. Dual-run + migration (phased) + +1. **Import** NexaCloud customers + active subscriptions into Odoo (script reads the + `nexacloud` DB → creates partners / links / subscriptions / charges). +2. **Shadow mode ≥ 1 billing cycle:** Odoo computes invoices while NexaCloud keeps + charging via its own Stripe. `fusion.billing.reconciliation` diffs Odoo-computed vs + NexaCloud-actual per customer/period; investigate every delta. +3. **Flip** when deltas are within tolerance: NexaCloud calls Odoo's API as SoR and + stops its internal Stripe billing. Past invoices stay archived (PDF / opening + balances) — not re-issued. +4. **Repeat** for NexaDesk (retire Lago for chat) → NexaMaps → then decommission + Lago VM 318. + +## 11. Risks & open items + +- **🟢 Stripe account unification — RESOLVED (2026-05-27).** All systems share ONE Stripe + account: **`acct_1ShlA9IkwUB1dVox`** (Nexa Systems Inc, CA, live). Verified live: + NexaCloud's direct `sk_live` key resolves to that account, and Lago has three Stripe + providers (`nexasystems`, `nexadesk`, `nexamaps`) that **all** resolve to the same + account. Therefore **no Stripe account migration is needed** — Odoo's `payment_stripe` + connects to that single account and **reuses existing Stripe customers + saved payment + methods** (map each Stripe `provider_customer_id` → `res.partner`). This removes what + was the biggest migration risk. +- **Idempotency** on usage counters is mandatory (dedupe key) to prevent double billing on retries. +- **Entitlement sync SLA:** on plan change, Odoo webhook informs the app; define how + fast app-side limits must update (and the reconciliation if a webhook is missed). +- **Odoo 19 correctness:** implementation MUST read live reference files from the + container (`docker exec odoo-nexa-app cat …`) before coding subscription/API/account + internals — never from memory (per `K:\Github\CLAUDE.md`). +- **Tax:** HST/GST per Canadian province via `account.tax`; confirm tax codes align + with current Lago `hst_on` usage. +- **Auth hardening:** API keys hashed at rest, per-service scoping, rate limiting, + request audit log; webhook secrets rotated. + +## 12. Phasing — spec sequence + +Each is its own spec → plan → build cycle: + +1. **`fusion_centralize_billing` core** — service registry, identity links, metric/charge catalog, + usage engine, inbound API, outbound webhook engine. *(detailed below — first deliverable)* +2. **NexaCloud adapter + dual-run reconciliation** *(the pilot — coupled to #1)* +3. NexaDesk adapter (swap the Lago client for the Odoo billing client) +4. NexaMaps adapter +5. Lago decommission + memberships/services onboarding + portal polish + +## 13. First-deliverable scope (sub-projects #1 + #2) + +**In scope** +- `fusion_centralize_billing` module skeleton (manifest, security/ACLs + record rules, README) following the `nexa_coa_setup` layout. +- Models in §5.1; new native fields use `x_fc_*`. +- Aggregate-push usage engine (§6) incl. pre-invoice cron + idempotent upsert. +- Inbound API (§7) with bearer auth, and outbound webhook engine (§8). +- NexaCloud mapping + importer + shadow-mode reconciliation (§9, §10). +- Manifest `depends`: `sale_subscription`, `account_accountant`, `payment_stripe`, + `sale_management` (+ `nexa_coa_setup` if COA dependencies apply). + +**Out of scope (YAGNI for now)** +- NexaDesk / NexaMaps adapters (specs #3/#4). +- Raw-event ingestion / per-event audit in Odoo (apps retain raw events). +- Lago decommission (spec #5) — Lago stays running until NexaDesk is migrated. +- Customer-portal redesign — use native portal as-is initially. + +## 14. Success criteria (first deliverable) + +- A NexaCloud deployment can be created as an Odoo subscription `sale.order` via the API, + with one `res.partner` resolving the NexaCloud user. +- CPU-seconds counters pushed to `/usage` aggregate correctly and produce a draft + invoice with quota + overage applied, taxed (HST), and charged through `payment_stripe`. +- A simulated `invoice.payment_failed` delivers a signed webhook NexaCloud can act on. +- Shadow-mode reconciliation report shows Odoo-computed vs NexaCloud-actual within + tolerance for ≥ 1 cycle before any flip. +- No double billing under usage-counter retries (idempotency verified). + +## 15. Open questions for review + +1. ~~Stripe: one account across all products, or separate?~~ **ANSWERED (2026-05-27):** one + account `acct_1ShlA9IkwUB1dVox` for everything (NexaCloud direct + Lago's + `nexasystems`/`nexadesk`/`nexamaps` providers). No account migration; reuse existing + Stripe customers + payment methods. +2. NexaCloud billing granularity — confirm **one subscription per deployment** (vs one per customer with deployment line items). +3. Membership model — Odoo native `membership` module, or model memberships as plain recurring subscriptions? +4. Spec/module commit target — confirm branch strategy in `Odoo-Modules` (currently on `feat/fusion-login-audit`). diff --git a/fusion_centralize_billing/README.md b/fusion_centralize_billing/README.md new file mode 100644 index 00000000..d3a3384b --- /dev/null +++ b/fusion_centralize_billing/README.md @@ -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. diff --git a/fusion_centralize_billing/__init__.py b/fusion_centralize_billing/__init__.py new file mode 100644 index 00000000..f7209b17 --- /dev/null +++ b/fusion_centralize_billing/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/fusion_centralize_billing/__manifest__.py b/fusion_centralize_billing/__manifest__.py new file mode 100644 index 00000000..6662ea80 --- /dev/null +++ b/fusion_centralize_billing/__manifest__.py @@ -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, +} diff --git a/fusion_centralize_billing/controllers/__init__.py b/fusion_centralize_billing/controllers/__init__.py new file mode 100644 index 00000000..4dc7607e --- /dev/null +++ b/fusion_centralize_billing/controllers/__init__.py @@ -0,0 +1 @@ +from . import api diff --git a/fusion_centralize_billing/controllers/api.py b/fusion_centralize_billing/controllers/api.py new file mode 100644 index 00000000..d9001684 --- /dev/null +++ b/fusion_centralize_billing/controllers/api.py @@ -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/ change / upgrade + # DELETE /subscriptions/ cancel + # POST /invoices one-off invoice (token pack, throttle-removal) + # GET /invoices list (filter by external customer) + # GET /invoices/ fetch + # POST /invoices//download PDF + # POST /invoices//retry_payment retry + # POST /invoices//void void + # POST /credit_notes refund (account.move reversal) + # GET /plans catalog/pricing for the app + # POST /customers//checkout_url Stripe payment-method setup diff --git a/fusion_centralize_billing/models/__init__.py b/fusion_centralize_billing/models/__init__.py new file mode 100644 index 00000000..de3d5dfd --- /dev/null +++ b/fusion_centralize_billing/models/__init__.py @@ -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 diff --git a/fusion_centralize_billing/models/account_link.py b/fusion_centralize_billing/models/account_link.py new file mode 100644 index 00000000..66e3c1ed --- /dev/null +++ b/fusion_centralize_billing/models/account_link.py @@ -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.", + ) diff --git a/fusion_centralize_billing/models/charge.py b/fusion_centralize_billing/models/charge.py new file mode 100644 index 00000000..a194f38d --- /dev/null +++ b/fusion_centralize_billing/models/charge.py @@ -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) diff --git a/fusion_centralize_billing/models/metric.py b/fusion_centralize_billing/models/metric.py new file mode 100644 index 00000000..b631e67d --- /dev/null +++ b/fusion_centralize_billing/models/metric.py @@ -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.") diff --git a/fusion_centralize_billing/models/reconciliation.py b/fusion_centralize_billing/models/reconciliation.py new file mode 100644 index 00000000..3805aae1 --- /dev/null +++ b/fusion_centralize_billing/models/reconciliation.py @@ -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() diff --git a/fusion_centralize_billing/models/service.py b/fusion_centralize_billing/models/service.py new file mode 100644 index 00000000..953eac7a --- /dev/null +++ b/fusion_centralize_billing/models/service.py @@ -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 diff --git a/fusion_centralize_billing/models/usage.py b/fusion_centralize_billing/models/usage.py new file mode 100644 index 00000000..f8e12658 --- /dev/null +++ b/fusion_centralize_billing/models/usage.py @@ -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.", + ) diff --git a/fusion_centralize_billing/models/webhook.py b/fusion_centralize_billing/models/webhook.py new file mode 100644 index 00000000..14b2ea81 --- /dev/null +++ b/fusion_centralize_billing/models/webhook.py @@ -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() diff --git a/fusion_centralize_billing/security/ir.model.access.csv b/fusion_centralize_billing/security/ir.model.access.csv new file mode 100644 index 00000000..a7d909ca --- /dev/null +++ b/fusion_centralize_billing/security/ir.model.access.csv @@ -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