# -*- 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.Monetary(help="Overage price per unit_batch.") 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. """ 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: 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)