# 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 --- ## ⚠ Test harness (supersedes any `-d nexamain` command below) **NEVER run `-u` / `--test-enable` against the live `nexamain` DB.** Tests run in an **isolated throwaway container** against a dedicated DB, reading a **separate** addons copy so the live module is never touched: ``` # 1) edit files on branch feat/nexacloud-odoo-billing-cutover, then sync the changed # module files to the staging addons copy on odoo-nexa: # /opt/odoo/custom-addons-staging/fusion_centralize_billing/... # 2) run (ssh odoo-nexa): docker run --rm --network odoo_odoo-network \ -v /opt/odoo/custom-addons-staging:/mnt/extra-addons:ro \ -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons:ro \ -v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf:ro \ -v /opt/odoo/staging-data:/var/lib/odoo \ odoo-nexa:19 -c /etc/odoo/odoo.conf -d fcb_test \ --db_host=db --db_user=odoo \ --addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/enterprise-addons \ --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel \ -u fusion_centralize_billing --stop-after-init --no-http ``` - `fcb_test` is a **fresh** install DB (not a prod clone). `nexamain_staging` is a prod clone kept for later integration/importer plans. - **Scope each step's run to the relevant test class** (`:TestSubscriptionCancel`, `:TestSubscriptionCancelHttp`). The wider suite is **not hermetic yet** (see Plan 00) — `test_invoice_ledger` needs a configured Canadian CoA/active CAD/HST; `test_usage`/`test_webhook` collide with cloned prod data. Don't gate this plan on those. - The per-step `Run:` blocks below that mention `-d nexamain` are **illustrative only — use this harness instead.** > **Prerequisite — Plan 00 (make the suite hermetic):** before green-baseline TDD, fix fixtures so the whole suite passes on `fcb_test`: `setUp` should get-or-create the `nexacloud`/`cpu_seconds` records (idempotent), and a test-setup helper must ensure an active CAD currency + a Canadian CoA + a 13% HST sale tax. Tracked as its own plan; recommended before Plan 01 execution. --- ## 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`. ✔