From 6a3156991a83bf4bf3d7dc31ccf034cc227456b8 Mon Sep 17 00:00:00 2001 From: xmrcheckout Date: Thu, 8 Jan 2026 12:57:30 +0000 Subject: [PATCH] Add optional post-confirmation continue URL --- api/app/routes.py | 56 ++++++++++++++++++- api/app/schemas.py | 31 ++++++++++ ui/app/(app)/dashboard/actions.ts | 32 +++++++++++ ui/app/(marketing)/docs/integrations/page.tsx | 5 ++ ui/app/(marketing)/docs/page.tsx | 18 ++++++ .../(marketing)/invoice/[invoiceId]/page.tsx | 12 ++++ ui/app/i/[invoiceId]/page.tsx | 14 +++++ ui/components/create-invoice-card.tsx | 16 ++++++ ui/components/invoice-panel.tsx | 27 +++++++++ 9 files changed, 210 insertions(+), 1 deletion(-) 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. +
  • + Optional: include{" "} + checkout_continue_url to show a + customer-facing Continue button after confirmation. +
  • Redirect the customer to invoice_url.
  • diff --git a/ui/app/(marketing)/docs/page.tsx b/ui/app/(marketing)/docs/page.tsx index 28682f9..2e0aeff 100644 --- a/ui/app/(marketing)/docs/page.tsx +++ b/ui/app/(marketing)/docs/page.tsx @@ -43,6 +43,11 @@ const endpointGroups = [ path: "/api/core/public/invoice/{invoice_id}", description: "Read invoice status and payment details without auth.", }, + { + method: "GET", + path: "/api/core/public/invoice/{invoice_id}/continue", + description: "After confirmation, redirect to the merchant-provided Continue URL (if configured).", + }, ], }, { @@ -241,6 +246,17 @@ const endpointRequirements = [ optional: [], notes: ["Returns invoice status and payment details."], }, + { + method: "GET", + path: "/api/core/public/invoice/{invoice_id}/continue", + auth: "None", + required: ["invoice_id (path)"], + optional: [], + notes: [ + "Only available after an invoice is confirmed.", + "Returns a 302 redirect to the merchant-provided checkout_continue_url (if configured).", + ], + }, { method: "GET", path: "/api/core/invoices", @@ -352,6 +368,7 @@ const endpointRequirements = [ required: ["amount_xmr or amount_fiat + currency"], optional: [ "confirmation_target (default 2, 0-10)", + "checkout_continue_url (https URL, optional post-confirmation Continue button)", "metadata", "expires_at (ISO 8601)", ], @@ -614,6 +631,7 @@ Content-Type: application/json "amount_fiat": "100.00", "currency": "USD", "confirmation_target": 2, + "checkout_continue_url": "https://merchant.example/thanks", "metadata": { "order_id": "ORDER-1234" } }`} diff --git a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx index d1a09ac..c67cebe 100644 --- a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx +++ b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx @@ -29,6 +29,7 @@ type InvoiceStatusResponse = { status: InvoiceStatus; confirmation_target: number; confirmations: number; + checkout_continue_available?: boolean; qr_logo?: "monero" | "none" | "custom" | null; qr_logo_data_url?: string | null; created_at: string; @@ -162,6 +163,8 @@ export default async function InvoiceStatusDetailPage({ const isInvalid = invoice.status === "invalid"; const hasDetectedPayment = invoice.status === "payment_detected" || invoice.status === "confirmed"; + const checkoutContinueAvailable = + invoice.status === "confirmed" && Boolean(invoice.checkout_continue_available); const timelineItems: { label: string; timestamp: ReturnType; @@ -326,6 +329,15 @@ export default async function InvoiceStatusDetailPage({
    + {checkoutContinueAvailable ? ( + + Continue to merchant + + ) : null}
    diff --git a/ui/app/i/[invoiceId]/page.tsx b/ui/app/i/[invoiceId]/page.tsx index 9d2be33..a5a6016 100644 --- a/ui/app/i/[invoiceId]/page.tsx +++ b/ui/app/i/[invoiceId]/page.tsx @@ -30,6 +30,7 @@ type InvoiceStatusResponse = { status: InvoiceStatus; confirmation_target: number; confirmations: number; + checkout_continue_available?: boolean; created_at: string; expires_at: string | null; detected_at: string | null; @@ -149,6 +150,10 @@ export default async function BtcpayModalInvoicePage({ const useClassicCheckout = isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic"; const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed"; + const checkoutContinueAvailable = + !isBtcpayInvoice && + invoice.status === "confirmed" && + Boolean(invoice.checkout_continue_available); return (
    @@ -241,6 +246,15 @@ export default async function BtcpayModalInvoicePage({

    + {checkoutContinueAvailable ? ( + + Continue to merchant + + ) : null}
    diff --git a/ui/components/create-invoice-card.tsx b/ui/components/create-invoice-card.tsx index 19a011f..71f29dd 100644 --- a/ui/components/create-invoice-card.tsx +++ b/ui/components/create-invoice-card.tsx @@ -376,6 +376,22 @@ export default function CreateInvoiceCard() { placeholder="Order 1042" />
    +
    + + +

    + After confirmation, the hosted invoice page shows a "Continue" button + that navigates to this URL. +

    +
    +
    + + setCheckoutContinueUrl(event.target.value)} + /> +

    + After confirmation, the hosted invoice page can show a Continue button. +

    +
    ) : null}