diff --git a/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md b/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md new file mode 100644 index 00000000..b73f3115 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md @@ -0,0 +1,1102 @@ +# 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 + +The module depends on Enterprise modules, so tests run on an instance that has them. Use the dev container (it bind-mounts `Odoo-Modules` at `/mnt/odoo-modules`): + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev \ + -i fusion_centralize_billing --test-enable \ + --test-tags /fusion_centralize_billing --stop-after-init +``` + +- First run uses `-i` (install); later runs use `-u` (upgrade). +- **Never** run `--test-enable -u` against production `nexamain`. If the dev instance lacks Enterprise addons, provision a throwaway test DB on `odoo-nexa` and run there. +- A task "passes" when the run ends with `0 failed, 0 error(s)` for the module's tags. + +## 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).