mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
- Advisory lock: wrap per-invoice processing in try/finally so lock is ALWAYS released, even on exception. Previously, any error between lock acquisition and release would leak the lock, causing the invoice to be silently skipped forever on subsequent cycles. - Error logging: wallet RPC errors now logged at ERROR level (was DEBUG), making silent reconciliation failures visible in production logs. - Transfer protection: don't delete stored transfers when wallet-rpc returns an empty response (transient RPC glitch). Only prune when we actually got transfer data back. - Wallet restore height: new wallets generated from keys now look back ~2 days (1440 blocks) instead of starting at current height. Prevents missing payments that arrived before the wallet was first opened on a given wallet-rpc instance. - Decimal formatting: _format_xmr_fixed() now strips trailing zeros instead of always showing 12 decimal places. - Invoice amount rounding: fiat-converted amounts now use ROUND_UP with 3 decimal places (was ROUND_DOWN with 12), preventing undercharging. - Webhook retries: BTCPay webhook delivery now retries up to 3 times with 1s/2s backoff on 5xx or network errors. Timeout increased 5s→10s. Failures logged at ERROR level (was WARNING/DEBUG).
174 lines
5.3 KiB
Python
174 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from urllib.parse import urljoin
|
|
|
|
import requests
|
|
from requests import RequestException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .models import BtcpayWebhook, Invoice
|
|
from .security import decrypt_secret
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
|
|
|
|
|
def dispatch_btcpay_webhooks(
|
|
db: Session,
|
|
user_id: str,
|
|
event_type: str,
|
|
invoice: Invoice,
|
|
*,
|
|
manually_marked: bool = False,
|
|
) -> None:
|
|
hooks = (
|
|
db.query(BtcpayWebhook)
|
|
.filter(
|
|
BtcpayWebhook.user_id == user_id,
|
|
BtcpayWebhook.enabled.is_(True),
|
|
)
|
|
.all()
|
|
)
|
|
if not hooks:
|
|
return
|
|
payload = _build_payload(
|
|
event_type=event_type,
|
|
user_id=user_id,
|
|
invoice=invoice,
|
|
manually_marked=manually_marked,
|
|
)
|
|
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
for hook in hooks:
|
|
if not _event_allowed(hook.authorized_events, event_type):
|
|
continue
|
|
try:
|
|
secret = decrypt_secret(hook.secret_encrypted)
|
|
signature = _sign_payload(body, secret)
|
|
headers = {
|
|
"BTCPay-Sig": f"sha256={signature}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "wowcheckout-btcpay-webhook/1.0",
|
|
}
|
|
# Retry up to 3 times with backoff on transient failures
|
|
last_response = None
|
|
for attempt in range(3):
|
|
try:
|
|
last_response = _post_with_redirects(
|
|
hook.url,
|
|
data=body,
|
|
headers=headers,
|
|
timeout=10,
|
|
)
|
|
if last_response is not None and last_response.status_code < 500:
|
|
break
|
|
except RequestException:
|
|
if attempt == 2:
|
|
raise
|
|
if attempt < 2:
|
|
time.sleep(1.0 * (attempt + 1))
|
|
if last_response is None:
|
|
logger.warning(
|
|
"BTCPay webhook delivery failed after retries (redirect loop)",
|
|
extra={"webhook_id": str(hook.id), "event": event_type, "url": hook.url},
|
|
)
|
|
continue
|
|
if last_response.status_code >= 400:
|
|
logger.warning(
|
|
"BTCPay webhook delivered non-success status",
|
|
extra={
|
|
"webhook_id": str(hook.id),
|
|
"event": event_type,
|
|
"http_status": last_response.status_code,
|
|
},
|
|
)
|
|
except RequestException as exc:
|
|
logger.error(
|
|
"BTCPay webhook delivery failed after 3 attempts: %s",
|
|
exc,
|
|
extra={"webhook_id": str(hook.id), "event": event_type, "url": hook.url},
|
|
)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"BTCPay webhook dispatch failed: %s",
|
|
exc,
|
|
extra={"webhook_id": str(hook.id), "event": event_type},
|
|
)
|
|
|
|
|
|
def _event_allowed(authorized_events: object, event_type: str) -> bool:
|
|
if not isinstance(authorized_events, dict):
|
|
return False
|
|
if authorized_events.get("everything") is True:
|
|
return True
|
|
specific = authorized_events.get("specificEvents") or []
|
|
return event_type in specific
|
|
|
|
|
|
def _build_payload(
|
|
*,
|
|
event_type: str,
|
|
user_id: str,
|
|
invoice: Invoice,
|
|
manually_marked: bool,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"type": event_type,
|
|
"timestamp": int(time.time()),
|
|
"storeId": user_id,
|
|
"invoiceId": str(invoice.id),
|
|
"manuallyMarked": manually_marked,
|
|
"overPaid": False,
|
|
"partiallyPaid": False,
|
|
"afterExpiration": _after_expiration(invoice),
|
|
"metadata": invoice.metadata_json or {},
|
|
}
|
|
|
|
|
|
def _after_expiration(invoice: Invoice) -> bool:
|
|
expires_at = invoice.expires_at
|
|
detected_at = invoice.detected_at
|
|
if expires_at is None or detected_at is None:
|
|
return False
|
|
if expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
if detected_at.tzinfo is None:
|
|
detected_at = detected_at.replace(tzinfo=timezone.utc)
|
|
return detected_at > expires_at
|
|
|
|
|
|
def _sign_payload(body: bytes, secret: str) -> str:
|
|
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
|
|
|
|
def _post_with_redirects(
|
|
url: str,
|
|
*,
|
|
data: bytes,
|
|
headers: dict[str, str],
|
|
timeout: int,
|
|
max_redirects: int = 3,
|
|
) -> requests.Response | None:
|
|
current_url = url
|
|
for _ in range(max_redirects + 1):
|
|
response = requests.post(
|
|
current_url,
|
|
data=data,
|
|
headers=headers,
|
|
timeout=timeout,
|
|
allow_redirects=False,
|
|
)
|
|
if response.status_code not in _REDIRECT_STATUSES:
|
|
return response
|
|
location = response.headers.get("Location")
|
|
if not location:
|
|
return response
|
|
current_url = urljoin(current_url, location)
|
|
return None
|