diff --git a/fusion_centralize_billing/models/webhook.py b/fusion_centralize_billing/models/webhook.py index 14b2ea81..acb01711 100644 --- a/fusion_centralize_billing/models/webhook.py +++ b/fusion_centralize_billing/models/webhook.py @@ -1,7 +1,19 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 -from odoo import fields, models +import hashlib +import hmac +import json +import logging +from datetime import timedelta + +import requests + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +MAX_ATTEMPTS = 8 class FusionBillingWebhook(models.Model): @@ -40,3 +52,50 @@ class FusionBillingWebhook(models.Model): next_retry_at = fields.Datetime() signature = fields.Char(help="HMAC-SHA256 of the payload using the service webhook_secret.") last_error = fields.Text() + + @api.model + def _sign(self, secret, body): + return hmac.new((secret or '').encode(), body.encode(), hashlib.sha256).hexdigest() + + @api.model + def _enqueue(self, service, event_type, payload): + body = json.dumps(payload, sort_keys=True, separators=(',', ':')) + return self.create({ + 'service_id': service.id, + 'event_type': event_type, + 'payload': payload, + 'signature': self._sign(service.webhook_secret, body), + 'state': 'pending', + 'next_retry_at': fields.Datetime.now(), + }) + + @api.model + def _cron_dispatch(self): + now = fields.Datetime.now() + due = self.search([ + ('state', 'in', ('pending', 'failed')), + ('next_retry_at', '<=', now), + ], limit=100) + for wh in due: + body = json.dumps(wh.payload, sort_keys=True, separators=(',', ':')) + try: + resp = requests.post( + wh.service_id.webhook_url, + data=body, + headers={'Content-Type': 'application/json', + 'X-Fusion-Signature': wh.signature, + 'X-Fusion-Event': wh.event_type}, + timeout=10, + ) + ok = 200 <= resp.status_code < 300 + except Exception as e: # noqa: BLE001 - record and retry + ok = False + wh.last_error = str(e)[:500] + wh.attempts += 1 + if ok: + wh.state = 'sent' + elif wh.attempts >= MAX_ATTEMPTS: + wh.state = 'dead' + else: + wh.state = 'failed' + wh.next_retry_at = now + timedelta(minutes=2 ** wh.attempts) diff --git a/fusion_centralize_billing/tests/__init__.py b/fusion_centralize_billing/tests/__init__.py index 225b18dc..6091734a 100644 --- a/fusion_centralize_billing/tests/__init__.py +++ b/fusion_centralize_billing/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_identity from . import test_charge from . import test_usage from . import test_api +from . import test_webhook diff --git a/fusion_centralize_billing/tests/test_webhook.py b/fusion_centralize_billing/tests/test_webhook.py new file mode 100644 index 00000000..aa8e9731 --- /dev/null +++ b/fusion_centralize_billing/tests/test_webhook.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +import hashlib +import hmac +import json +from unittest.mock import patch + +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestWebhookEngine(TransactionCase): + + def setUp(self): + super().setUp() + self.service = self.env['fusion.billing.service'].sudo().create({ + 'name': 'NexaCloud', 'code': 'nexacloud', + 'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook', + 'webhook_secret': 'whsec_test', + }) + self.Webhook = self.env['fusion.billing.webhook'].sudo() + + def test_enqueue_signs_payload(self): + wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-1'}) + self.assertEqual(wh.state, 'pending') + body = json.dumps({'invoice': 'INV-1'}, sort_keys=True, separators=(',', ':')) + expected = hmac.new(b'whsec_test', body.encode(), hashlib.sha256).hexdigest() + self.assertEqual(wh.signature, expected) + + def test_dispatch_marks_sent_on_2xx(self): + wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-2'}) + + class _Resp: + status_code = 200 + text = 'ok' + + with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', + return_value=_Resp()) as mock_post: + self.Webhook._cron_dispatch() + self.assertTrue(mock_post.called) + self.assertEqual(wh.state, 'sent') + + def test_dispatch_retries_then_deadletters(self): + wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-3'}) + wh.write({'attempts': 7}) # already past max + + class _Resp: + status_code = 500 + text = 'err' + + with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', + return_value=_Resp()): + self.Webhook._cron_dispatch() + self.assertEqual(wh.state, 'dead')