# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 from odoo import api, fields, models class FusionBillingUsage(models.Model): """Aggregated usage rollup for a (subscription, metric, period). Aggregate-push model: apps send periodic counters (not raw events). The ``idempotency_key`` makes re-sent counters safe โ€” they never double-count. A pre-invoice cron sums these and feeds billable quantity onto the subscription. NOTE (Odoo 19, verified): the subscription is a ``sale.order`` with ``is_subscription=True`` โ€” there is no ``sale.subscription`` model. See spec ยง5.2. """ _name = "fusion.billing.usage" _description = "Fusion Billing โ€” Aggregated Usage (period rollup)" _order = "period_start desc" subscription_id = fields.Many2one( "sale.order", required=True, ondelete="cascade", index=True, string="Subscription", domain=[("is_subscription", "=", True)], ) metric_id = fields.Many2one( "fusion.billing.metric", required=True, ondelete="restrict", index=True, ) period_start = fields.Datetime(required=True) period_end = fields.Datetime(required=True) quantity = fields.Float(default=0.0) source = fields.Char(default="push") idempotency_key = fields.Char( index=True, help="Dedupe key so re-sent counters never double-count.", ) _idempotency_uniq = models.Constraint( "unique(idempotency_key)", "Usage idempotency key must be unique.", ) @api.model def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None): """Upsert one aggregated usage row. Same idempotency key updates in place (no double-count).""" metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1) if not metric: raise ValueError("Unknown metric code: %s" % metric_code) vals = { 'subscription_id': subscription.id, 'metric_id': metric.id, 'period_start': period_start, 'period_end': period_end, 'quantity': quantity, 'idempotency_key': idem, } if idem: existing = self.search([('idempotency_key', '=', idem)], limit=1) if existing: existing.write({'quantity': quantity}) return existing return self.create(vals) @api.model def _cron_rate_open_periods(self): """Hourly cron: for every active charge, aggregate usage and upsert overage lines on all in-progress subscriptions whose next invoice date is set.""" Charge = self.env['fusion.billing.charge'].search([('active', '=', True)]) SaleOrder = self.env['sale.order'] for charge in Charge: subs = SaleOrder.search([ ('is_subscription', '=', True), ('subscription_state', '=', '3_progress'), ('plan_id.name', '!=', False), ]) for sub in subs: if not sub.next_invoice_date: continue period_end = fields.Datetime.to_datetime(sub.next_invoice_date) period_start = period_end.replace(day=1) sub._fc_rate_usage(charge, period_start, period_end) @api.model def _aggregate(self, subscription, metric, period_start, period_end): """Aggregate stored usage for a subscription+metric within [period_start, period_end) using the metric's aggregation function.""" rows = self.search([ ('subscription_id', '=', subscription.id), ('metric_id', '=', metric.id), ('period_start', '>=', period_start), ('period_end', '<=', period_end), ]) qtys = rows.mapped('quantity') if not qtys: return 0.0 agg = metric.aggregation if agg == 'sum': return sum(qtys) if agg == 'max': return max(qtys) if agg == 'last': return rows.sorted('period_start')[-1].quantity if agg == 'unique_count': return float(len(set(qtys))) return sum(qtys)