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>
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 DBnexamain); NexaCloud (LXC 102, app192.168.1.250, DB192.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)
- Sequence: NexaCloud first (per parent spec), then NexaDesk, then NexaMaps.
- Granularity: one Odoo subscription per NexaCloud deployment (mirrors
nexacloudsubscriptions.deployment_id; the existing usage-push andfusion.billing.reconciliationcode already key per deployment viax_fc_nexacloud_subscription_id). - 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.
- Go-forward billing only. The importer sets each subscription's
next_invoice_dateso 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 — seelago-doublecharge-incident-2026-06memory).
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)
- Charge catalog (the main gap — currently 0).
- NexaCloud plans/products →
product.template+sale.subscription.plan(monthly), each taggedplan_codeand aproduct.default_codeofNC-PLAN-<slug>(reconciliation already filters plan lines ondefault_code LIKE 'NC-PLAN-%'). cpu_secondsmetric (exists) → onefusion.billing.chargeper plan:included_quota= the plan's bundled CPU-seconds,price_per_unit/unit_batchfor overage derived fromusage_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.
- NexaCloud plans/products →
- Run the importer (
wizards/import_wizard.py): read thenexacloudDB → ensureres.partner+account.linkfor each active customer (7 exist; backfill any missing), and create one shadowsale.order(is_subscription=True) per active deployment, settingx_fc_nexacloud_subscription_id,x_fc_nexacloud_plan_id, theNC-PLAN-*line, andnext_invoice_date= the deployment's next real billing date (go-forward only). Subscriptions start in shadow (draft/not auto-charging). - Inbound API — add only what NexaCloud needs.
POST /customers,POST /subscriptions,POST /usage,GET /plansalready exist. Add subscription cancel (DELETE /subscriptions/:id→ terminate thesale.order) for NexaCloud's deprovision path. All other parent-spec §7 endpoints stay deferred to the NexaDesk increment. - Wire the control loop: set the
nexacloudfusion.billing.service.webhook_url→https://api.vps.nexasystems.ca/api/v1/billing/webhooks/central, and confirmcronschedules forusage._cron_rate_open_periodsandwebhook._cron_dispatchare enabled.
4.2 NexaCloud side (Nexa-Cloud repo)
- Configure + activate the adapter: set
odoo_billing_base_url=https://erp.nexasystems.ca/api/billing/v1,odoo_billing_api_key=<nexacloud service key>. Keepodoo_billing_enabledstaged so usage push + the webhook receiver activate for shadow without yet disabling local Stripe. - Identity + subscription sync: on deployment create / cancel, call Odoo
POST /customersandPOST /subscriptions/ cancel (usage push already exists inusage_metering.py). Send a stableexternal_id(NexaCloud user id) andsubscription_external_id(deployment/subscription id) — namespaced, to avoid the cross-appexternal_idcollision noted innexa-billing-architecture. - Reconciliation feed: push NexaCloud's actual charged amount per (deployment, period) so
reconciliation._reconcile_rowscan diff Odoo-computed vs NexaCloud-actual. (Source: NexaCloud's own invoices/usage_records.) - Activate the control-loop receiver:
routers/odoo_billing.py/billing/webhooks/central→services/odoo_billing_integration.pymapsinvoice.payment_failed→suspend (existingnetwork_isolation/throttle_checker/resource_manager),invoice.payment_succeeded/subscription.reactivated→restore,subscription.terminated→deprovision. Verify HMAC against thenexacloudservicewebhook_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)
- NexaCloud stops its own Stripe charging (disable the charge path in
billing_service.py/ schedulerbilling_payment+ invoice generation) and treats Odoo as SoR. - Odoo subscriptions move from shadow → active; native subscription invoicing charges the shared Stripe account
acct_1ShlA9IkwUB1dVox(saved cards carry over — no re-collection). - Webhooks drive suspend/restore/deprovision. Past NexaCloud invoices remain archived (PDF/opening balance) — not re-issued.
- 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 (
/invoicesfamily,/credit_notes,/catalog,/checkout_url,PUT /subscriptionsplan-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) viaPOST /subscriptions, resolving oneres.partnerthroughaccount.link. cpu_secondscounters pushed to/usageaggregate (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_failedwebhook reaches/billing/webhooks/central(valid HMAC) and triggers a NexaCloud suspend;invoice.payment_succeededrestores. fusion.billing.reconciliationismatchfor 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_datego-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 …) forsale.ordersubscription flow,account.move,payment_stripebefore coding internals — never from memory (perK:\Github\CLAUDE.md). - Idempotency:
fusion.billing.usageunique(subscription, metric, idempotency_key)already enforces it; the NexaCloud key isnexacloud: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_paymentscheduler job must be guarded so it can't charge once Odoo is SoR. - Spec/branch target:
Odoo-Modulesis onfeat/fusion-login-auditwith-wt-portal/-wt-fmworktrees; confirm the branch for engine changes; NexaCloud changes land on its own branch (note: pushingNexa-Cloudmainauto-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-forwardnext_invoice_dateassertion. - NexaCloud tests: adapter customer/subscription calls;
/billing/webhooks/centralHMAC verify + suspend/restore/deprovision dispatch; reconciliation-amount push. - Dual-run acceptance: a full cycle of
matchreconciliation on real (or staged) deployments before the flip gate.