mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +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 (0.033554999694 → 0.033555). - Invoice amount rounding: fiat-converted amounts now use ROUND_UP with 5 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).
713 lines
23 KiB
Python
713 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .btcpay_schemas import (
|
|
BtcpayInvoiceCreate,
|
|
BtcpayInvoiceStatusUpdate,
|
|
BtcpayWebhookCreate,
|
|
BtcpayWebhookUpdate,
|
|
)
|
|
from .btcpay_webhooks import dispatch_btcpay_webhooks
|
|
from .config import INVOICE_DEFAULT_EXPIRY_HOURS
|
|
from .config import QR_STORAGE_DIR
|
|
from .db import get_db
|
|
from .formatting import format_xmr_amount
|
|
from .models import BtcpayWebhook, Invoice, InvoiceTransfer, User
|
|
from .rates import get_xmr_rate
|
|
from .security import (
|
|
decrypt_api_key,
|
|
encrypt_secret,
|
|
generate_webhook_secret,
|
|
hash_api_key,
|
|
require_api_key,
|
|
)
|
|
from .subaddress_allocator import create_subaddress_for_user
|
|
from .webhooks import dispatch_webhooks
|
|
from .qr_codes import ensure_invoice_qr_png, resolve_qr_settings
|
|
|
|
router = APIRouter()
|
|
|
|
BTCPAY_PAYMENT_METHOD = "XMR-CHAIN"
|
|
BTCPAY_PAYMENT_METHOD_ALIASES = {BTCPAY_PAYMENT_METHOD, "XMR", "XMR_CHAIN"}
|
|
BTCPAY_WEBHOOK_EVENTS = {
|
|
"InvoiceCreated",
|
|
"InvoiceReceivedPayment",
|
|
"InvoicePaidInFull",
|
|
"InvoicePaymentSettled",
|
|
"InvoiceProcessing",
|
|
"InvoiceExpired",
|
|
"InvoiceSettled",
|
|
"InvoiceInvalid",
|
|
}
|
|
|
|
|
|
def _require_btcpay_user(
|
|
api_key: str = Depends(require_api_key),
|
|
db: Session = Depends(get_db),
|
|
) -> User:
|
|
user = db.query(User).filter(User.api_key_hash == hash_api_key(api_key)).first()
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User API key required",
|
|
)
|
|
return user
|
|
|
|
|
|
def _require_store(store_id: str, user: User) -> str:
|
|
if store_id != str(user.id):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Store not found",
|
|
)
|
|
return store_id
|
|
|
|
|
|
def _store_payload(user: User) -> dict[str, Any]:
|
|
expiry_minutes = max(1, INVOICE_DEFAULT_EXPIRY_HOURS * 60)
|
|
return {
|
|
"id": str(user.id),
|
|
"name": "XMR Checkout",
|
|
"website": "",
|
|
"defaultCurrency": "XMR",
|
|
"invoiceExpiration": expiry_minutes,
|
|
"displayExpirationTimer": True,
|
|
"monitoringExpiration": expiry_minutes,
|
|
"speedPolicy": "MediumSpeed",
|
|
"paymentTolerance": 0,
|
|
"anyoneCanCreateInvoice": False,
|
|
"requiresRefundEmail": False,
|
|
"lightningAmountInSatoshi": False,
|
|
"lightningPrivateRouteHints": False,
|
|
"onChainWithLnInvoiceFallback": False,
|
|
"redirectAutomatically": False,
|
|
"showRecommendedFee": False,
|
|
"recommendedFeeBlockTarget": 0,
|
|
"defaultLang": "en",
|
|
"customLogo": "",
|
|
"customCSS": "",
|
|
"htmlTitle": "XMR Checkout",
|
|
"networkFeeMode": "Never",
|
|
"payJoinEnabled": False,
|
|
"lazyPaymentMethods": False,
|
|
"defaultPaymentMethod": BTCPAY_PAYMENT_METHOD,
|
|
}
|
|
|
|
|
|
def _invoice_checkout_link(invoice: Invoice, request: Request) -> str:
|
|
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
|
|
host = request.headers.get("x-forwarded-host", request.headers.get("host", ""))
|
|
if not host:
|
|
return f"/i/{invoice.id}"
|
|
return f"{proto}://{host}/i/{invoice.id}"
|
|
|
|
|
|
def _normalize_btcpay_payment_method(value: str) -> str:
|
|
normalized = value.strip().upper()
|
|
if normalized in BTCPAY_PAYMENT_METHOD_ALIASES:
|
|
return BTCPAY_PAYMENT_METHOD
|
|
return normalized
|
|
|
|
|
|
def _epoch_seconds(value: datetime | None) -> int:
|
|
if value is None:
|
|
return 0
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return int(value.timestamp())
|
|
|
|
|
|
def _resolve_btcpay_amount(
|
|
amount: Decimal,
|
|
currency: str,
|
|
) -> tuple[Decimal, dict[str, str] | None]:
|
|
if currency.upper() == "XMR":
|
|
return amount, None
|
|
try:
|
|
quote = get_xmr_rate(currency)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(exc),
|
|
) from exc
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Fiat quote service unavailable",
|
|
) from exc
|
|
# Round UP to 5 decimal places so we never undercharge.
|
|
# 5 decimals = 0.00001 XMR ≈ $0.002 at $200/XMR — more than enough precision.
|
|
# See also: routes.py _resolve_invoice_amount() for the same rounding.
|
|
# To change precision: adjust the Decimal quantize string here and in routes.py.
|
|
amount_xmr = (Decimal(amount) / quote.rate).quantize(
|
|
Decimal("0.00001"), rounding=ROUND_UP
|
|
)
|
|
quote_payload = {
|
|
"fiat_amount": str(amount),
|
|
"fiat_currency": quote.currency,
|
|
"rate": str(quote.rate),
|
|
"source": quote.source,
|
|
"quoted_at": quote.quoted_at.isoformat(),
|
|
}
|
|
return amount_xmr, quote_payload
|
|
|
|
|
|
def _btcpay_status(invoice: Invoice) -> tuple[str, str]:
|
|
additional_status = _btcpay_additional_status(invoice)
|
|
if invoice.status == "payment_detected":
|
|
return "Processing", additional_status
|
|
if invoice.status == "confirmed":
|
|
return "Settled", additional_status
|
|
if invoice.status == "expired":
|
|
return "Expired", additional_status
|
|
if invoice.status == "invalid":
|
|
return "Invalid", additional_status
|
|
return "New", additional_status
|
|
|
|
|
|
def _btcpay_additional_status(invoice: Invoice) -> str:
|
|
if invoice.status == "invalid":
|
|
return "Marked"
|
|
paid_atomic = invoice.total_paid_atomic or 0
|
|
required_atomic = _xmr_to_atomic(invoice.amount_xmr)
|
|
if paid_atomic > required_atomic:
|
|
return "PaidOver"
|
|
if 0 < paid_atomic < required_atomic:
|
|
return "PaidPartial"
|
|
if _after_expiration(invoice):
|
|
return "PaidLate"
|
|
return "None"
|
|
|
|
|
|
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 _xmr_to_atomic(amount: Decimal) -> int:
|
|
quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value(
|
|
rounding=ROUND_DOWN
|
|
)
|
|
return int(quantized)
|
|
|
|
|
|
def _btcpay_amount_currency(invoice: Invoice) -> tuple[str, str, dict[str, Any]]:
|
|
metadata = invoice.metadata_json or {}
|
|
btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None
|
|
if isinstance(btcpay_data, dict):
|
|
amount = btcpay_data.get("amount")
|
|
currency = btcpay_data.get("currency")
|
|
if isinstance(amount, str) and isinstance(currency, str):
|
|
return amount, currency, btcpay_data
|
|
return format_xmr_amount(invoice.amount_xmr), "XMR", {}
|
|
|
|
|
|
def _format_xmr_fixed(value: Decimal) -> str:
|
|
"""Format XMR amount with trailing zeros stripped."""
|
|
formatted = f"{value.quantize(Decimal('0.000000000001')):.12f}"
|
|
if "." in formatted:
|
|
formatted = formatted.rstrip("0").rstrip(".")
|
|
return formatted
|
|
|
|
|
|
def _atomic_to_xmr(value: int) -> Decimal:
|
|
return (Decimal(value) / Decimal("1000000000000")).quantize(
|
|
Decimal("0.000000000001"), rounding=ROUND_DOWN
|
|
)
|
|
|
|
|
|
@router.get("/api/v1/stores")
|
|
def list_stores(user: User = Depends(_require_btcpay_user)):
|
|
return [_store_payload(user)]
|
|
|
|
|
|
@router.get("/api/v1/stores/{store_id}")
|
|
def get_store(store_id: str, user: User = Depends(_require_btcpay_user)):
|
|
_require_store(store_id, user)
|
|
return _store_payload(user)
|
|
|
|
|
|
@router.get("/api/v1/stores/{store_id}/payment-methods")
|
|
def list_payment_methods(store_id: str, user: User = Depends(_require_btcpay_user)):
|
|
_require_store(store_id, user)
|
|
return [
|
|
{
|
|
"paymentMethodId": BTCPAY_PAYMENT_METHOD,
|
|
"paymentMethod": BTCPAY_PAYMENT_METHOD,
|
|
"enabled": True,
|
|
"cryptoCode": "XMR",
|
|
}
|
|
]
|
|
|
|
|
|
@router.post("/api/v1/stores/{store_id}/invoices", status_code=200)
|
|
def create_invoice(
|
|
store_id: str,
|
|
payload: BtcpayInvoiceCreate,
|
|
request: Request,
|
|
user: User = Depends(_require_btcpay_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_require_store(store_id, user)
|
|
checkout = payload.checkout
|
|
payment_methods = (
|
|
[_normalize_btcpay_payment_method(method) for method in checkout.paymentMethods]
|
|
if checkout and checkout.paymentMethods
|
|
else None
|
|
)
|
|
if payment_methods and BTCPAY_PAYMENT_METHOD not in payment_methods:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Unsupported payment method",
|
|
)
|
|
amount_xmr, quote_payload = _resolve_btcpay_amount(payload.amount, payload.currency)
|
|
expires_at = None
|
|
now = datetime.now(timezone.utc)
|
|
expiration_minutes = None
|
|
monitoring_minutes = None
|
|
if checkout and checkout.expirationMinutes:
|
|
expiration_minutes = int(checkout.expirationMinutes)
|
|
if expiration_minutes:
|
|
expires_at = now + timedelta(minutes=expiration_minutes)
|
|
else:
|
|
expires_at = now + timedelta(hours=INVOICE_DEFAULT_EXPIRY_HOURS)
|
|
if checkout and checkout.monitoringMinutes is not None:
|
|
monitoring_minutes = int(checkout.monitoringMinutes)
|
|
metadata: dict[str, Any] = dict(payload.metadata or {})
|
|
btcpay_data = metadata.get("btcpay") if isinstance(metadata.get("btcpay"), dict) else {}
|
|
btcpay_data = dict(btcpay_data)
|
|
btcpay_data.update(
|
|
{
|
|
"amount": str(payload.amount),
|
|
"currency": payload.currency,
|
|
"checkout": checkout.model_dump(mode="json") if checkout else None,
|
|
"expiration_minutes": expiration_minutes,
|
|
"monitoring_minutes": monitoring_minutes,
|
|
}
|
|
)
|
|
metadata["btcpay"] = btcpay_data
|
|
if quote_payload and "quote" not in metadata:
|
|
metadata["quote"] = quote_payload
|
|
qr = metadata.get("qr")
|
|
if not isinstance(qr, dict):
|
|
qr = {"logo": user.default_qr_logo}
|
|
if user.default_qr_logo == "custom" and user.default_qr_logo_data_url:
|
|
qr["logo_data_url"] = user.default_qr_logo_data_url
|
|
metadata["qr"] = qr
|
|
address, subaddress_index = create_subaddress_for_user(db, user=user)
|
|
invoice = Invoice(
|
|
user_id=user.id,
|
|
wallet_address=user.payment_address,
|
|
address=address,
|
|
subaddress_index=subaddress_index,
|
|
amount_xmr=amount_xmr,
|
|
status="pending",
|
|
confirmation_target=user.default_confirmation_target,
|
|
total_paid_atomic=0,
|
|
metadata_json=metadata,
|
|
expires_at=expires_at,
|
|
)
|
|
db.add(invoice)
|
|
db.commit()
|
|
db.refresh(invoice)
|
|
try:
|
|
ensure_invoice_qr_png(
|
|
invoice=invoice,
|
|
storage_dir=QR_STORAGE_DIR,
|
|
settings=resolve_qr_settings(invoice),
|
|
)
|
|
except HTTPException:
|
|
pass
|
|
dispatch_webhooks(db, str(user.id), "invoice.created", invoice)
|
|
dispatch_btcpay_webhooks(db, str(user.id), "InvoiceCreated", invoice)
|
|
status_name, additional_status = _btcpay_status(invoice)
|
|
amount, currency, _ = _btcpay_amount_currency(invoice)
|
|
expiration_time = _epoch_seconds(invoice.expires_at)
|
|
monitoring_time = expiration_time
|
|
if monitoring_minutes is None:
|
|
monitoring_minutes = 60
|
|
monitoring_time = expiration_time + int(monitoring_minutes * 60)
|
|
return {
|
|
"id": str(invoice.id),
|
|
"amount": amount,
|
|
"currency": currency,
|
|
"type": "Standard",
|
|
"checkoutLink": _invoice_checkout_link(invoice, request),
|
|
"createdTime": _epoch_seconds(invoice.created_at),
|
|
"expirationTime": expiration_time,
|
|
"monitoringTime": monitoring_time,
|
|
"archived": False,
|
|
"status": status_name,
|
|
"additionalStatus": additional_status,
|
|
"availableStatusesForManualMarking": ["Invalid"],
|
|
}
|
|
|
|
|
|
@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}")
|
|
def get_invoice(
|
|
store_id: str,
|
|
invoice_id: str,
|
|
request: Request,
|
|
user: User = Depends(_require_btcpay_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_require_store(store_id, user)
|
|
try:
|
|
invoice_uuid = uuid.UUID(invoice_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Invoice not found",
|
|
)
|
|
invoice = (
|
|
db.query(Invoice)
|
|
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
|
|
.first()
|
|
)
|
|
if invoice is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Invoice not found",
|
|
)
|
|
status_name, additional_status = _btcpay_status(invoice)
|
|
amount, currency, btcpay_data = _btcpay_amount_currency(invoice)
|
|
expiration_time = _epoch_seconds(invoice.expires_at)
|
|
monitoring_minutes = (
|
|
btcpay_data.get("monitoring_minutes")
|
|
if isinstance(btcpay_data, dict)
|
|
else None
|
|
)
|
|
if monitoring_minutes is None:
|
|
monitoring_minutes = 60
|
|
monitoring_time = expiration_time + int(int(monitoring_minutes) * 60)
|
|
return {
|
|
"id": str(invoice.id),
|
|
"amount": amount,
|
|
"currency": currency,
|
|
"type": "Standard",
|
|
"checkoutLink": _invoice_checkout_link(invoice, request),
|
|
"createdTime": _epoch_seconds(invoice.created_at),
|
|
"expirationTime": expiration_time,
|
|
"monitoringTime": monitoring_time,
|
|
"archived": invoice.archived_at is not None,
|
|
"status": status_name,
|
|
"additionalStatus": additional_status,
|
|
"availableStatusesForManualMarking": ["Invalid"],
|
|
}
|
|
|
|
|
|
@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods")
|
|
def get_invoice_payment_methods(
|
|
store_id: str,
|
|
invoice_id: str,
|
|
user: User = Depends(_require_btcpay_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_require_store(store_id, user)
|
|
try:
|
|
invoice_uuid = uuid.UUID(invoice_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Invoice not found",
|
|
)
|
|
invoice = (
|
|
db.query(Invoice)
|
|
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
|
|
.first()
|
|
)
|
|
if invoice is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Invoice not found",
|
|
)
|
|
transfers = (
|
|
db.query(InvoiceTransfer)
|
|
.filter(InvoiceTransfer.invoice_id == invoice.id)
|
|
.order_by(InvoiceTransfer.created_at.asc())
|
|
.all()
|
|
)
|
|
total_atomic = sum(
|
|
transfer.amount_atomic for transfer in transfers if transfer.amount_atomic > 0
|
|
)
|
|
total_paid = _atomic_to_xmr(total_atomic)
|
|
due = max(Decimal("0"), invoice.amount_xmr - total_paid)
|
|
metadata = invoice.metadata_json or {}
|
|
quote = metadata.get("quote") if isinstance(metadata, dict) else None
|
|
rate_value = "0"
|
|
if isinstance(quote, dict) and "rate" in quote:
|
|
rate_value = str(quote.get("rate", "0"))
|
|
payments = []
|
|
created_fallback = _epoch_seconds(invoice.created_at)
|
|
for transfer in transfers:
|
|
if transfer.amount_atomic <= 0:
|
|
continue
|
|
confirmations = transfer.confirmations
|
|
status_label = (
|
|
"confirmed"
|
|
if confirmations >= max(0, invoice.confirmation_target)
|
|
else "pending"
|
|
)
|
|
payments.append(
|
|
{
|
|
"id": f"{transfer.txid}-0",
|
|
"value": _format_xmr_fixed(_atomic_to_xmr(transfer.amount_atomic)),
|
|
"fee": "0",
|
|
"destination": transfer.address or invoice.address,
|
|
"status": status_label,
|
|
"receivedDate": transfer.timestamp or created_fallback,
|
|
}
|
|
)
|
|
return [
|
|
{
|
|
"paymentMethodId": BTCPAY_PAYMENT_METHOD,
|
|
"paymentMethod": BTCPAY_PAYMENT_METHOD,
|
|
"cryptoCode": "XMR",
|
|
"destination": invoice.address,
|
|
"rate": rate_value,
|
|
"amount": _format_xmr_fixed(invoice.amount_xmr),
|
|
"due": _format_xmr_fixed(due),
|
|
"totalPaid": _format_xmr_fixed(total_paid),
|
|
"paymentMethodPaid": _format_xmr_fixed(total_paid),
|
|
"networkFee": "0",
|
|
"payments": payments,
|
|
}
|
|
]
|
|
|
|
|
|
@router.post("/api/v1/stores/{store_id}/invoices/{invoice_id}/status", status_code=200)
|
|
def mark_invoice_status(
|
|
store_id: str,
|
|
invoice_id: str,
|
|
payload: BtcpayInvoiceStatusUpdate,
|
|
user: User = Depends(_require_btcpay_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_require_store(store_id, user)
|
|
if payload.status.strip().lower() != "invalid":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Unsupported status",
|
|
)
|
|
try:
|
|
invoice_uuid = uuid.UUID(invoice_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Invoice not found",
|
|
)
|
|
invoice = (
|
|
db.query(Invoice)
|
|
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
|
|
.first()
|
|
)
|
|
if invoice is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Invoice not found",
|
|
)
|
|
if invoice.status == "confirmed":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Confirmed invoices cannot be marked invalid",
|
|
)
|
|
invoice.status = "invalid"
|
|
db.add(invoice)
|
|
db.commit()
|
|
db.refresh(invoice)
|
|
dispatch_btcpay_webhooks(
|
|
db,
|
|
str(user.id),
|
|
"InvoiceInvalid",
|
|
invoice,
|
|
manually_marked=True,
|
|
)
|
|
return {"status": "Invalid"}
|
|
|
|
|
|
@router.post("/api/v1/stores/{store_id}/webhooks", status_code=200)
|
|
def create_webhook(
|
|
store_id: str,
|
|
payload: BtcpayWebhookCreate,
|
|
user: User = Depends(_require_btcpay_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_require_store(store_id, user)
|
|
_validate_webhook_events(payload.authorizedEvents.specificEvents)
|
|
secret = generate_webhook_secret()
|
|
webhook = BtcpayWebhook(
|
|
user_id=user.id,
|
|
url=str(payload.url),
|
|
enabled=payload.enabled,
|
|
automatic_redelivery=payload.automaticRedelivery,
|
|
authorized_events=payload.authorizedEvents.model_dump(),
|
|
secret_encrypted=encrypt_secret(secret),
|
|
)
|
|
db.add(webhook)
|
|
db.commit()
|
|
db.refresh(webhook)
|
|
return _webhook_response(webhook, include_secret=True, secret=secret)
|
|
|
|
|
|
@router.get("/api/v1/stores/{store_id}/webhooks")
|
|
def list_webhooks(
|
|
store_id: str,
|
|
user: User = Depends(_require_btcpay_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_require_store(store_id, user)
|
|
hooks = (
|
|
db.query(BtcpayWebhook)
|
|
.filter(BtcpayWebhook.user_id == user.id)
|
|
.order_by(BtcpayWebhook.created_at.desc())
|
|
.all()
|
|
)
|
|
return [_webhook_response(hook) for hook in hooks]
|
|
|
|
|
|
@router.get("/api/v1/stores/{store_id}/webhooks/{webhook_id}")
|
|
def get_webhook(
|
|
store_id: str,
|
|
webhook_id: str,
|
|
user: User = Depends(_require_btcpay_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_require_store(store_id, user)
|
|
hook = _get_webhook(db, webhook_id, user)
|
|
return _webhook_response(hook)
|
|
|
|
|
|
@router.put("/api/v1/stores/{store_id}/webhooks/{webhook_id}")
|
|
def update_webhook(
|
|
store_id: str,
|
|
webhook_id: str,
|
|
payload: BtcpayWebhookUpdate,
|
|
user: User = Depends(_require_btcpay_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_require_store(store_id, user)
|
|
hook = _get_webhook(db, webhook_id, user)
|
|
if payload.authorizedEvents and payload.authorizedEvents.specificEvents:
|
|
_validate_webhook_events(payload.authorizedEvents.specificEvents)
|
|
if payload.enabled is not None:
|
|
hook.enabled = payload.enabled
|
|
if payload.automaticRedelivery is not None:
|
|
hook.automatic_redelivery = payload.automaticRedelivery
|
|
if payload.url is not None:
|
|
hook.url = str(payload.url)
|
|
if payload.authorizedEvents is not None:
|
|
hook.authorized_events = payload.authorizedEvents.model_dump()
|
|
db.add(hook)
|
|
db.commit()
|
|
db.refresh(hook)
|
|
return _webhook_response(hook)
|
|
|
|
|
|
@router.delete(
|
|
"/api/v1/stores/{store_id}/webhooks/{webhook_id}",
|
|
status_code=204,
|
|
)
|
|
def delete_webhook(
|
|
store_id: str,
|
|
webhook_id: str,
|
|
user: User = Depends(_require_btcpay_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_require_store(store_id, user)
|
|
hook = _get_webhook(db, webhook_id, user)
|
|
db.delete(hook)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@router.get("/api/v1/server/info")
|
|
def server_info():
|
|
return {
|
|
"version": "1.7.5",
|
|
"onion": None,
|
|
"fullySynched": True,
|
|
"supportedPaymentMethods": [BTCPAY_PAYMENT_METHOD],
|
|
}
|
|
|
|
|
|
@router.get("/api/v1/api-keys/current")
|
|
def api_key_current(
|
|
user: User = Depends(_require_btcpay_user),
|
|
):
|
|
store_id = str(user.id)
|
|
return {
|
|
"apiKey": decrypt_api_key(user.api_key_encrypted),
|
|
"label": "XMR Checkout",
|
|
"permissions": [
|
|
f"btcpay.store.canviewinvoices:{store_id}",
|
|
f"btcpay.store.cancreateinvoice:{store_id}",
|
|
f"btcpay.store.canviewstoresettings:{store_id}",
|
|
f"btcpay.store.canmodifyinvoices:{store_id}",
|
|
f"btcpay.store.webhooks.canmodifywebhooks:{store_id}",
|
|
],
|
|
}
|
|
|
|
|
|
def _get_webhook(db: Session, webhook_id: str, user: User) -> BtcpayWebhook:
|
|
try:
|
|
webhook_uuid = uuid.UUID(webhook_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Webhook not found",
|
|
)
|
|
hook = (
|
|
db.query(BtcpayWebhook)
|
|
.filter(BtcpayWebhook.id == webhook_uuid, BtcpayWebhook.user_id == user.id)
|
|
.first()
|
|
)
|
|
if hook is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Webhook not found",
|
|
)
|
|
return hook
|
|
|
|
|
|
def _validate_webhook_events(events: list[str] | None) -> None:
|
|
if not events:
|
|
return
|
|
for event in events:
|
|
if event not in BTCPAY_WEBHOOK_EVENTS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Webhook event is not supported",
|
|
)
|
|
|
|
|
|
def _webhook_response(
|
|
hook: BtcpayWebhook,
|
|
*,
|
|
include_secret: bool = False,
|
|
secret: str | None = None,
|
|
) -> dict[str, Any]:
|
|
payload = {
|
|
"id": str(hook.id),
|
|
"enabled": hook.enabled,
|
|
"automaticRedelivery": hook.automatic_redelivery,
|
|
"url": hook.url,
|
|
"authorizedEvents": hook.authorized_events,
|
|
}
|
|
if include_secret and secret:
|
|
payload["secret"] = secret
|
|
return payload
|