feat(billing): /usage resolves subscription by source app id (enables 2b)

_api_record_usage now resolves the target subscription via the source
app's own id (x_fc_nexacloud_subscription_id, scoped to the service)
before falling back to a direct Odoo sale.order id. This is what lets
NexaCloud push usage against the shadow subscriptions the importer
created from NexaCloud UUIDs — closing the flip-day mapping gap the
review flagged. Authz unchanged (partner must be linked to the service).
This commit is contained in:
gsinghpal
2026-05-27 14:37:30 -04:00
parent 2bdf4ef6a0
commit a5144a925c
2 changed files with 45 additions and 5 deletions

View File

@@ -109,6 +109,27 @@ class FusionBillingService(models.Model):
self, ext, name=payload.get('name'), email=payload.get('email'))
return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext}
def _fc_resolve_subscription(self, external_ref):
"""Resolve the subscription sale.order a usage event targets.
Prefer the source app's OWN id (``x_fc_nexacloud_subscription_id`` scoped to this
service) so apps reference their own ids — this is what lets NexaCloud push usage
against shadow subscriptions the importer created from its UUIDs. Falls back to a
direct Odoo ``sale.order`` id for live-created subs (post-flip). Authorization is
still enforced by the caller (partner must be linked to this service)."""
self.ensure_one()
SaleOrder = self.env['sale.order']
sub = SaleOrder.search([
('x_fc_nexacloud_subscription_id', '=', str(external_ref)),
('x_fc_billing_service_id', '=', self.id),
], limit=1)
if sub:
return sub
try:
return SaleOrder.browse(int(external_ref))
except (TypeError, ValueError):
return SaleOrder
def _api_record_usage(self, payload):
"""Ingest a batch of usage events.
@@ -139,15 +160,11 @@ class FusionBillingService(models.Model):
'period_start', 'period_end'):
if ev.get(key) in (None, ''):
return {'status': 'error', 'error': 'missing %s' % key}
try:
sub_id = int(ev['subscription_external_id'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid subscription_external_id'}
try:
quantity = float(ev['quantity'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid quantity'}
sub = self.env['sale.order'].browse(sub_id)
sub = self._fc_resolve_subscription(ev['subscription_external_id'])
if not sub.exists() or not sub.is_subscription \
or sub.partner_id not in linked_partners:
return {'status': 'error', 'error': 'unknown subscription'}

View File

@@ -254,3 +254,26 @@ class TestImporterReadGuard(TransactionCase):
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
with self.assertRaises(UserError):
wiz.action_test_connection()
@tagged('post_install', '-at_install')
class TestUsageApiSourceId(TransactionCase):
"""The /usage API must resolve a subscription by NexaCloud's OWN id, so usage can be
pushed against shadow subs the importer created from UUIDs (the flip-day gap)."""
def setUp(self):
super().setUp()
self.env['fusion.billing.import.wizard'].sudo()._import_rows(_fixture())
self.service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])
def test_record_usage_resolves_by_nexacloud_subscription_id(self):
res = self.service._api_record_usage({'events': [{
'subscription_external_id': 's-1', # NexaCloud UUID, not the Odoo id
'metric_code': 'cpu_seconds', 'quantity': 3600.0,
'period_start': '2026-05-01', 'period_end': '2026-06-01',
'idempotency_key': 'nc:s-1:2026-05'}]})
self.assertEqual(res['status'], 'ok')
self.assertEqual(res['accepted'], 1)
sub = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
self.assertEqual(usage.quantity, 3600.0)