- 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>
75 lines
3.2 KiB
Python
75 lines
3.2 KiB
Python
# -*- 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')
|
|
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)
|
|
|
|
# ── 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)
|