diff --git a/fusion_centralize_billing/models/charge.py b/fusion_centralize_billing/models/charge.py index a194f38d..6f614800 100644 --- a/fusion_centralize_billing/models/charge.py +++ b/fusion_centralize_billing/models/charge.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 -from odoo import fields, models +import math + +from odoo import api, fields, models class FusionBillingCharge(models.Model): @@ -51,3 +53,21 @@ class FusionBillingCharge(models.Model): "res.currency", default=lambda self: self.env.company.currency_id, ) active = fields.Boolean(default=True) + + 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) diff --git a/fusion_centralize_billing/tests/__init__.py b/fusion_centralize_billing/tests/__init__.py index 7399d626..23d9aa9c 100644 --- a/fusion_centralize_billing/tests/__init__.py +++ b/fusion_centralize_billing/tests/__init__.py @@ -1 +1,2 @@ from . import test_identity +from . import test_charge diff --git a/fusion_centralize_billing/tests/test_charge.py b/fusion_centralize_billing/tests/test_charge.py new file mode 100644 index 00000000..ae33f141 --- /dev/null +++ b/fusion_centralize_billing/tests/test_charge.py @@ -0,0 +1,45 @@ +# -*- 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)