Files
Odoo-Modules/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
gsinghpal 032b10752e test(billing): odoo-trial Enterprise test runner + plan test-env fix
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>
2026-05-27 08:40:51 -04:00

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 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 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):

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_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)
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.pyPOST /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).