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>
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
- Odoo fully replaces Lago. Build a metered-billing engine inside
fusion_centralize_billing; decommission Lago VM 318 at the end. - One unified customer, separate invoice per service. One
res.partnerper real client; each service bills on its own subscription/cycle. No cross-product invoice merging. - 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.
- 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. - Pilot = NexaCloud, phased dual-run cutover (one product at a time, parallel run + reconciliation before flip).
- Aggregate-push usage ingestion. Apps push periodic pre-aggregated counters; Odoo stores rollups and feeds native
sale.subscriptionmetered 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 nosale.subscriptionmodel. A subscription IS asale.orderwithis_subscription=True,plan_id→sale.subscription.plan, plussubscription_state/next_invoice_date/recurring_monthly. Every "subscription" reference in this spec means that. The usage engine linksfusion.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)
- Apps
POST /usagewith periodic counters and anidempotency_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 intofusion.billing.usagekeyed byidempotency_keyso retries never double-bill. - A pre-invoice cron (runs ahead of each subscription's invoice date) sums the
period's
fusion.billing.usageper metric, applies the matchingfusion.billing.charge(quota → free, overage → priced bycharge_model), and writes the billable quantity/amount onto the subscription's draft invoice line (usage product). - 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.partnerviaaccount.link;nexacloud.products/plans→product.template+ subscription plans (plan_code= NexaCloud plan id/slug, prices fromprice_monthly/price_yearly);nexacloud.deployments+subscriptions→ one subscriptionsale.orderper deployment (NexaCloud bills per deployment). - Metering: CPU-seconds →
fusion.billing.metriccpu_seconds(sum) +charge(included = plan quota, overage priced). Throttle-removal fee → one-off invoice (or add-on product).nexacloud/.../usage_metering.pypushes counters to/usage. - Control loop:
invoice.payment_failed→ NexaCloud suspends using its existingnetwork_isolation/throttle_checker/resource_manager;subscription.terminated→ NexaCloud deprovisions.
10. Dual-run + migration (phased)
- Import NexaCloud customers + active subscriptions into Odoo (script reads the
nexacloudDB → creates partners / links / subscriptions / charges). - Shadow mode ≥ 1 billing cycle: Odoo computes invoices while NexaCloud keeps
charging via its own Stripe.
fusion.billing.reconciliationdiffs Odoo-computed vs NexaCloud-actual per customer/period; investigate every delta. - 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.
- 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 directsk_livekey 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'spayment_stripeconnects to that single account and reuses existing Stripe customers + saved payment methods (map each Stripeprovider_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 (perK:\Github\CLAUDE.md). - Tax: HST/GST per Canadian province via
account.tax; confirm tax codes align with current Lagohst_onusage. - 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:
fusion_centralize_billingcore — service registry, identity links, metric/charge catalog, usage engine, inbound API, outbound webhook engine. (detailed below — first deliverable)- NexaCloud adapter + dual-run reconciliation (the pilot — coupled to #1)
- NexaDesk adapter (swap the Lago client for the Odoo billing client)
- NexaMaps adapter
- Lago decommission + memberships/services onboarding + portal polish
13. First-deliverable scope (sub-projects #1 + #2)
In scope
fusion_centralize_billingmodule skeleton (manifest, security/ACLs + record rules, README) following thenexa_coa_setuplayout.- 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_setupif 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.ordervia the API, with oneres.partnerresolving the NexaCloud user. - CPU-seconds counters pushed to
/usageaggregate correctly and produce a draft invoice with quota + overage applied, taxed (HST), and charged throughpayment_stripe. - A simulated
invoice.payment_faileddelivers 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
Stripe: one account across all products, or separate?ANSWERED (2026-05-27): one accountacct_1ShlA9IkwUB1dVoxfor everything (NexaCloud direct + Lago'snexasystems/nexadesk/nexamapsproviders). No account migration; reuse existing Stripe customers + payment methods.- NexaCloud billing granularity — confirm one subscription per deployment (vs one per customer with deployment line items).
- Membership model — Odoo native
membershipmodule, or model memberships as plain recurring subscriptions? - Spec/module commit target — confirm branch strategy in
Odoo-Modules(currently onfeat/fusion-login-audit).