feat(billing): outbound webhook engine (HMAC + retry/backoff)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-27 03:04:05 -04:00
parent 0754d0b101
commit 6c395709cf
3 changed files with 114 additions and 1 deletions

View File

@@ -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)

View File

@@ -2,3 +2,4 @@ from . import test_identity
from . import test_charge
from . import test_usage
from . import test_api
from . import test_webhook

View File

@@ -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')