feat(billing): outbound webhook engine (HMAC + retry/backoff)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -2,3 +2,4 @@ from . import test_identity
|
||||
from . import test_charge
|
||||
from . import test_usage
|
||||
from . import test_api
|
||||
from . import test_webhook
|
||||
|
||||
53
fusion_centralize_billing/tests/test_webhook.py
Normal file
53
fusion_centralize_billing/tests/test_webhook.py
Normal 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')
|
||||
Reference in New Issue
Block a user