Files
Odoo-Modules/docs/superpowers/plans/2026-06-02-nexacloud-cutover-01-odoo-cancel-endpoint.md
gsinghpal 451fc5eafd 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) <noreply@anthropic.com>
2026-06-02 08:38:48 -04:00

13 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


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