fix(billing): resolve code-review findings (authz, cross-billing, validation, webhook integrity)

- C1/H4: rating cron only rates subs on the charge's own plan_id
- C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing)
- C2/C4: /usage authorizes each event (exists + is_subscription + linked customer)
- C3: API handlers validate input and return 4xx-shaped errors instead of raising;
       controller maps status=='error' to HTTP 400
- H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date)
- H2: _aggregate uses half-open window anchored on period_start
- H3: idempotency scoped to (subscription_id, metric_id, idempotency_key)
- H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id,
       caps backoff at 2**min(attempts,10)
- H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url
- M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume)
- L1: currency_id required on charge + reconciliation
- L2: charge price non-negative + unit_batch positive DB constraints

Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-27 03:27:34 -04:00
parent a5db0fe71e
commit d770c0c3a9
11 changed files with 442 additions and 32 deletions

View File

@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
from psycopg2 import IntegrityError
from odoo.tests.common import TransactionCase, tagged
from odoo.tools.misc import mute_logger
@tagged('post_install', '-at_install')
@@ -43,3 +46,29 @@ class TestChargeMath(TransactionCase):
# 2,001 units -> 3 packages -> $6.00
_, amount = charge._compute_billable(2_001.0)
self.assertAlmostEqual(amount, 6.0, places=2)
# ── item 10 (M7): only the two implemented charge models remain ──
def test_charge_model_selection_limited(self):
field = self.env['fusion.billing.charge']._fields['charge_model']
keys = [k for k, _label in field.selection]
self.assertEqual(sorted(keys), ['package', 'standard'])
self.assertNotIn('graduated', keys)
self.assertNotIn('volume', keys)
# ── item 11 (L1): currency is required and defaults to company currency ──
def test_currency_required_and_defaulted(self):
field = self.env['fusion.billing.charge']._fields['currency_id']
self.assertTrue(field.required)
charge = self._charge()
self.assertEqual(charge.currency_id, self.env.company.currency_id)
# ── item 12 (L2): non-negative price + positive batch DB constraints ──
def test_negative_price_rejected(self):
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self._charge(price_per_unit=-1.0)
def test_zero_batch_rejected(self):
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self._charge(unit_batch=0.0)