Files
Odoo-Modules/fusion_centralize_billing/models/charge.py
gsinghpal 3e0b531110 fix(billing): charge rate precision — Float not Monetary, no premature cent-rounding
price_per_unit was a Monetary field, so a realistic sub-cent rate like
$0.0075/core-hour was rounded to $0.01 on write, corrupting the rate.
Make it Float(16,6). Also stop _compute_billable from rounding the
overage amount to 2 decimals mid-calc — that lost the half-cent on
sub-cent rates and would drift against the source app, which keeps
usage amounts at 4 decimals and only rounds at the invoice total.
Now rounds to 6 dp (float-noise only); cent-rounding defers to the
invoice line. Exposed while building the NexaCloud importer.
2026-05-27 13:34:37 -04:00

93 lines
3.9 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
import math
from odoo import api, fields, models
class FusionBillingCharge(models.Model):
"""Maps a plan + metric to quota + overage pricing.
This is where "5,000,000 included / $0.10 per 1k overage" (NexaMaps) or a
NexaCloud CPU-seconds quota lives. Keyed by the shared ``plan_code`` the app
references; Odoo owns the money, the app owns feature entitlements. See spec §5.1.
"""
_name = "fusion.billing.charge"
_description = "Fusion Billing — Metered Charge (quota + overage)"
_order = "plan_code, name"
name = fields.Char(required=True)
plan_code = fields.Char(
required=True, index=True,
help="Shared plan_code the source app references (matches a sale.subscription.plan).",
)
plan_id = fields.Many2one(
"sale.subscription.plan",
help="Optional link to the Odoo recurrence/plan for this charge.",
)
metric_id = fields.Many2one(
"fusion.billing.metric", required=True, ondelete="restrict",
)
product_id = fields.Many2one(
"product.product", help="Usage product invoiced for overage.",
)
included_quota = fields.Float(
default=0.0, help="Units included before overage applies, per period.",
)
price_per_unit = fields.Float(
digits=(16, 6),
help="Overage price per unit_batch. A Float (not Monetary) so sub-cent rates "
"like $0.0075/core-hour are stored exactly — Monetary rounds to the "
"currency's 2 decimals and would corrupt the rate. Final cent-rounding "
"happens at the invoice line/total, not in the per-charge math.",
)
unit_batch = fields.Float(
default=1.0, help="Batch size for overage pricing, e.g. 1000 = priced per 1k.",
)
charge_model = fields.Selection(
[
("standard", "Standard (per unit)"),
("package", "Package"),
],
default="standard", required=True,
)
currency_id = fields.Many2one(
"res.currency", required=True,
default=lambda self: self.env.company.currency_id,
)
active = fields.Boolean(default=True)
_price_non_negative = models.Constraint(
"CHECK (price_per_unit >= 0)", "Overage price per unit cannot be negative.",
)
_unit_batch_positive = models.Constraint(
"CHECK (unit_batch > 0)", "Unit batch must be greater than zero.",
)
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': price the overage in (rounded-up) `unit_batch` blocks.
- 'package': price whole packages over the RAW quantity (quota ignored for
package counting); a partial package rounds up.
The amount keeps the rate's precision (rounded to 6 dp only to clear float
noise) — it must NOT be rounded to cents here. Sub-cent rates (e.g.
$0.0075/core-hour) and fractional totals are preserved so they match the
source app's own sub-cent usage amounts; final cent-rounding happens once at
the invoice line / invoice total, exactly as the source app does.
"""
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), 6)
# standard: 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), 6)