Files
Odoo-Modules/docs/superpowers/plans/2026-06-02-nexacloud-cutover-01-odoo-cancel-endpoint.md

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_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 endpointthis 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/<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 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:

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_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. ✔