Files
Odoo-Modules/fusion_centralize_billing/models/usage.py
gsinghpal d770c0c3a9 fix(billing): resolve code-review findings (authz, cross-billing, validation, webhook integrity)
- C1/H4: rating cron only rates subs on the charge's own plan_id
- C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing)
- C2/C4: /usage authorizes each event (exists + is_subscription + linked customer)
- C3: API handlers validate input and return 4xx-shaped errors instead of raising;
       controller maps status=='error' to HTTP 400
- H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date)
- H2: _aggregate uses half-open window anchored on period_start
- H3: idempotency scoped to (subscription_id, metric_id, idempotency_key)
- H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id,
       caps backoff at 2**min(attempts,10)
- H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url
- M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume)
- L1: currency_id required on charge + reconciliation
- L2: charge price non-negative + unit_batch positive DB constraints

Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00

121 lines
4.9 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(subscription_id, metric_id, idempotency_key)",
"Usage idempotency key must be unique per subscription and metric.",
)
@api.model
def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None):
"""Upsert one aggregated usage row. Same idempotency key (scoped to the same
subscription + metric) 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([
('subscription_id', '=', subscription.id),
('metric_id', '=', metric.id),
('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 the in-progress subscriptions that are on the charge's own plan.
A charge only rates subscriptions whose ``plan_id`` matches the charge's
``plan_id`` — never every subscription against every charge (C1/H4). The
billing-period window is the subscription's real open period
``[last_invoice_date or start_date, next_invoice_date)`` (H1)."""
Charge = self.env['fusion.billing.charge'].search([('active', '=', True)])
SaleOrder = self.env['sale.order']
for charge in Charge:
if not charge.plan_id:
continue
subs = SaleOrder.search([
('is_subscription', '=', True),
('subscription_state', '=', '3_progress'),
('plan_id', '=', charge.plan_id.id),
])
for sub in subs:
if not sub.next_invoice_date:
continue
period_end = fields.Datetime.to_datetime(sub.next_invoice_date)
period_start = fields.Datetime.to_datetime(
sub.last_invoice_date or sub.start_date)
if not period_start:
continue
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 over the half-open window
``[period_start, period_end)``, anchored on each rollup's ``period_start``,
using the metric's aggregation function."""
rows = self.search([
('subscription_id', '=', subscription.id),
('metric_id', '=', metric.id),
('period_start', '>=', period_start),
('period_start', '<', 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)