# fusion_centralize_billing — Core Engine Implementation Plan > **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:** Build the product-agnostic core of `fusion_centralize_billing` — service registry + API-key auth, identity resolution, the metered-charge math, idempotent usage ingestion + aggregation, the Lago-shaped inbound API, and the outbound webhook engine — on Odoo 19 Enterprise. **Architecture:** Thin HTTP controllers (`type="http"`, bearer auth) delegate to **model methods** that hold all logic (so everything is unit-testable under `TransactionCase` without an HTTP server). Usage arrives as pre-aggregated counters, is stored idempotently, and a cron rates it against `fusion.billing.charge` (quota + overage). Native `sale_subscription`/`account_accountant`/`payment_stripe` do invoicing/tax/payment. Lifecycle events are queued in `fusion.billing.webhook` and dispatched by a cron with HMAC signing + exponential backoff. **Tech Stack:** Odoo 19 Enterprise (Python 3.12), `sale_subscription`, `account_accountant`, `payment_stripe`. Tests: `odoo.tests.common.TransactionCase`. **Scope:** Core engine only. The **NexaCloud adapter + dual-run reconciliation** is a separate follow-on plan (it depends on this core). The scaffold already exists and compiles (`fusion_centralize_billing/`: 7 `fusion.billing.*` models, API controller shell, security, README). **Spec:** `docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md` --- ## Conventions for every task - **Never code Odoo internals from memory** (repo CLAUDE.md rule #1). Where a step says "read reference", run the exact `docker exec` shown and confirm field/method names before implementing. - **Models, not services:** business logic lives in model methods named `_api_*` (request handlers) or `_*` (helpers). Controllers only parse → call → JSON. - **Money:** CAD, `Monetary` with a `currency_id`. Quantities are `Float`. - **SQL constraints/indexes:** declarative `models.Constraint` / `models.Index` only (rule #9). Never `_sql_constraints`. - **New fields on native models:** `x_fc_*` prefix. - **Registering tests:** when a task creates a new `tests/test_*.py`, append its `from . import test_*` line to `tests/__init__.py` and include `__init__.py` in that task's commit. This keeps the module importable at every task boundary (the `__init__.py` must never import a file that doesn't exist yet). ## Test environment **Verified 2026-05-27.** The local dev Odoo (`odoo-modsdev`) is **Community** and CANNOT install this module (no `sale_subscription`/`account_accountant`). Tests run on the **odoo-trial** Enterprise sandbox (Proxmox VM 316, Odoo 19.0 Enterprise, db `trial`), reached via Proxmox guest-exec (VM 316 has no direct SSH — only `qm guest exec` through `pve-worker1`). A committed runner handles sync + test: ```bash bash scripts/fcb_test_on_trial.sh ``` It tars the module, ships it into odoo-trial's `/opt/odoo/custom-addons/`, then runs `odoo -d trial -u fusion_centralize_billing --no-http --workers 0 --test-enable --test-tags /fusion_centralize_billing --stop-after-init`. - **Pass condition:** the output contains `FCB_EXIT=0` (Odoo exits non-zero on any test failure; failures also show as `FAIL`/`ERROR`/assert lines). - The scaffold is already installed on `trial` (7 `fusion_billing_*` tables verified). Each run re-syncs the latest local code and `-u` upgrades it. - **Never** run `--test-enable` against production `nexamain` (odoo-nexa). - Requires SSH access to `pve-worker1` (in the ssh config). Subagents run the same script — they do NOT use `odoo-modsdev` for this module. ## File structure (this plan) ``` fusion_centralize_billing/ models/ service.py # +_match_api_key, +_api_* request handlers account_link.py # +_resolve_or_create_partner metric.py # (definition only) charge.py # +_compute_billable (quota + overage math — the billing core) usage.py # +_record_usage (idempotent), +_aggregate, +_cron_rate_open_periods webhook.py # +_enqueue, +_sign, +_cron_dispatch reconciliation.py # (untouched here — NexaCloud-adapter plan) controllers/ api.py # implement endpoints; each delegates to a model _api_* method data/ ir_cron.xml # NEW: usage-rating cron + webhook-dispatch cron tests/ __init__.py # NEW test_charge.py # NEW test_usage.py # NEW test_identity.py # NEW test_api.py # NEW test_webhook.py # NEW __manifest__.py # add data/ir_cron.xml; add 'tests' is implicit ``` --- ## Task 1: Test scaffolding + service API-key matching **Files:** - Create: `fusion_centralize_billing/tests/__init__.py` - Create: `fusion_centralize_billing/tests/test_identity.py` - Modify: `fusion_centralize_billing/models/service.py` - [ ] **Step 1: Create the tests package** `fusion_centralize_billing/tests/__init__.py` (start with ONLY the module that exists now; later test tasks append their own line per the "Registering tests" convention): ```python from . import test_identity ``` - [ ] **Step 2: Write the failing test** `fusion_centralize_billing/tests/test_identity.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestServiceApiKey(TransactionCase): def setUp(self): super().setUp() self.Service = self.env['fusion.billing.service'].sudo() self.service = self.Service.create({'name': 'NexaCloud', 'code': 'nexacloud'}) def test_generate_and_match_api_key(self): raw = self.service.action_generate_api_key() self.assertTrue(raw and len(raw) >= 20) self.assertTrue(self.service.api_key_hash) self.assertNotEqual(raw, self.service.api_key_hash) # only the hash is stored matched = self.Service._match_api_key(raw) self.assertEqual(matched, self.service) def test_match_api_key_rejects_unknown_and_inactive(self): raw = self.service.action_generate_api_key() self.assertFalse(self.Service._match_api_key('nope-not-a-key')) self.service.active = False self.assertFalse(self.Service._match_api_key(raw)) ``` - [ ] **Step 3: Run it, expect failure** Run: `docker exec odoo-modsdev-app odoo -d fusion-dev -i fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing --stop-after-init` Expected: FAIL — `_match_api_key` does not exist. - [ ] **Step 4: Implement `_match_api_key`** In `models/service.py`, add the method to `FusionBillingService` (the `hashlib`/`secrets` imports already exist): ```python @api.model def _match_api_key(self, raw_key): """Return the active service whose stored hash matches raw_key, else empty recordset.""" if not raw_key: return self.browse() key_hash = hashlib.sha256(raw_key.encode()).hexdigest() return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1) ``` - [ ] **Step 5: Run it, expect pass** Run: same command as Step 3. Expected: PASS for `TestServiceApiKey`. - [ ] **Step 6: Commit** ```bash git add fusion_centralize_billing/tests/__init__.py fusion_centralize_billing/tests/test_identity.py fusion_centralize_billing/models/service.py git commit -m "feat(billing): service API-key generation + matching" ``` --- ## Task 2: Identity resolution (external account → res.partner) **Files:** - Modify: `fusion_centralize_billing/models/account_link.py` - Modify: `fusion_centralize_billing/tests/test_identity.py` - [ ] **Step 1: Write the failing test** (append to `test_identity.py`) ```python @tagged('post_install', '-at_install') class TestIdentityResolution(TransactionCase): def setUp(self): super().setUp() self.service = self.env['fusion.billing.service'].sudo().create( {'name': 'NexaDesk', 'code': 'nexadesk'}) self.Link = self.env['fusion.billing.account.link'].sudo() def test_creates_partner_first_time(self): link = self.Link._resolve_or_create_partner( self.service, external_id='tenant-1', name='Acme Inc', email='ar@acme.test') self.assertTrue(link.partner_id) self.assertEqual(link.partner_id.name, 'Acme Inc') self.assertEqual(link.external_id, 'tenant-1') def test_idempotent_same_external_id(self): a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test') b = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme Renamed', 'ar@acme.test') self.assertEqual(a, b) # same link row self.assertEqual(a.partner_id, b.partner_id) # same partner def test_reuses_partner_by_email_across_services(self): other = self.env['fusion.billing.service'].sudo().create({'name': 'Maps', 'code': 'nexamaps'}) a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test') b = self.Link._resolve_or_create_partner(other, 'client-9', 'Acme', 'ar@acme.test') self.assertEqual(a.partner_id, b.partner_id) # one unified customer self.assertNotEqual(a, b) # but distinct link rows ``` - [ ] **Step 2: Run it, expect failure** Run: `... --test-tags /fusion_centralize_billing ...` Expected: FAIL — `_resolve_or_create_partner` not defined. - [ ] **Step 3: Implement the resolver** In `models/account_link.py`, add `api` to the import (`from odoo import api, fields, models`) and add: ```python @api.model def _resolve_or_create_partner(self, service, external_id, name=None, email=None, extra=None): """Return the link for (service, external_id), creating partner+link if needed. Unifies customers: if a link for this external_id exists, reuse it; else if a partner with the same email already exists (possibly from another service), link to it; else create a new partner. """ existing = self.search( [('service_id', '=', service.id), ('external_id', '=', external_id)], limit=1) if existing: return existing partner = self.env['res.partner'] if email: partner = partner.search([('email', '=', email)], limit=1) if not partner: partner = partner.create({'name': name or external_id, 'email': email, **(extra or {})}) return self.create({ 'service_id': service.id, 'external_id': external_id, 'external_email': email, 'partner_id': partner.id, }) ``` - [ ] **Step 4: Run it, expect pass** Run: same command. Expected: PASS for `TestIdentityResolution`. - [ ] **Step 5: Commit** ```bash git add fusion_centralize_billing/models/account_link.py fusion_centralize_billing/tests/test_identity.py git commit -m "feat(billing): identity resolution external account -> partner" ``` --- ## Task 3: Metered-charge math (quota + overage) This is the billing core — pure, deterministic, heavily tested. **Files:** - Modify: `fusion_centralize_billing/models/charge.py` - Create: `fusion_centralize_billing/tests/test_charge.py` - [ ] **Step 1: Write the failing test** `fusion_centralize_billing/tests/test_charge.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestChargeMath(TransactionCase): def setUp(self): super().setUp() self.metric = self.env['fusion.billing.metric'].sudo().create( {'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'}) def _charge(self, **kw): vals = { 'name': 'Maps overage', 'plan_code': 'maps-business', 'metric_id': self.metric.id, 'charge_model': 'standard', 'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0, } vals.update(kw) return self.env['fusion.billing.charge'].sudo().create(vals) def test_under_quota_is_free(self): charge = self._charge() overage_units, amount = charge._compute_billable(4_000_000.0) self.assertEqual(overage_units, 0.0) self.assertEqual(amount, 0.0) def test_standard_overage_per_1k(self): charge = self._charge() # 6,000,000 used - 5,000,000 quota = 1,000,000 overage = 1000 batches * $0.10 overage_units, amount = charge._compute_billable(6_000_000.0) self.assertEqual(overage_units, 1_000_000.0) self.assertAlmostEqual(amount, 100.0, places=2) def test_partial_batch_rounds_up(self): charge = self._charge(included_quota=0.0) # 1,500 units, batch 1000 -> 2 batches -> $0.20 _, amount = charge._compute_billable(1_500.0) self.assertAlmostEqual(amount, 0.20, places=2) def test_package_model_charges_whole_packages(self): charge = self._charge(charge_model='package', included_quota=0.0, unit_batch=1000.0, price_per_unit=2.0) # 2,001 units -> 3 packages -> $6.00 _, amount = charge._compute_billable(2_001.0) self.assertAlmostEqual(amount, 6.0, places=2) ``` - [ ] **Step 2: Run it, expect failure** Expected: FAIL — `_compute_billable` not defined. - [ ] **Step 3: Implement the math** In `models/charge.py` add `import math` and `from odoo import api, fields, models`, then: ```python def _compute_billable(self, total_quantity): """Return (overage_units, amount) for total period usage under this charge. - overage_units = usage above included_quota (never negative) - 'standard'/'package'/'volume': priced per `unit_batch` block, partial block rounds up. (graduated tiers are out of scope for the core; treated as 'standard'.) """ self.ensure_one() overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0)) batch = self.unit_batch or 1.0 if self.charge_model == 'package': # whole packages over the RAW quantity (quota ignored for package counting) blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0 return overage, round(blocks * (self.price_per_unit or 0.0), 2) # standard / volume / graduated-fallback: price the overage in (rounded-up) batches blocks = math.ceil(overage / batch) if overage > 0 else 0 return overage, round(blocks * (self.price_per_unit or 0.0), 2) ``` - [ ] **Step 4: Run it, expect pass** Expected: PASS for `TestChargeMath` (4 tests). - [ ] **Step 5: Commit** ```bash git add fusion_centralize_billing/models/charge.py fusion_centralize_billing/tests/test_charge.py fusion_centralize_billing/tests/__init__.py git commit -m "feat(billing): metered charge math (quota + overage)" ``` --- ## Task 4: Idempotent usage ingestion **Files:** - Modify: `fusion_centralize_billing/models/usage.py` - Create: `fusion_centralize_billing/tests/test_usage.py` - [ ] **Step 1: Write the failing test** `fusion_centralize_billing/tests/test_usage.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestUsageIngestion(TransactionCase): def setUp(self): super().setUp() self.metric = self.env['fusion.billing.metric'].sudo().create( {'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'}) self.plan = self.env['sale.subscription.plan'].sudo().create( {'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'}) self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'}) self.sub = self.env['sale.order'].sudo().create({ 'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id, }) self.Usage = self.env['fusion.billing.usage'].sudo() def test_record_usage_creates_row(self): u = self.Usage._record_usage( self.sub, 'cpu_seconds', 120.0, '2026-05-01 00:00:00', '2026-06-01 00:00:00', idem='nexacloud:cpu:sub1:2026-05-01') self.assertEqual(u.quantity, 120.0) self.assertEqual(u.metric_id, self.metric) def test_idempotent_key_updates_not_duplicates(self): k = 'nexacloud:cpu:sub1:2026-05-01' self.Usage._record_usage(self.sub, 'cpu_seconds', 100.0, '2026-05-01', '2026-06-01', idem=k) self.Usage._record_usage(self.sub, 'cpu_seconds', 175.0, '2026-05-01', '2026-06-01', idem=k) rows = self.Usage.search([('idempotency_key', '=', k)]) self.assertEqual(len(rows), 1) # no duplicate self.assertEqual(rows.quantity, 175.0) # last value wins for the same key ``` - [ ] **Step 2: Run it, expect failure** Expected: FAIL — `_record_usage` not defined. - [ ] **Step 3: Implement ingestion** In `models/usage.py` add `from odoo import api, fields, models` (keep existing) and: ```python @api.model def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None): """Upsert one aggregated usage row. Same idempotency key updates in place (no double-count).""" metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1) if not metric: raise ValueError("Unknown metric code: %s" % metric_code) vals = { 'subscription_id': subscription.id, 'metric_id': metric.id, 'period_start': period_start, 'period_end': period_end, 'quantity': quantity, 'idempotency_key': idem, } if idem: existing = self.search([('idempotency_key', '=', idem)], limit=1) if existing: existing.write({'quantity': quantity}) return existing return self.create(vals) ``` - [ ] **Step 4: Run it, expect pass** Expected: PASS for `TestUsageIngestion`. - [ ] **Step 5: Commit** ```bash git add fusion_centralize_billing/models/usage.py fusion_centralize_billing/tests/test_usage.py fusion_centralize_billing/tests/__init__.py git commit -m "feat(billing): idempotent usage ingestion" ``` --- ## Task 5: Usage aggregation per period **Files:** - Modify: `fusion_centralize_billing/models/usage.py` - Modify: `fusion_centralize_billing/tests/test_usage.py` - [ ] **Step 1: Write the failing test** (append to `test_usage.py`) ```python def test_aggregate_sum(self): for i, q in enumerate([10.0, 20.0, 30.0]): self.Usage._record_usage(self.sub, 'cpu_seconds', q, '2026-05-01', '2026-06-01', idem='cpu-%d' % i) total = self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01') self.assertEqual(total, 60.0) def test_aggregate_max(self): self.metric.aggregation = 'max' for i, q in enumerate([10.0, 55.0, 30.0]): self.Usage._record_usage(self.sub, 'cpu_seconds', q, '2026-05-01', '2026-06-01', idem='m-%d' % i) self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 55.0) def test_aggregate_excludes_other_periods(self): self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr') self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may') self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0) ``` - [ ] **Step 2: Run it, expect failure** Expected: FAIL — `_aggregate` not defined. - [ ] **Step 3: Implement aggregation** In `models/usage.py`: ```python @api.model def _aggregate(self, subscription, metric, period_start, period_end): """Aggregate stored usage for a subscription+metric within [period_start, period_end) using the metric's aggregation function.""" rows = self.search([ ('subscription_id', '=', subscription.id), ('metric_id', '=', metric.id), ('period_start', '>=', period_start), ('period_end', '<=', period_end), ]) qtys = rows.mapped('quantity') if not qtys: return 0.0 agg = metric.aggregation if agg == 'sum': return sum(qtys) if agg == 'max': return max(qtys) if agg == 'last': return rows.sorted('period_start')[-1].quantity if agg == 'unique_count': return float(len(set(qtys))) return sum(qtys) ``` - [ ] **Step 4: Run it, expect pass** Expected: PASS for the three new tests. - [ ] **Step 5: Commit** ```bash git add fusion_centralize_billing/models/usage.py fusion_centralize_billing/tests/test_usage.py git commit -m "feat(billing): period usage aggregation by metric function" ``` --- ## Task 6: Inbound API handlers (customers + usage + catalog) as model methods Logic in `_api_*` methods (testable); controllers wired in Task 9. **Files:** - Modify: `fusion_centralize_billing/models/service.py` - Create: `fusion_centralize_billing/tests/test_api.py` - Create empty `fusion_centralize_billing/tests/test_webhook.py` (filled in Task 8) so the package import in Task 1 resolves. - [ ] **Step 1: Register the API test module** Append to `fusion_centralize_billing/tests/__init__.py`: ```python from . import test_api ``` - [ ] **Step 2: Write the failing test** `fusion_centralize_billing/tests/test_api.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestApiHandlers(TransactionCase): def setUp(self): super().setUp() self.service = self.env['fusion.billing.service'].sudo().create( {'name': 'NexaMaps', 'code': 'nexamaps'}) self.env['fusion.billing.metric'].sudo().create( {'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'}) self.plan = self.env['sale.subscription.plan'].sudo().create( {'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'}) def test_api_upsert_customer(self): res = self.service._api_upsert_customer( {'external_id': 'client-9', 'name': 'Globex', 'email': 'billing@globex.test'}) self.assertEqual(res['status'], 'ok') link = self.env['fusion.billing.account.link'].search( [('service_id', '=', self.service.id), ('external_id', '=', 'client-9')]) self.assertEqual(link.partner_id.name, 'Globex') def test_api_record_usage_batch(self): self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'}) partner = self.env['fusion.billing.account.link'].search( [('external_id', '=', 'client-9')]).partner_id sub = self.env['sale.order'].sudo().create( {'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id}) res = self.service._api_record_usage({'events': [{ 'subscription_external_id': str(sub.id), 'metric_code': 'api_calls', 'quantity': 1234.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01', 'idempotency_key': 'maps:client-9:2026-05-01', }]}) self.assertEqual(res['accepted'], 1) usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)]) self.assertEqual(usage.quantity, 1234.0) def test_api_catalog_lists_active_charges(self): self.env['fusion.billing.charge'].sudo().create({ 'name': 'Maps overage', 'plan_code': 'maps-business', 'metric_id': self.env['fusion.billing.metric'].search([('code', '=', 'api_calls')]).id, 'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0}) cat = self.service._api_catalog() codes = [c['plan_code'] for c in cat['charges']] self.assertIn('maps-business', codes) ``` - [ ] **Step 3: Run it, expect failure** Expected: FAIL — `_api_upsert_customer` not defined. - [ ] **Step 4: Implement the handlers** In `models/service.py` (add `from odoo import api, fields, models` if not already importing `api`): ```python def _api_upsert_customer(self, payload): self.ensure_one() ext = payload.get('external_id') if not ext: return {'status': 'error', 'error': 'external_id required'} link = self.env['fusion.billing.account.link']._resolve_or_create_partner( self, ext, name=payload.get('name'), email=payload.get('email')) return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext} def _api_record_usage(self, payload): self.ensure_one() events = payload.get('events') or [] Usage = self.env['fusion.billing.usage'] accepted = 0 for ev in events: sub = self.env['sale.order'].browse(int(ev['subscription_external_id'])) Usage._record_usage( sub, ev['metric_code'], float(ev['quantity']), ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key')) accepted += 1 return {'status': 'ok', 'accepted': accepted} def _api_catalog(self): self.ensure_one() charges = self.env['fusion.billing.charge'].search([('active', '=', True)]) return {'status': 'ok', 'charges': [{ 'plan_code': c.plan_code, 'metric': c.metric_id.code, 'included_quota': c.included_quota, 'price_per_unit': c.price_per_unit, 'unit_batch': c.unit_batch, 'charge_model': c.charge_model, } for c in charges]} ``` - [ ] **Step 5: Run it, expect pass** Expected: PASS for `TestApiHandlers` (3 tests). - [ ] **Step 6: Commit** ```bash git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_api.py fusion_centralize_billing/tests/__init__.py git commit -m "feat(billing): inbound API handlers (customer/usage/catalog)" ``` --- ## Task 7: Subscription creation handler **Read reference first** (do not code subscription creation from memory): ```bash docker exec odoo-nexa-app bash -lc "grep -nE 'def action_confirm|is_subscription|def _portal_ensure_token|plan_id' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head" ``` Confirm: a subscription is a `sale.order` with `is_subscription=True`, `plan_id`, and is activated via `action_confirm()` (sets `subscription_state='3_progress'`, computes `next_invoice_date`). **Files:** - Modify: `fusion_centralize_billing/models/service.py` - Modify: `fusion_centralize_billing/tests/test_api.py` - [ ] **Step 1: Write the failing test** (append to `test_api.py`) ```python def test_api_create_subscription(self): self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'}) product = self.env['product.product'].sudo().create( {'name': 'Maps Business', 'type': 'service', 'list_price': 249.0}) res = self.service._api_create_subscription({ 'external_customer_id': 'client-9', 'plan_id': self.plan.id, 'lines': [{'product_id': product.id, 'quantity': 1}], }) self.assertEqual(res['status'], 'ok') sub = self.env['sale.order'].browse(res['subscription_id']) self.assertTrue(sub.is_subscription) self.assertEqual(sub.plan_id, self.plan) self.assertEqual(sub.subscription_state, '3_progress') ``` - [ ] **Step 2: Run it, expect failure** Expected: FAIL — `_api_create_subscription` not defined. - [ ] **Step 3: Implement subscription creation** In `models/service.py`: ```python def _api_create_subscription(self, payload): self.ensure_one() link = self.env['fusion.billing.account.link'].search([ ('service_id', '=', self.id), ('external_id', '=', payload.get('external_customer_id')), ], limit=1) if not link: return {'status': 'error', 'error': 'unknown customer'} order_lines = [(0, 0, { 'product_id': line['product_id'], 'product_uom_qty': line.get('quantity', 1), }) for line in payload.get('lines', [])] sub = self.env['sale.order'].create({ 'partner_id': link.partner_id.id, 'is_subscription': True, 'plan_id': payload['plan_id'], 'order_line': order_lines, }) sub.action_confirm() return {'status': 'ok', 'subscription_id': sub.id, 'subscription_state': sub.subscription_state} ``` - [ ] **Step 4: Run it, expect pass** Expected: PASS. If `action_confirm()` raises about a missing pricelist/journal on the fresh `fusion-dev` DB, set the partner's `property_product_pricelist` in the test setUp (add `self.env.ref('product.list0')` if present) — do NOT weaken the assertion. - [ ] **Step 5: Commit** ```bash git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_api.py git commit -m "feat(billing): subscription creation handler (sale.order is_subscription)" ``` --- ## Task 8: Outbound webhook engine (enqueue + HMAC + dispatch with retry) **Files:** - Modify: `fusion_centralize_billing/models/webhook.py` - Modify: `fusion_centralize_billing/tests/test_webhook.py` - [ ] **Step 1: Write the failing test** Create `fusion_centralize_billing/tests/test_webhook.py` (and append `from . import test_webhook` to `tests/__init__.py`): ```python # -*- coding: utf-8 -*- import hashlib import hmac import json from unittest.mock import patch from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestWebhookEngine(TransactionCase): def setUp(self): super().setUp() self.service = self.env['fusion.billing.service'].sudo().create({ 'name': 'NexaCloud', 'code': 'nexacloud', 'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook', 'webhook_secret': 'whsec_test', }) self.Webhook = self.env['fusion.billing.webhook'].sudo() def test_enqueue_signs_payload(self): wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-1'}) self.assertEqual(wh.state, 'pending') body = json.dumps({'invoice': 'INV-1'}, sort_keys=True, separators=(',', ':')) expected = hmac.new(b'whsec_test', body.encode(), hashlib.sha256).hexdigest() self.assertEqual(wh.signature, expected) def test_dispatch_marks_sent_on_2xx(self): wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-2'}) class _Resp: status_code = 200 text = 'ok' with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', return_value=_Resp()) as mock_post: self.Webhook._cron_dispatch() self.assertTrue(mock_post.called) self.assertEqual(wh.state, 'sent') def test_dispatch_retries_then_deadletters(self): wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-3'}) wh.write({'attempts': 7}) # already past max class _Resp: status_code = 500 text = 'err' with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', return_value=_Resp()): self.Webhook._cron_dispatch() self.assertEqual(wh.state, 'dead') ``` - [ ] **Step 2: Run it, expect failure** Expected: FAIL — `_enqueue`/`_cron_dispatch` not defined. - [ ] **Step 3: Implement the webhook engine** In `models/webhook.py` add imports and methods: ```python import hashlib import hmac import json import logging from datetime import timedelta import requests from odoo import api, fields, models _logger = logging.getLogger(__name__) MAX_ATTEMPTS = 8 ``` Add to `FusionBillingWebhook`: ```python @api.model def _sign(self, secret, body): return hmac.new((secret or '').encode(), body.encode(), hashlib.sha256).hexdigest() @api.model def _enqueue(self, service, event_type, payload): body = json.dumps(payload, sort_keys=True, separators=(',', ':')) return self.create({ 'service_id': service.id, 'event_type': event_type, 'payload': payload, 'signature': self._sign(service.webhook_secret, body), 'state': 'pending', 'next_retry_at': fields.Datetime.now(), }) @api.model def _cron_dispatch(self): now = fields.Datetime.now() due = self.search([ ('state', 'in', ('pending', 'failed')), ('next_retry_at', '<=', now), ], limit=100) for wh in due: body = json.dumps(wh.payload, sort_keys=True, separators=(',', ':')) try: resp = requests.post( wh.service_id.webhook_url, data=body, headers={'Content-Type': 'application/json', 'X-Fusion-Signature': wh.signature, 'X-Fusion-Event': wh.event_type}, timeout=10, ) ok = 200 <= resp.status_code < 300 except Exception as e: # noqa: BLE001 - record and retry ok = False wh.last_error = str(e)[:500] wh.attempts += 1 if ok: wh.state = 'sent' elif wh.attempts >= MAX_ATTEMPTS: wh.state = 'dead' else: wh.state = 'failed' wh.next_retry_at = now + timedelta(minutes=2 ** wh.attempts) ``` - [ ] **Step 4: Run it, expect pass** Expected: PASS for `TestWebhookEngine` (3 tests). - [ ] **Step 5: Commit** ```bash git add fusion_centralize_billing/models/webhook.py fusion_centralize_billing/tests/test_webhook.py fusion_centralize_billing/tests/__init__.py git commit -m "feat(billing): outbound webhook engine (HMAC + retry/backoff)" ``` --- ## Task 9: Wire the HTTP controllers to the handlers **Files:** - Modify: `fusion_centralize_billing/controllers/api.py` - [ ] **Step 1: Implement `/customers`, `/usage`, `/plans` (delegating to handlers)** Replace the `post_usage` stub and add the customer/plans routes. Each authenticates, parses JSON, calls the model handler, returns its dict: ```python def _read_json(self): try: raw = request.httprequest.get_data(as_text=True) or "{}" return json.loads(raw) except Exception: return None @http.route(f"{API_BASE}/customers", type="http", auth="none", methods=["POST"], csrf=False) def post_customer(self, **kw): service = self._authenticate() if not service: return self._json({"error": "unauthorized"}, status=401) payload = self._read_json() if payload is None: return self._json({"error": "invalid json"}, status=400) return self._json(service._api_upsert_customer(payload)) @http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False) def post_usage(self, **kw): service = self._authenticate() if not service: return self._json({"error": "unauthorized"}, status=401) payload = self._read_json() if payload is None: return self._json({"error": "invalid json"}, status=400) return self._json(service._api_record_usage(payload), status=202) @http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False) def get_plans(self, **kw): service = self._authenticate() if not service: return self._json({"error": "unauthorized"}, status=401) return self._json(service._api_catalog()) @http.route(f"{API_BASE}/subscriptions", type="http", auth="none", methods=["POST"], csrf=False) def post_subscription(self, **kw): service = self._authenticate() if not service: return self._json({"error": "unauthorized"}, status=401) payload = self._read_json() if payload is None: return self._json({"error": "invalid json"}, status=400) return self._json(service._api_create_subscription(payload)) ``` Also refactor the scaffold's `_authenticate` to reuse the Task 1 helper (DRY — drop the duplicated hash/search): ```python def _authenticate(self): auth = request.httprequest.headers.get("Authorization", "") if not auth.startswith("Bearer "): return None return request.env["fusion.billing.service"].sudo()._match_api_key(auth[7:].strip()) or None ``` Add `import json` at the top of `controllers/api.py` (alongside `hashlib`). - [ ] **Step 2: Verify module upgrades cleanly** Run: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_centralize_billing --stop-after-init` Expected: ends with no traceback; `Modules loaded.` - [ ] **Step 3: Smoke-test the live endpoint** (optional, dev only) ```bash # In odoo-shell, generate a key for a service, then: curl -s -X POST http://localhost:8069/api/billing/v1/customers \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"external_id":"smoke-1","name":"Smoke Test"}' # Expect: {"status":"ok","partner_id":,"external_id":"smoke-1"} ``` - [ ] **Step 4: Commit** ```bash git add fusion_centralize_billing/controllers/api.py git commit -m "feat(billing): wire HTTP controllers to API handlers" ``` --- ## Task 10: Usage-rating cron (rate open periods → invoice line) **Read reference first:** ```bash docker exec odoo-nexa-app bash -lc "grep -nE 'def _create_recurring_invoice|def _create_invoices|def _get_invoiceable_lines' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head" ``` Confirm how a subscription's upcoming invoice is generated, and whether to (a) add a `sale.order.line` for the overage product, or (b) post an `account.move` line. The core uses approach (a): add/update an overage line on the subscription so native invoicing bills it. **Files:** - Modify: `fusion_centralize_billing/models/usage.py` - Modify: `fusion_centralize_billing/tests/test_usage.py` - [ ] **Step 1: Write the failing test** (append to `test_usage.py`) ```python def test_rate_open_period_creates_overage_line(self): product = self.env['product.product'].sudo().create( {'name': 'API overage', 'type': 'service', 'list_price': 0.0}) charge = self.env['fusion.billing.charge'].sudo().create({ 'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id, 'product_id': product.id, 'included_quota': 100.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'}) self.Usage._record_usage(self.sub, 'cpu_seconds', 1100.0, '2026-05-01', '2026-06-01', idem='r1') amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01') # 1100 - 100 = 1000 overage = 1 batch * $0.10 = $0.10 self.assertAlmostEqual(amount, 0.10, places=2) line = self.sub.order_line.filtered(lambda l: l.product_id == product) self.assertTrue(line) ``` - [ ] **Step 2: Run it, expect failure** Expected: FAIL — `_fc_rate_usage` not defined on `sale.order`. - [ ] **Step 3: Implement the rating method on `sale.order`** Create `fusion_centralize_billing/models/sale_order.py`: ```python # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 from odoo import api, fields, models class SaleOrder(models.Model): _inherit = "sale.order" def _fc_rate_usage(self, charge, period_start, period_end): """Aggregate this subscription's usage for `charge`'s metric in the period, compute the overage amount, and upsert a matching overage order line. Returns the amount.""" self.ensure_one() Usage = self.env['fusion.billing.usage'] total = Usage._aggregate(self, charge.metric_id, period_start, period_end) _overage, amount = charge._compute_billable(total) if charge.product_id: line = self.order_line.filtered(lambda l: l.product_id == charge.product_id) vals = {'product_uom_qty': 1, 'price_unit': amount} if line: line.write(vals) else: self.env['sale.order.line'].create( {'order_id': self.id, 'product_id': charge.product_id.id, **vals}) return amount ``` Register it in `models/__init__.py` (add `from . import sale_order`). - [ ] **Step 4: Run it, expect pass** Expected: PASS. (If `sale.order.line.create` requires a pricelist/tax setup the fresh DB lacks, set them in setUp — don't weaken the assertion.) - [ ] **Step 5: Add the rating cron** Create `fusion_centralize_billing/data/ir_cron.xml`: ```xml Fusion Billing: Rate usage before invoicing code model._cron_rate_open_periods() 1 hours True Fusion Billing: Dispatch outbound webhooks code model._cron_dispatch() 2 minutes True ``` Add a minimal driver `_cron_rate_open_periods` to `models/usage.py` (iterates active subscriptions with charges for the current period and calls `_fc_rate_usage`): ```python @api.model def _cron_rate_open_periods(self): Charge = self.env['fusion.billing.charge'].search([('active', '=', True)]) SaleOrder = self.env['sale.order'] for charge in Charge: subs = SaleOrder.search([ ('is_subscription', '=', True), ('subscription_state', '=', '3_progress'), ('plan_id.name', '!=', False), ]) for sub in subs: if not sub.next_invoice_date: continue period_end = fields.Datetime.to_datetime(sub.next_invoice_date) period_start = period_end.replace(day=1) sub._fc_rate_usage(charge, period_start, period_end) ``` Register `data/ir_cron.xml` in `__manifest__.py` `data` list (after the security line). - [ ] **Step 6: Run module upgrade + tests** Run: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing --stop-after-init` Expected: crons load; all tests PASS. - [ ] **Step 7: Commit** ```bash git add fusion_centralize_billing/models/sale_order.py fusion_centralize_billing/models/__init__.py fusion_centralize_billing/models/usage.py fusion_centralize_billing/data/ir_cron.xml fusion_centralize_billing/__manifest__.py fusion_centralize_billing/tests/test_usage.py git commit -m "feat(billing): usage-rating + webhook-dispatch crons" ``` --- ## Task 11: Full module test run + lint pass **Files:** none (verification task) - [ ] **Step 1: Full test run** Run: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing --stop-after-init 2>&1 | tail -30` Expected: `0 failed, 0 error(s)`. Fix any failure at its source (do not weaken assertions). - [ ] **Step 2: Confirm no `_sql_constraints` regressions** Run: `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo "clean"` Expected: `clean` (all constraints are `models.Constraint`). - [ ] **Step 3: Confirm no `sale.subscription` model references** Run: `grep -rn "sale\.subscription[^.]" fusion_centralize_billing/ || echo "clean"` Expected: `clean` (only `sale.subscription.plan` is valid; bare `sale.subscription` is not a model). - [ ] **Step 4: Commit (if any fixes)** ```bash git add -A fusion_centralize_billing/ git commit -m "test(billing): full core engine test pass" ``` --- ## Done = core engine complete When all tasks pass: a source app can authenticate with a bearer key, upsert a unified customer, create a subscription `sale.order`, push idempotent usage counters, have them rated against quota+overage onto an invoice line, and receive HMAC-signed lifecycle webhooks with retry. Native Odoo handles invoicing/tax/Stripe. ## Next plan (not this one): NexaCloud adapter + dual-run - Map `nexacloud` DB (users/products/plans/deployments) → partners/links/subscriptions/charges (importer script). - CPU-seconds metric + throttle-removal one-off invoice; `usage_metering.py` → `POST /usage`. - `invoice.payment_failed`/`subscription.terminated` webhooks → NexaCloud suspend/deprovision. - `fusion.billing.reconciliation`: shadow-mode Odoo-vs-NexaCloud per period; flip when within tolerance. - Invoice list/get/PDF/void/retry + credit-note endpoints (deferred from core; add when the adapter needs them).