+ After confirmation, the hosted invoice page shows a "Continue" button + that navigates to this URL. +
++ After confirmation, the hosted invoice page can show a Continue button. +
+diff --git a/api/app/routes.py b/api/app/routes.py index cdbf088..deb0dad 100644 --- a/api/app/routes.py +++ b/api/app/routes.py @@ -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, diff --git a/api/app/schemas.py b/api/app/schemas.py index 85b6237..56aeb00 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -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 diff --git a/ui/app/(app)/dashboard/actions.ts b/ui/app/(app)/dashboard/actions.ts index dabcf1f..b967da3 100644 --- a/ui/app/(app)/dashboard/actions.ts +++ b/ui/app/(app)/dashboard/actions.ts @@ -125,6 +125,37 @@ export async function createInvoiceAction( } const recipientName = String(formData.get("recipient_name") ?? "").trim(); const description = String(formData.get("description") ?? "").trim(); + const checkoutContinueUrlRaw = String(formData.get("checkout_continue_url") ?? "").trim(); + if (checkoutContinueUrlRaw) { + try { + const parsed = new URL(checkoutContinueUrlRaw); + const isLocalhost = + parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) { + return { + error: "Continue URL must use https (http is allowed for localhost).", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + } catch { + return { + error: "Continue URL must be a valid URL.", + invoiceId: null, + address: null, + amount: null, + recipientName: null, + description: null, + subaddressIndex: null, + warnings: null, + }; + } + } const expiresAtRaw = String(formData.get("expires_at") ?? "").trim(); const expiresDate = String(formData.get("expires_date") ?? "").trim(); const expiresTime = String(formData.get("expires_time") ?? "").trim(); @@ -216,6 +247,7 @@ export async function createInvoiceAction( currency: isFiat ? currency : null, confirmation_target: confirmationTarget, expires_at: expiresAt, + checkout_continue_url: checkoutContinueUrlRaw || null, metadata: Object.keys(metadata).length > 0 || qrPayload ? { diff --git a/ui/app/(marketing)/docs/integrations/page.tsx b/ui/app/(marketing)/docs/integrations/page.tsx index d01b54e..efad0a4 100644 --- a/ui/app/(marketing)/docs/integrations/page.tsx +++ b/ui/app/(marketing)/docs/integrations/page.tsx @@ -73,6 +73,11 @@ export default function IntegrationsPage() { Create an invoice via{" "} POST /api/core/invoices. +
+ After confirmation, the hosted invoice page shows a "Continue" button + that navigates to this URL. +
++ After confirmation, the hosted invoice page can show a Continue button. +
+