299 lines
15 KiB
Markdown
299 lines
15 KiB
Markdown
# 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`](../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/<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`:
|
|
```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/<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:
|
|
```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/<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**
|
|
|
|
```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/<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`. ✔
|