Files
Odoo-Modules/docs/superpowers/specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md
gsinghpal 451fc5eafd docs(billing): NexaCloud->Odoo cutover spec + plan 01 (cancel endpoint)
Increment design (phase #2 of the approved 2026-05-27 centralized-billing
spec) to make Odoo fusion_centralize_billing the system of record for
NexaCloud billing: build -> import -> dual-run -> gated flip, NexaCloud first,
one subscription per deployment, go-forward billing only. Plan 01 = the Odoo
subscription-cancel endpoint (test-first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:38:48 -04:00

12 KiB

NexaCloud → Odoo Centralized Billing — Cutover (build-out · dual-run · gated flip)

  • Date: 2026-06-02
  • Status: Design approved — pending written-spec review
  • Author: Design session (Claude + Gurpreet)
  • Parent spec: 2026-05-27-nexa-billing-centralized-design.md (architecture; this doc is its phase #2 — the NexaCloud pilot)
  • Repos: K:\Github\Odoo-Modules\fusion_centralize_billing (engine) + K:\Github\Nexa-Cloud (the NexaCloud adapter)
  • Hosts: odoo-nexa (VM 315, Odoo 19 Enterprise, live DB nexamain); NexaCloud (LXC 102, app 192.168.1.250, DB 192.168.1.50)

1. Goal

Make Odoo (fusion_centralize_billing on odoo-nexa) the system of record for NexaCloud billing: build the engine pieces NexaCloud needs, import NexaCloud's active deployments as Odoo subscriptions, run Odoo in shadow alongside NexaCloud's existing Stripe billing for ≥1 cycle, reconcile to the cent, and then flip NexaCloud onto Odoo behind an explicit go/no-go gate. NexaCloud is the pilot; NexaDesk and NexaMaps follow in later increments. This does not touch Lago.

2. Decisions locked (this session, 2026-06-02)

  1. Sequence: NexaCloud first (per parent spec), then NexaDesk, then NexaMaps.
  2. Granularity: one Odoo subscription per NexaCloud deployment (mirrors nexacloud subscriptions.deployment_id; the existing usage-push and fusion.billing.reconciliation code already key per deployment via x_fc_nexacloud_subscription_id).
  3. Approach A: build → import → dual-run → gated flip, all in this increment; the flip executes only after ≥1 green reconciliation cycle and explicit operator go-ahead.
  4. Go-forward billing only. The importer sets each subscription's next_invoice_date so Odoo bills only future periods. Past NexaCloud periods are never re-issued (this is the exact failure mode of the 2026-05-27 Lago incident — see lago-doublecharge-incident-2026-06 memory).

3. Current state (recon, 2026-06-02)

Engine is installed on nexamain (fusion_centralize_billing v19.0.1.1.0; deps sale_subscription, payment_stripe, account_accountant installed). Runtime rows:

Table Rows Read
fusion_billing_service 1 only nexacloud; webhook_url empty
fusion_billing_account_link 7 identities imported
fusion_billing_metric 1 (cpu_seconds)
fusion_billing_charge 0 no quota/overage pricing yet
fusion_billing_usage 0 nothing ingested
fusion_billing_reconciliation 0 dual-run never run
fusion_billing_webhook 0 control loop never fired
sale_order (is_subscription) 0 no subscriptions exist

Engine code status: webhook.py delivery engine (HMAC + backoff + dead-letter) is complete (its "TODO §8" header comment is stale); usage.py (idempotent upsert + pre-invoice rating cron + aggregation) and reconciliation.py (NexaCloud dual-run) are complete. controllers/api.py implements /health, POST /customers, POST /usage, GET /plans, POST /subscriptions only — the rest of parent-spec §7 is unimplemented (needed by NexaDesk, not NexaCloud).

NexaCloud adapter is present but INERT: config.py odoo_billing_enabled=False, odoo_billing_base_url/odoo_billing_api_key empty; usage_metering.py pushes cpu_seconds only when enabled; routers/odoo_billing.py /billing/webhooks/central returns 404 when disabled; services/odoo_billing_integration.py is the (inert) receiver. Lago is paused (worker+clock stopped) and out of scope here.

4. Scope

4.1 Odoo side (fusion_centralize_billing + catalog data on nexamain)

  1. Charge catalog (the main gap — currently 0).
    • NexaCloud plans/products → product.template + sale.subscription.plan (monthly), each tagged plan_code and a product.default_code of NC-PLAN-<slug> (reconciliation already filters plan lines on default_code LIKE 'NC-PLAN-%').
    • cpu_seconds metric (exists) → one fusion.billing.charge per plan: included_quota = the plan's bundled CPU-seconds, price_per_unit/unit_batch for overage derived from usage_metering.HOURLY_RATES (cpu_per_core=$0.0075/core-hr → per-cpu-second rate). Memory/disk are part of the flat plan today (not metered) — keep them flat unless a plan meters them.
    • Throttle-removal fee and the CPU/RAM/disk/daily-backup add-ons → one-off invoice products / optional recurring add-on products tagged NC-ADDON-<slug>.
    • HST: reuse native account.tax (13% ON); confirm the tax code matches what NexaCloud invoices apply today.
  2. Run the importer (wizards/import_wizard.py): read the nexacloud DB → ensure res.partner + account.link for each active customer (7 exist; backfill any missing), and create one shadow sale.order (is_subscription=True) per active deployment, setting x_fc_nexacloud_subscription_id, x_fc_nexacloud_plan_id, the NC-PLAN-* line, and next_invoice_date = the deployment's next real billing date (go-forward only). Subscriptions start in shadow (draft/not auto-charging).
  3. Inbound API — add only what NexaCloud needs. POST /customers, POST /subscriptions, POST /usage, GET /plans already exist. Add subscription cancel (DELETE /subscriptions/:id → terminate the sale.order) for NexaCloud's deprovision path. All other parent-spec §7 endpoints stay deferred to the NexaDesk increment.
  4. Wire the control loop: set the nexacloud fusion.billing.service.webhook_urlhttps://api.vps.nexasystems.ca/api/v1/billing/webhooks/central, and confirm cron schedules for usage._cron_rate_open_periods and webhook._cron_dispatch are enabled.

4.2 NexaCloud side (Nexa-Cloud repo)

  1. Configure + activate the adapter: set odoo_billing_base_url=https://erp.nexasystems.ca/api/billing/v1, odoo_billing_api_key=<nexacloud service key>. Keep odoo_billing_enabled staged so usage push + the webhook receiver activate for shadow without yet disabling local Stripe.
  2. Identity + subscription sync: on deployment create / cancel, call Odoo POST /customers and POST /subscriptions / cancel (usage push already exists in usage_metering.py). Send a stable external_id (NexaCloud user id) and subscription_external_id (deployment/subscription id) — namespaced, to avoid the cross-app external_id collision noted in nexa-billing-architecture.
  3. Reconciliation feed: push NexaCloud's actual charged amount per (deployment, period) so reconciliation._reconcile_rows can diff Odoo-computed vs NexaCloud-actual. (Source: NexaCloud's own invoices/usage_records.)
  4. Activate the control-loop receiver: routers/odoo_billing.py /billing/webhooks/centralservices/odoo_billing_integration.py maps invoice.payment_failed→suspend (existing network_isolation/throttle_checker/resource_manager), invoice.payment_succeeded/subscription.reactivated→restore, subscription.terminated→deprovision. Verify HMAC against the nexacloud service webhook_secret.

4.3 Dual-run (shadow, ≥1 billing cycle)

NexaCloud keeps charging via its own Stripe. Odoo computes draft, uncharged invoices from imported subscriptions + pushed cpu_seconds. fusion.billing.reconciliation upserts one row per (service, deployment, period) with odoo_amount vs external_amount and a cent-level delta. Operators investigate every delta row until a full cycle is match within tolerance (default $0.01).

4.4 Gated flip (after ≥1 green cycle + explicit go)

  1. NexaCloud stops its own Stripe charging (disable the charge path in billing_service.py / scheduler billing_payment + invoice generation) and treats Odoo as SoR.
  2. Odoo subscriptions move from shadow → active; native subscription invoicing charges the shared Stripe account acct_1ShlA9IkwUB1dVox (saved cards carry over — no re-collection).
  3. Webhooks drive suspend/restore/deprovision. Past NexaCloud invoices remain archived (PDF/opening balance) — not re-issued.
  4. Rollback: re-enable NexaCloud local billing + set Odoo subs back to shadow (no data destroyed).

5. Out of scope (YAGNI for this increment)

  • NexaDesk and NexaMaps adapters (later increments) and the inbound-API endpoints only they need (/invoices family, /credit_notes, /catalog, /checkout_url, PUT /subscriptions plan-change/upgrade).
  • Lago changes or decommission (Lago stays paused; its remediation is tracked separately).
  • Customer-portal redesign — use native Odoo portal as-is.
  • Metering memory/disk/bandwidth (stay flat unless a NexaCloud plan already meters them).

6. Success criteria

  • A NexaCloud deployment is created as an Odoo subscription sale.order (is_subscription=True) via POST /subscriptions, resolving one res.partner through account.link.
  • cpu_seconds counters pushed to /usage aggregate (idempotent) into a draft invoice with quota → free, overage priced, HST applied — matching NexaCloud's own computed amount within $0.01.
  • A simulated invoice.payment_failed webhook reaches /billing/webhooks/central (valid HMAC) and triggers a NexaCloud suspend; invoice.payment_succeeded restores.
  • fusion.billing.reconciliation is match for every active deployment across ≥1 full cycle before any flip.
  • Re-sending the same usage counter (same idempotency_key) does not double-bill (constraint + upsert verified by test).
  • Post-flip: Odoo charges go-forward periods only; zero past-period re-issues.

7. Risks & open items

  • Re-billing regression (highest): the importer MUST set next_invoice_date go-forward and must not finalize/charge historical periods. Add an explicit test asserting no invoice is generated for any period earlier than import time. (Direct mitigation of the 2026-05-27 Lago incident.)
  • Odoo 19 correctness: read live reference files from the container (docker exec odoo-nexa-app cat …) for sale.order subscription flow, account.move, payment_stripe before coding internals — never from memory (per K:\Github\CLAUDE.md).
  • Idempotency: fusion.billing.usage unique (subscription, metric, idempotency_key) already enforces it; the NexaCloud key is nexacloud:cpu_seconds:<sub>:<period> — keep it stable across retries.
  • external_id namespacing: NexaCloud must send namespaced ids so it can never collide with NexaDesk/NexaMaps in the shared Odoo identity space.
  • Reconciliation source: confirm where NexaCloud's "actual amount" comes from (its invoices/usage_records) and that it's net of the same HST basis Odoo uses.
  • Flip switch safety: disabling NexaCloud's local Stripe must be a single, reversible config flag, and the billing_payment scheduler job must be guarded so it can't charge once Odoo is SoR.
  • Spec/branch target: Odoo-Modules is on feat/fusion-login-audit with -wt-portal/-wt-fm worktrees; confirm the branch for engine changes; NexaCloud changes land on its own branch (note: pushing Nexa-Cloud main auto-deploys to prod).

8. Test plan

  • Odoo unit tests (extend fusion_centralize_billing/tests/): catalog→charge mapping; usage aggregation + quota/overage; idempotent re-push; reconciliation match/delta; webhook HMAC sign/verify + backoff; importer go-forward next_invoice_date assertion.
  • NexaCloud tests: adapter customer/subscription calls; /billing/webhooks/central HMAC verify + suspend/restore/deprovision dispatch; reconciliation-amount push.
  • Dual-run acceptance: a full cycle of match reconciliation on real (or staged) deployments before the flip gate.