Files
Odoo-Modules/docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md
gsinghpal 2b47bd8b10 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>
2026-05-27 08:40:51 -04:00

17 KiB

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_idsale.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_idsale.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.usersres.partner via account.link; nexacloud.products/plansproduct.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_idres.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).