15 KiB
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/<ref> route wraps it.
Tech Stack: Odoo 19 Enterprise (sale_subscription), Python, Odoo TransactionCase tests.
Spec: 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_testis a fresh install DB (not a prod clone).nexamain_stagingis 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_ledgerneeds a configured Canadian CoA/active CAD/HST;test_usage/test_webhookcollide with cloned prod data. Don't gate this plan on those. - The per-step
Run:blocks below that mention-d nexamainare 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:setUpshould get-or-create thenexacloud/cpu_secondsrecords (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:
- Odoo: subscription-cancel endpoint ← this doc (unblocked; no external decisions).
- 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. - Odoo: importer go-forward subscriptions — extend
wizards/import_wizard.pyto create one shadowsale.orderper active deployment with go-forwardnext_invoice_date; the safety test that asserts no past-period invoice is the centrepiece (guards against the 2026-05-27 Lago re-bill). - NexaCloud: adapter activation — config (
odoo_billing_base_url/api_key/staged enable), customer + subscription create/cancel calls, reconciliation-amount push. - NexaCloud: control-loop receiver — activate
/billing/webhooks/centralHMAC verify → suspend/restore/deprovision vianetwork_isolation/throttle_checker/resource_manager. - 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— addDELETE /subscriptions/<ref>. - 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:
# -*- 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:
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:
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
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/<ref> route
Files:
-
Modify:
fusion_centralize_billing/controllers/api.py(add route afterpost_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:
from odoo.tests import HttpCase
Then append:
@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:
@http.route(f"{API_BASE}/subscriptions/<sub_ref>", 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
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/<ref> 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_subscriptionreturns 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_partnersguard as_api_record_usage. ✔