- SaleOrder._fc_rate_usage: aggregates usage, computes overage via charge._compute_billable, upserts sale.order.line for the overage product - FusionBillingUsage._cron_rate_open_periods: hourly cron iterates active charges × in-progress subscriptions, calls _fc_rate_usage - data/ir_cron.xml: two crons — rate usage (hourly), dispatch webhooks (2 min) - __manifest__.py: registers data/ir_cron.xml in data list - test_usage.py: test_rate_open_period_creates_overage_line (TDD, FCB_EXIT=0) Reference: _create_recurring_invoice / _get_invoiceable_lines confirmed in Enterprise sale_subscription/models/sale_order.py — overage line goes onto sale.order so native invoicing picks it up via _get_invoiceable_lines.
104 lines
4.1 KiB
Python
104 lines
4.1 KiB
Python
# -*- 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)
|