Files
Odoo-Modules/fusion_centralize_billing/models/webhook.py
2026-05-27 08:42:08 -04:00

102 lines
3.4 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
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):
"""Outbound webhook queue: lifecycle events delivered to source apps.
Processed by a cron with exponential backoff + HMAC-SHA256 signing, dead-lettered
after N attempts (mirror the proven retry pattern in NexaDesk's
lago-payment-retry-job). Apps react: suspend / restore / deprovision. See spec §8.
TODO(spec §8): cron processor, HMAC signing, backoff schedule.
"""
_name = "fusion.billing.webhook"
_description = "Fusion Billing — Outbound Webhook Event"
_order = "create_date desc"
service_id = fields.Many2one(
"fusion.billing.service", required=True, ondelete="cascade", index=True,
)
event_type = fields.Char(
required=True, index=True,
help="invoice.payment_failed / invoice.payment_succeeded / "
"subscription.terminated / subscription.reactivated / usage.threshold_reached",
)
payload = fields.Json()
state = fields.Selection(
[
("pending", "Pending"),
("sent", "Sent"),
("failed", "Failed"),
("dead", "Dead-lettered"),
],
default="pending", required=True, index=True,
)
attempts = fields.Integer(default=0)
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)