From a5db0fe71e53fe403525db73b570a3807c54ce90 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 03:08:45 -0400 Subject: [PATCH] feat(billing): usage-rating + webhook-dispatch crons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- fusion_centralize_billing/__manifest__.py | 1 + fusion_centralize_billing/data/ir_cron.xml | 22 ++++++++++++++++ fusion_centralize_billing/models/__init__.py | 1 + .../models/sale_order.py | 26 +++++++++++++++++++ fusion_centralize_billing/models/usage.py | 19 ++++++++++++++ fusion_centralize_billing/tests/test_usage.py | 15 +++++++++++ 6 files changed, 84 insertions(+) create mode 100644 fusion_centralize_billing/data/ir_cron.xml create mode 100644 fusion_centralize_billing/models/sale_order.py diff --git a/fusion_centralize_billing/__manifest__.py b/fusion_centralize_billing/__manifest__.py index 6662ea80..a55c6d54 100644 --- a/fusion_centralize_billing/__manifest__.py +++ b/fusion_centralize_billing/__manifest__.py @@ -47,6 +47,7 @@ reference files from the container before implementing subscription/account inte ], "data": [ "security/ir.model.access.csv", + "data/ir_cron.xml", ], "installable": True, "application": False, diff --git a/fusion_centralize_billing/data/ir_cron.xml b/fusion_centralize_billing/data/ir_cron.xml new file mode 100644 index 00000000..97511c84 --- /dev/null +++ b/fusion_centralize_billing/data/ir_cron.xml @@ -0,0 +1,22 @@ + + + + Fusion Billing: Rate usage before invoicing + + code + model._cron_rate_open_periods() + 1 + hours + True + + + + Fusion Billing: Dispatch outbound webhooks + + code + model._cron_dispatch() + 2 + minutes + True + + diff --git a/fusion_centralize_billing/models/__init__.py b/fusion_centralize_billing/models/__init__.py index de3d5dfd..15c4adda 100644 --- a/fusion_centralize_billing/models/__init__.py +++ b/fusion_centralize_billing/models/__init__.py @@ -5,3 +5,4 @@ from . import charge from . import usage from . import webhook from . import reconciliation +from . import sale_order diff --git a/fusion_centralize_billing/models/sale_order.py b/fusion_centralize_billing/models/sale_order.py new file mode 100644 index 00000000..339b4652 --- /dev/null +++ b/fusion_centralize_billing/models/sale_order.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def _fc_rate_usage(self, charge, period_start, period_end): + """Aggregate this subscription's usage for `charge`'s metric in the period, + compute the overage amount, and upsert a matching overage order line. + Returns the amount.""" + self.ensure_one() + Usage = self.env['fusion.billing.usage'] + total = Usage._aggregate(self, charge.metric_id, period_start, period_end) + _overage, amount = charge._compute_billable(total) + if charge.product_id: + line = self.order_line.filtered(lambda l: l.product_id == charge.product_id) + vals = {'product_uom_qty': 1, 'price_unit': amount} + if line: + line.write(vals) + else: + self.env['sale.order.line'].create( + {'order_id': self.id, 'product_id': charge.product_id.id, **vals}) + return amount diff --git a/fusion_centralize_billing/models/usage.py b/fusion_centralize_billing/models/usage.py index b3ce930b..cdb2deef 100644 --- a/fusion_centralize_billing/models/usage.py +++ b/fusion_centralize_billing/models/usage.py @@ -59,6 +59,25 @@ class FusionBillingUsage(models.Model): 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) diff --git a/fusion_centralize_billing/tests/test_usage.py b/fusion_centralize_billing/tests/test_usage.py index de8cf2de..db4ecc1b 100644 --- a/fusion_centralize_billing/tests/test_usage.py +++ b/fusion_centralize_billing/tests/test_usage.py @@ -50,3 +50,18 @@ class TestUsageIngestion(TransactionCase): self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr') self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may') self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0) + + def test_rate_open_period_creates_overage_line(self): + product = self.env['product.product'].sudo().create( + {'name': 'API overage', 'type': 'service', 'list_price': 0.0}) + charge = self.env['fusion.billing.charge'].sudo().create({ + 'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id, + 'product_id': product.id, 'included_quota': 100.0, + 'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'}) + self.Usage._record_usage(self.sub, 'cpu_seconds', 1100.0, + '2026-05-01', '2026-06-01', idem='r1') + amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01') + # 1100 - 100 = 1000 overage = 1 batch * $0.10 = $0.10 + self.assertAlmostEqual(amount, 0.10, places=2) + line = self.sub.order_line.filtered(lambda l: l.product_id == product) + self.assertTrue(line)