- 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>
172 lines
9.5 KiB
Python
172 lines
9.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class TestRatingCron(TransactionCase):
|
|
"""The rating cron must only rate a subscription against charges on its OWN plan
|
|
(items 1/C1/H4) and over the subscription's real open billing period (item 5/H1)."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
|
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
|
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
|
|
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
|
self.plan_b = self.env['sale.subscription.plan'].sudo().create(
|
|
{'name': 'Plan B', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
|
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
|
|
self.recurring_product = self.env['product.product'].sudo().create(
|
|
{'name': 'Plan seat', 'type': 'service', 'recurring_invoice': True,
|
|
'list_price': 10.0})
|
|
self.overage_product = self.env['product.product'].sudo().create(
|
|
{'name': 'CPU overage', 'type': 'service', 'list_price': 0.0})
|
|
self.Usage = self.env['fusion.billing.usage'].sudo()
|
|
|
|
def _confirmed_sub(self, plan):
|
|
sub = self.env['sale.order'].sudo().create({
|
|
'partner_id': self.partner.id, 'plan_id': plan.id,
|
|
'order_line': [(0, 0, {'product_id': self.recurring_product.id,
|
|
'product_uom_qty': 1})],
|
|
})
|
|
sub.action_confirm()
|
|
# widen the computed billing window so usage in May is in-period
|
|
sub.write({'start_date': '2026-05-01', 'next_invoice_date': '2026-06-01'})
|
|
return sub
|
|
|
|
def test_cron_rates_only_matching_plan(self):
|
|
sub_a = self._confirmed_sub(self.plan_a)
|
|
sub_b = self._confirmed_sub(self.plan_b)
|
|
self.assertEqual(sub_a.subscription_state, '3_progress')
|
|
self.assertEqual(sub_b.subscription_state, '3_progress')
|
|
# one charge, linked to Plan A only
|
|
charge = self.env['fusion.billing.charge'].sudo().create({
|
|
'name': 'CPU overage', 'plan_code': 'plan-a', 'plan_id': self.plan_a.id,
|
|
'metric_id': self.metric.id, 'product_id': self.overage_product.id,
|
|
'included_quota': 100.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
|
|
'charge_model': 'standard'})
|
|
# usage recorded on BOTH subs, in the open period
|
|
self.Usage._record_usage(sub_a, 'cpu_seconds', 1100.0,
|
|
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='a1')
|
|
self.Usage._record_usage(sub_b, 'cpu_seconds', 1100.0,
|
|
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='b1')
|
|
|
|
self.Usage._cron_rate_open_periods()
|
|
|
|
# Plan A sub IS rated (window captured the usage → overage line present)
|
|
line_a = sub_a.order_line.filtered(lambda l: l.product_id == self.overage_product)
|
|
self.assertTrue(line_a, "Plan A subscription should be rated by the Plan A charge")
|
|
self.assertAlmostEqual(line_a.price_unit, 0.10, places=2)
|
|
# Plan B sub is NOT rated by the Plan A charge
|
|
line_b = sub_b.order_line.filtered(lambda l: l.product_id == self.overage_product)
|
|
self.assertFalse(line_b, "Plan B subscription must NOT be rated by the Plan A charge")
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class TestUsageIngestion(TransactionCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
|
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
|
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
|
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
|
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
|
|
self.sub = self.env['sale.order'].sudo().create({
|
|
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
|
|
})
|
|
self.Usage = self.env['fusion.billing.usage'].sudo()
|
|
|
|
def test_record_usage_creates_row(self):
|
|
u = self.Usage._record_usage(
|
|
self.sub, 'cpu_seconds', 120.0,
|
|
'2026-05-01 00:00:00', '2026-06-01 00:00:00', idem='nexacloud:cpu:sub1:2026-05-01')
|
|
self.assertEqual(u.quantity, 120.0)
|
|
self.assertEqual(u.metric_id, self.metric)
|
|
|
|
def test_idempotent_key_updates_not_duplicates(self):
|
|
k = 'nexacloud:cpu:sub1:2026-05-01'
|
|
self.Usage._record_usage(self.sub, 'cpu_seconds', 100.0, '2026-05-01', '2026-06-01', idem=k)
|
|
self.Usage._record_usage(self.sub, 'cpu_seconds', 175.0, '2026-05-01', '2026-06-01', idem=k)
|
|
rows = self.Usage.search([('idempotency_key', '=', k)])
|
|
self.assertEqual(len(rows), 1) # no duplicate
|
|
self.assertEqual(rows.quantity, 175.0) # last value wins for the same key
|
|
|
|
def test_aggregate_sum(self):
|
|
for i, q in enumerate([10.0, 20.0, 30.0]):
|
|
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
|
|
'2026-05-01', '2026-06-01', idem='cpu-%d' % i)
|
|
total = self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01')
|
|
self.assertEqual(total, 60.0)
|
|
|
|
def test_aggregate_max(self):
|
|
self.metric.aggregation = 'max'
|
|
for i, q in enumerate([10.0, 55.0, 30.0]):
|
|
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
|
|
'2026-05-01', '2026-06-01', idem='m-%d' % i)
|
|
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 55.0)
|
|
|
|
def test_aggregate_excludes_other_periods(self):
|
|
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)
|
|
|
|
# ── item 6 (H2): half-open aggregation window anchored on period_start ──
|
|
def test_aggregate_daily_rollups_in_window(self):
|
|
"""Three DAILY rollups (period_start 05-01/-08/-15, each period_end +1 day)
|
|
sum correctly for the half-open window ['2026-05-01', '2026-06-01')."""
|
|
rollups = [
|
|
('2026-05-01 00:00:00', '2026-05-02 00:00:00', 3.0),
|
|
('2026-05-08 00:00:00', '2026-05-09 00:00:00', 5.0),
|
|
('2026-05-15 00:00:00', '2026-05-16 00:00:00', 7.0),
|
|
]
|
|
for i, (ps, pe, q) in enumerate(rollups):
|
|
self.Usage._record_usage(self.sub, 'cpu_seconds', q, ps, pe, idem='daily-%d' % i)
|
|
total = self.Usage._aggregate(
|
|
self.sub, self.metric, '2026-05-01 00:00:00', '2026-06-01 00:00:00')
|
|
self.assertEqual(total, 15.0) # 3 + 5 + 7
|
|
|
|
# ── item 7 (H3): idempotency key is scoped per (subscription, metric) ──
|
|
def test_same_idempotency_key_distinct_subscriptions(self):
|
|
"""The SAME idempotency key on two DIFFERENT subscriptions creates TWO rows."""
|
|
sub2 = self.env['sale.order'].sudo().create({
|
|
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
|
|
})
|
|
key = 'shared-idem-key'
|
|
a = self.Usage._record_usage(self.sub, 'cpu_seconds', 10.0, '2026-05-01', '2026-06-01', idem=key)
|
|
b = self.Usage._record_usage(sub2, 'cpu_seconds', 20.0, '2026-05-01', '2026-06-01', idem=key)
|
|
self.assertNotEqual(a, b) # distinct rows, no collision
|
|
rows = self.Usage.search([('idempotency_key', '=', key)])
|
|
self.assertEqual(len(rows), 2)
|
|
self.assertEqual(a.quantity, 10.0)
|
|
self.assertEqual(b.quantity, 20.0)
|
|
|
|
# ── item 2 (C1): zero aggregated usage creates no overage line ──
|
|
def test_zero_usage_creates_no_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'})
|
|
# no usage recorded → aggregate is 0 → amount 0 → no line created
|
|
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
|
|
self.assertEqual(amount, 0.0)
|
|
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
|
|
self.assertFalse(line)
|