diff --git a/CLAUDE.md b/CLAUDE.md
index 25ba8d7d..4f0bdc32 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,6 +31,8 @@
13. **`ir.cron` dropped `numbercall`** in Odoo 19. Old recipes set `-1` for "run forever"; that now raises `ValueError: Invalid field 'numbercall' in 'ir.cron'` at install time. Just omit the field — recurring crons keep running as long as `active=True`. Source: `odoo/addons/base/models/ir_cron.py` field list.
14. **`cr.commit()` / `cr.rollback()` raise AssertionError inside `TransactionCase`** — they are NOT silent no-ops in Odoo 19. The test cursor explicitly refuses both ("Cannot commit or rollback a cursor from inside a test, this will lead to a broken cursor when trying to rollback the test. Please rollback to a specific savepoint instead..."). For cron/worker code that needs per-row isolation so one bad row doesn't roll back the whole batch, use `with self.env.cr.savepoint(): ...` inside the loop instead of `cr.commit()`. Savepoints work in both prod (under the outer cron transaction) and tests (under the outer test transaction). The cron transaction commits the whole batch when the method returns; in tests everything rolls back cleanly. Source: `odoo/sql_db.py::TestCursor.commit` and `Cursor.savepoint()`.
+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