From 451fc5eafdbb118552e5d135fc5135ec2bad15ba Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 08:38:48 -0400 Subject: [PATCH] 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) --- ...xacloud-cutover-01-odoo-cancel-endpoint.md | 270 ++++++++++++++++++ ...2-nexacloud-odoo-billing-cutover-design.md | 101 +++++++ 2 files changed, 371 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-nexacloud-cutover-01-odoo-cancel-endpoint.md create mode 100644 docs/superpowers/specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md diff --git a/docs/superpowers/plans/2026-06-02-nexacloud-cutover-01-odoo-cancel-endpoint.md b/docs/superpowers/plans/2026-06-02-nexacloud-cutover-01-odoo-cancel-endpoint.md new file mode 100644 index 00000000..20b3da45 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-nexacloud-cutover-01-odoo-cancel-endpoint.md @@ -0,0 +1,270 @@ +# NexaCloud→Odoo Cutover — Plan 01: Odoo subscription-cancel endpoint + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the one inbound endpoint NexaCloud's deprovision path needs — cancel (close) a subscription — to `fusion_centralize_billing`, with the same auth model the other endpoints already use. + +**Architecture:** New `fusion.billing.service._api_cancel_subscription(external_ref)` resolves the subscription via the existing `_fc_resolve_subscription`, enforces the same "partner must be linked to this service" authorization as `_api_record_usage`, and closes it with Odoo 19's native `set_close()` (→ `subscription_state='6_churn'`). A `DELETE /api/billing/v1/subscriptions/` route wraps it. + +**Tech Stack:** Odoo 19 Enterprise (`sale_subscription`), Python, Odoo `TransactionCase` tests. + +**Spec:** [`2026-06-02-nexacloud-odoo-billing-cutover-design.md`](../specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md) §4.1.3 + +--- + +## Increment plan sequence (this is Plan 01 of 6) + +Each is its own plan doc + its own working, testable deliverable. Order reflects dependencies: + +1. **Odoo: subscription-cancel endpoint** ← *this doc* (unblocked; no external decisions). +2. **Odoo: NexaCloud charge catalog** — products + `sale.subscription.plan` (`NC-PLAN-*`) + `fusion.billing.charge` (cpu_seconds quota/overage). **Blocked on confirming real NexaCloud plan pricing/quotas** (open review Q#1) before it can be written placeholder-free. +3. **Odoo: importer go-forward subscriptions** — extend `wizards/import_wizard.py` to create one shadow `sale.order` per active deployment with go-forward `next_invoice_date`; the safety test that asserts **no past-period invoice** is the centrepiece (guards against the 2026-05-27 Lago re-bill). +4. **NexaCloud: adapter activation** — config (`odoo_billing_base_url`/`api_key`/staged enable), customer + subscription create/cancel calls, reconciliation-amount push. +5. **NexaCloud: control-loop receiver** — activate `/billing/webhooks/central` HMAC verify → suspend/restore/deprovision via `network_isolation`/`throttle_checker`/`resource_manager`. +6. **Dual-run + gated flip** — operational runbook: shadow ≥1 cycle, reconcile to cent, then the reversible flip flag. + +--- + +## File structure (this plan) + +- Modify: `fusion_centralize_billing/models/service.py` — add `_api_cancel_subscription`. +- Modify: `fusion_centralize_billing/controllers/api.py` — add `DELETE /subscriptions/`. +- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py` — service-method + authorization tests. +- Modify: `fusion_centralize_billing/tests/__init__.py` — import the new test module. + +Run tests (from `K:\Github\CLAUDE.md` workflow, adapted to odoo-nexa): +``` +ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init" +``` + +--- + +### Task 1: `_api_cancel_subscription` service method + +**Files:** +- Modify: `fusion_centralize_billing/models/service.py` (add method after `_api_create_subscription`, ~line 250) +- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py` +- Modify: `fusion_centralize_billing/tests/__init__.py` + +- [ ] **Step 0: Verify the Odoo 19 close method (do NOT code from memory — per `K:\Github\CLAUDE.md`)** + +Run: +``` +ssh odoo-nexa "docker exec odoo-nexa-app grep -nE 'def set_close|def set_open|6_churn' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head" +``` +Expected: a `def set_close(self...)` exists and sets `subscription_state='6_churn'`. If the method name differs in this build, use the actual name in Step 3 and the assertion in Step 1. + +- [ ] **Step 1: Write the failing test** + +Create `fusion_centralize_billing/tests/test_subscription_cancel.py`: +```python +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestSubscriptionCancel(TransactionCase): + + def setUp(self): + super().setUp() + self.plan = self.env['sale.subscription.plan'].sudo().create( + {'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'}) + self.product = self.env['product.product'].sudo().create( + {'name': 'NexaCloud Plan', 'type': 'service', + 'recurring_invoice': True, 'list_price': 49.0}) + self.svc_a = self.env['fusion.billing.service'].sudo().create( + {'name': 'NexaCloud', 'code': 'nexacloud'}) + self.svc_b = self.env['fusion.billing.service'].sudo().create( + {'name': 'Other', 'code': 'other'}) + self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'}) + res = self.svc_a._api_create_subscription({ + 'external_customer_id': 'user-1', 'plan_id': self.plan.id, + 'lines': [{'product_id': self.product.id, 'quantity': 1}]}) + self.sub = self.env['sale.order'].browse(res['subscription_id']) + + def test_cancel_closes_subscription(self): + self.assertEqual(self.sub.subscription_state, '3_progress') + res = self.svc_a._api_cancel_subscription(str(self.sub.id)) + self.assertEqual(res['status'], 'ok') + self.assertEqual(self.sub.subscription_state, '6_churn') + + def test_cancel_is_idempotent(self): + self.svc_a._api_cancel_subscription(str(self.sub.id)) + res = self.svc_a._api_cancel_subscription(str(self.sub.id)) + self.assertEqual(res['status'], 'ok') + self.assertEqual(self.sub.subscription_state, '6_churn') + + def test_cancel_unknown_subscription_rejected(self): + res = self.svc_a._api_cancel_subscription('999999999') + self.assertEqual(res['status'], 'error') + self.assertEqual(res['error'], 'unknown subscription') + + def test_cancel_cross_service_rejected(self): + # svc_b is not linked to the customer that owns self.sub + res = self.svc_b._api_cancel_subscription(str(self.sub.id)) + self.assertEqual(res['status'], 'error') + self.assertEqual(res['error'], 'unknown subscription') + self.assertEqual(self.sub.subscription_state, '3_progress') + + def test_cancel_missing_id_rejected(self): + res = self.svc_a._api_cancel_subscription('') + self.assertEqual(res['status'], 'error') +``` + +Append to `fusion_centralize_billing/tests/__init__.py`: +```python +from . import test_subscription_cancel +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +``` +ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init" +``` +Expected: FAIL — `AttributeError: 'fusion.billing.service' object has no attribute '_api_cancel_subscription'`. + +- [ ] **Step 3: Implement the method** + +In `fusion_centralize_billing/models/service.py`, add immediately after `_api_create_subscription`: +```python + def _api_cancel_subscription(self, external_ref): + """Cancel (close) the subscription identified by ``external_ref``. + + Authorization mirrors ``_api_record_usage``: the resolved sale.order must + exist, be a subscription, and belong to a customer THIS service is linked + to. Idempotent — closing an already-churned subscription returns ok. + Validation (C3): an empty ref returns a 4xx-shaped error, never raises. + """ + self.ensure_one() + if external_ref in (None, ''): + return {'status': 'error', 'error': 'subscription id required'} + sub = self._fc_resolve_subscription(external_ref) + linked_partners = self.account_link_ids.mapped('partner_id') + if not sub.exists() or not sub.is_subscription \ + or sub.partner_id not in linked_partners: + return {'status': 'error', 'error': 'unknown subscription'} + if sub.subscription_state != '6_churn': + sub.set_close() + return {'status': 'ok', 'subscription_id': sub.id, + 'subscription_state': sub.subscription_state} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: +``` +ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init" +``` +Expected: PASS — 5 tests, 0 failures. (If `set_close()` was a different name in Step 0, use that name here and re-run.) + +- [ ] **Step 5: Commit** + +```bash +git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_subscription_cancel.py fusion_centralize_billing/tests/__init__.py +git commit -m "feat(billing): add _api_cancel_subscription (close sub, service-scoped authz)" +``` + +--- + +### Task 2: `DELETE /subscriptions/` route + +**Files:** +- Modify: `fusion_centralize_billing/controllers/api.py` (add route after `post_subscription`, ~line 95) +- Modify: `fusion_centralize_billing/tests/test_subscription_cancel.py` (add an HTTP-layer test) + +- [ ] **Step 1: Write the failing test (HTTP layer)** + +Append to `tests/test_subscription_cancel.py` a class that exercises the route through Odoo's test client. Add the import at the top of the file: +```python +from odoo.tests import HttpCase +``` +Then append: +```python +@tagged('post_install', '-at_install') +class TestSubscriptionCancelHttp(HttpCase): + + def setUp(self): + super().setUp() + self.plan = self.env['sale.subscription.plan'].sudo().create( + {'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'}) + self.product = self.env['product.product'].sudo().create( + {'name': 'NexaCloud Plan', 'type': 'service', + 'recurring_invoice': True, 'list_price': 49.0}) + self.svc = self.env['fusion.billing.service'].sudo().create( + {'name': 'NexaCloud', 'code': 'nexacloud'}) + self.raw_key = self.svc.action_generate_api_key() + self.svc._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'}) + res = self.svc._api_create_subscription({ + 'external_customer_id': 'user-1', 'plan_id': self.plan.id, + 'lines': [{'product_id': self.product.id, 'quantity': 1}]}) + self.sub_id = res['subscription_id'] + self.env.cr.commit() + self.addCleanup(self._cleanup) + + def _cleanup(self): + self.env['sale.order'].browse(self.sub_id).sudo().unlink() + + def test_delete_requires_auth(self): + resp = self.url_open( + "/api/billing/v1/subscriptions/%s" % self.sub_id, + method='DELETE') + self.assertEqual(resp.status_code, 401) + + def test_delete_cancels_with_valid_key(self): + resp = self.url_open( + "/api/billing/v1/subscriptions/%s" % self.sub_id, + method='DELETE', + headers={'Authorization': 'Bearer %s' % self.raw_key}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()['subscription_state'], '6_churn') +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: +``` +ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init" +``` +Expected: FAIL — the DELETE route returns 404 (route not registered) so the assertions fail. + +- [ ] **Step 3: Implement the route** + +In `fusion_centralize_billing/controllers/api.py`, add after `post_subscription`: +```python + @http.route(f"{API_BASE}/subscriptions/", type="http", auth="none", + methods=["DELETE"], csrf=False) + def delete_subscription(self, sub_ref, **kw): + service = self._authenticate() + if not service: + return self._json({"error": "unauthorized"}, status=401) + result = service._api_cancel_subscription(sub_ref) + if result.get("status") == "error": + status = 404 if result.get("error") == "unknown subscription" else 400 + return self._json(result, status=status) + return self._json(result) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: +``` +ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init" +``` +Expected: PASS — 2 tests, 0 failures. + +- [ ] **Step 5: Commit** + +```bash +git add fusion_centralize_billing/controllers/api.py fusion_centralize_billing/tests/test_subscription_cancel.py +git commit -m "feat(billing): DELETE /api/billing/v1/subscriptions/ cancel route" +``` + +--- + +## Self-review + +- **Spec coverage:** §4.1.3 "add subscription cancel (`DELETE /subscriptions/:id`)" → Tasks 1+2. ✔ +- **Placeholder scan:** none — all code is concrete; Step 0 verifies the one Odoo-internal name (`set_close`) against the live container instead of assuming. +- **Type consistency:** `_api_cancel_subscription` returns the same `{'status','subscription_id','subscription_state'}` shape as `_api_create_subscription`; error shape matches `_api_record_usage` (`{'status':'error','error':...}`); resolver reused (`_fc_resolve_subscription`) so cross-service rejection is identical to `/usage`. ✔ +- **Authorization parity:** cancel uses the exact `not sub.exists() or not sub.is_subscription or sub.partner_id not in linked_partners` guard as `_api_record_usage`. ✔ diff --git a/docs/superpowers/specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md b/docs/superpowers/specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md new file mode 100644 index 00000000..5c60eac2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md @@ -0,0 +1,101 @@ +# 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`](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-` (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-`. + - 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_url` → `https://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) + +4. **Configure + activate the adapter:** set `odoo_billing_base_url=https://erp.nexasystems.ca/api/billing/v1`, `odoo_billing_api_key=`. Keep `odoo_billing_enabled` staged so usage push + the webhook receiver activate for shadow without yet disabling local Stripe. +5. **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`. +6. **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`.) +7. **Activate the control-loop receiver:** `routers/odoo_billing.py` `/billing/webhooks/central` → `services/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::` — 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.