feat(billing): usage-rating + webhook-dispatch crons
- 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.
This commit is contained in:
@@ -47,6 +47,7 @@ reference files from the container before implementing subscription/account inte
|
|||||||
],
|
],
|
||||||
"data": [
|
"data": [
|
||||||
"security/ir.model.access.csv",
|
"security/ir.model.access.csv",
|
||||||
|
"data/ir_cron.xml",
|
||||||
],
|
],
|
||||||
"installable": True,
|
"installable": True,
|
||||||
"application": False,
|
"application": False,
|
||||||
|
|||||||
22
fusion_centralize_billing/data/ir_cron.xml
Normal file
22
fusion_centralize_billing/data/ir_cron.xml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
<record id="cron_fc_rate_usage" model="ir.cron">
|
||||||
|
<field name="name">Fusion Billing: Rate usage before invoicing</field>
|
||||||
|
<field name="model_id" ref="model_fusion_billing_usage"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_rate_open_periods()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">hours</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="cron_fc_dispatch_webhooks" model="ir.cron">
|
||||||
|
<field name="name">Fusion Billing: Dispatch outbound webhooks</field>
|
||||||
|
<field name="model_id" ref="model_fusion_billing_webhook"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_dispatch()</field>
|
||||||
|
<field name="interval_number">2</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -5,3 +5,4 @@ from . import charge
|
|||||||
from . import usage
|
from . import usage
|
||||||
from . import webhook
|
from . import webhook
|
||||||
from . import reconciliation
|
from . import reconciliation
|
||||||
|
from . import sale_order
|
||||||
|
|||||||
26
fusion_centralize_billing/models/sale_order.py
Normal file
26
fusion_centralize_billing/models/sale_order.py
Normal file
@@ -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
|
||||||
@@ -59,6 +59,25 @@ class FusionBillingUsage(models.Model):
|
|||||||
return existing
|
return existing
|
||||||
return self.create(vals)
|
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
|
@api.model
|
||||||
def _aggregate(self, subscription, metric, period_start, period_end):
|
def _aggregate(self, subscription, metric, period_start, period_end):
|
||||||
"""Aggregate stored usage for a subscription+metric within [period_start, period_end)
|
"""Aggregate stored usage for a subscription+metric within [period_start, period_end)
|
||||||
|
|||||||
@@ -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', 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.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)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user