74 lines
2.9 KiB
Python
74 lines
2.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.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)
|