# -*- 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)"), ("graduated", "Graduated"), ("package", "Package"), ("volume", "Volume"), ], default="standard", required=True, ) currency_id = fields.Many2one( "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)