feat(billing): design + scaffold fusion_centralize_billing

Centralize billing for all NexaSystems services (NexaCloud, NexaDesk,
NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance,
replacing Lago. The module adds only the metering + integration layer;
native sale_subscription / account_accountant / payment_stripe do all the
financial work (invoicing, HST, dunning, portal, credit notes, Stripe).

Includes:
- Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md):
  6 locked decisions, architecture, data model, usage engine, Lago-shaped
  API, webhook control loop, NexaCloud pilot, phased dual-run migration.
- Module scaffold: 7 fusion.billing.* models (service, account.link, metric,
  charge, usage, webhook, reconciliation), bearer-auth API controller shell,
  security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs
  pending the implementation plan.
- CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription
  is a sale.order(is_subscription) + sale.subscription.plan (verified live).

Task 0 verified: a single Stripe account is shared across NexaCloud and all
Lago providers, so no Stripe account/card migration is required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-27 02:14:19 -04:00
parent 5764d439c3
commit 2b47bd8b10
16 changed files with 785 additions and 0 deletions

View File

@@ -0,0 +1,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`).