102 lines
3.4 KiB
Python
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)
|