feat(billing): metered charge math (quota + overage)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from . import test_identity
|
||||
from . import test_charge
|
||||
|
||||
45
fusion_centralize_billing/tests/test_charge.py
Normal file
45
fusion_centralize_billing/tests/test_charge.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user