mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
Minimal changes from upstream xmrcheckout to support Wownero: - Rate provider: Nonlogs WOW-BTC via Kraken BTC/USD - Subaddress derivation: Wownero network byte overrides (53/63/54) - Payment URI scheme: wownero: - Dockerfile: Wownero binaries from Codeberg - Display text: Monero→Wownero, XMR→WOW throughout UI - Default daemon: 192.168.88.235:34568 - Added wownero-logo.svg File and folder structure kept identical to upstream for easy sync.
705 lines
22 KiB
Python
705 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal, ROUND_DOWN
|
|
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_wow_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 = "WOW-CHAIN"
|
|
BTCPAY_PAYMENT_METHOD_ALIASES = {BTCPAY_PAYMENT_METHOD, "WOW", "WOW_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": "WOW Checkout",
|
|
"website": "",
|
|
"defaultCurrency": "WOW",
|
|
"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": "WOW 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() == "WOW":
|
|
return amount, None
|
|
try:
|
|
quote = get_wow_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
|
|
amount_xmr = (Decimal(amount) / quote.rate).quantize(
|
|
Decimal("0.000000000001"), rounding=ROUND_DOWN
|
|
)
|
|
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), "WOW", {}
|
|
|
|
|
|
def _format_xmr_fixed(value: Decimal) -> str:
|
|
return f"{value.quantize(Decimal('0.000000000001')):.12f}"
|
|
|
|
|
|
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": "WOW",
|
|
}
|
|
]
|
|
|
|
|
|
@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": "WOW",
|
|
"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": "WOW 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
|