Add optional post-confirmation continue URL

This commit is contained in:
xmrcheckout
2026-01-08 16:43:11 +00:00
parent 6eb8d381c9
commit 6a3156991a
9 changed files with 210 additions and 1 deletions
+55 -1
View File
@@ -7,7 +7,7 @@ import io
import json
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import StreamingResponse
from fastapi.responses import RedirectResponse, StreamingResponse
import requests
from requests import RequestException
from sqlalchemy import func, or_
@@ -131,6 +131,20 @@ def _is_donation_invoice(invoice: Invoice) -> bool:
return isinstance(metadata, dict) and metadata.get("origin") == "donation"
def _get_checkout_continue_url(invoice: Invoice) -> str | None:
metadata = invoice.metadata_json or {}
if not isinstance(metadata, dict):
return None
checkout = metadata.get("checkout")
if not isinstance(checkout, dict):
return None
value = checkout.get("continue_url")
if not isinstance(value, str):
return None
trimmed = value.strip()
return trimmed or None
def _ensure_webhook_secret(db: Session, user: User) -> str:
if user.webhook_secret_encrypted:
return decrypt_secret(user.webhook_secret_encrypted)
@@ -288,6 +302,10 @@ def _public_invoice_status_response(
update["qr_logo"] = logo
if logo == "custom" and isinstance(logo_data_url, str):
update["qr_logo_data_url"] = logo_data_url
continue_url = _get_checkout_continue_url(invoice)
update["checkout_continue_available"] = bool(
invoice.status == "confirmed" and continue_url
)
update["qr_url"] = _qr_url(invoice, request)
return response.model_copy(update=update)
@@ -455,6 +473,36 @@ def get_invoice_status(
return _public_invoice_status_response(db, invoice, request)
@router.get(
"/api/core/public/invoice/{invoice_id}/continue",
response_class=RedirectResponse,
)
def continue_invoice_after_confirmation(
invoice_id: uuid.UUID,
db: Session = Depends(get_db),
):
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if invoice is None or _is_donation_invoice(invoice):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invoice not found",
)
if invoice.status != "confirmed":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Continue link not available",
)
continue_url = _get_checkout_continue_url(invoice)
if not continue_url:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Continue link not available",
)
response = RedirectResponse(url=continue_url, status_code=status.HTTP_302_FOUND)
response.headers["Cache-Control"] = "no-store"
return response
@router.get(
"/api/core/public/donation/{invoice_id}",
response_model=InvoiceStatusResponse,
@@ -933,6 +981,12 @@ def create_invoice_for_user(
metadata = {"qr": {"logo": user.default_qr_logo}}
if user.default_qr_logo == "custom" and user.default_qr_logo_data_url:
metadata["qr"]["logo_data_url"] = user.default_qr_logo_data_url
if payload.checkout_continue_url is not None:
checkout = metadata.get("checkout") if isinstance(metadata, dict) else None
if not isinstance(checkout, dict):
checkout = {}
checkout = {**checkout, "continue_url": str(payload.checkout_continue_url)}
metadata = {**(metadata if isinstance(metadata, dict) else {}), "checkout": checkout}
address, subaddress_index = create_subaddress_for_user(db, user=user)
invoice = Invoice(
user_id=user.id,
+31
View File
@@ -26,6 +26,10 @@ class InvoiceCreate(BaseModel):
amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 2
checkout_continue_url: AnyUrl | None = Field(
default=None,
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
)
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
@@ -49,6 +53,17 @@ class InvoiceCreate(BaseModel):
raise ValueError("currency is required when amount_fiat is provided")
return self
@field_validator("checkout_continue_url")
@classmethod
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
if value is None:
return None
if value.scheme == "https":
return value
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
return value
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
class DonationCreate(BaseModel):
amount_xmr: Decimal = Field(gt=0)
@@ -90,6 +105,10 @@ class InvoiceCreateUser(BaseModel):
amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 2
checkout_continue_url: AnyUrl | None = Field(
default=None,
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
)
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
@@ -113,6 +132,17 @@ class InvoiceCreateUser(BaseModel):
raise ValueError("currency is required when amount_fiat is provided")
return self
@field_validator("checkout_continue_url")
@classmethod
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
if value is None:
return None
if value.scheme == "https":
return value
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
return value
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
class InvoiceListResponse(BaseModel):
items: list[InvoiceResponse]
@@ -145,6 +175,7 @@ class InvoiceStatusResponse(BaseModel):
btcpay_redirect_automatically: bool | None = None
btcpay_order_id: str | None = None
btcpay_order_number: str | None = None
checkout_continue_available: bool = False
quote: dict[str, Any] | None = None
qr_url: str | None = None