Local dev Odoo is Community (can't install the module). Add a guest-exec runner that syncs the module to the odoo-trial Enterprise sandbox (VM 316, db trial) and runs --test-enable there; pass = FCB_EXIT=0. Scaffold verified installing on Odoo 19.0 Enterprise (7 fusion_billing_* tables created). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
45 KiB
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 execshown 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,
Monetarywith acurrency_id. Quantities areFloat. - SQL constraints/indexes: declarative
models.Constraint/models.Indexonly (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 itsfrom . import test_*line totests/__init__.pyand include__init__.pyin that task's commit. This keeps the module importable at every task boundary (the__init__.pymust 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 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 asFAIL/ERROR/assert lines). - The scaffold is already installed on
trial(7fusion_billing_*tables verified). Each run re-syncs the latest local code and-uupgrades it. - Never run
--test-enableagainst productionnexamain(odoo-nexa). - Requires SSH access to
pve-worker1(in the ssh config). Subagents run the same script — they do NOT useodoo-modsdevfor 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):
from . import test_identity
- Step 2: Write the failing test
fusion_centralize_billing/tests/test_identity.py:
# -*- 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):
@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
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)
@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:
@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
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:
# -*- 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:
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
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:
# -*- 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:
@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
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)
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:
@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
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:
from . import test_api
- Step 2: Write the failing test
fusion_centralize_billing/tests/test_api.py:
# -*- 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):
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
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):
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)
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:
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
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):
# -*- 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:
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:
@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
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:
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):
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)
# In odoo-shell, generate a key for a service, then:
curl -s -X POST http://localhost:8069/api/billing/v1/customers \
-H "Authorization: Bearer <raw-key>" -H "Content-Type: application/json" \
-d '{"external_id":"smoke-1","name":"Smoke Test"}'
# Expect: {"status":"ok","partner_id":<id>,"external_id":"smoke-1"}
- Step 4: Commit
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:
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)
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:
# -*- 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 version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fc_rate_usage" model="ir.cron">
<field name="name">Fusion Billing: Rate usage before invoicing</field>
<field name="model_id" ref="model_fusion_billing_usage"/>
<field name="state">code</field>
<field name="code">model._cron_rate_open_periods()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active">True</field>
</record>
<record id="cron_fc_dispatch_webhooks" model="ir.cron">
<field name="name">Fusion Billing: Dispatch outbound webhooks</field>
<field name="model_id" ref="model_fusion_billing_webhook"/>
<field name="state">code</field>
<field name="code">model._cron_dispatch()</field>
<field name="interval_number">2</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
</odoo>
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):
@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
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_constraintsregressions
Run: grep -rn "_sql_constraints" fusion_centralize_billing/ || echo "clean"
Expected: clean (all constraints are models.Constraint).
- Step 3: Confirm no
sale.subscriptionmodel 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)
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
nexacloudDB (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.terminatedwebhooks → 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).