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