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 import json
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import StreamingResponse from fastapi.responses import RedirectResponse, StreamingResponse
import requests import requests
from requests import RequestException from requests import RequestException
from sqlalchemy import func, or_ 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" 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: def _ensure_webhook_secret(db: Session, user: User) -> str:
if user.webhook_secret_encrypted: if user.webhook_secret_encrypted:
return decrypt_secret(user.webhook_secret_encrypted) return decrypt_secret(user.webhook_secret_encrypted)
@@ -288,6 +302,10 @@ def _public_invoice_status_response(
update["qr_logo"] = logo update["qr_logo"] = logo
if logo == "custom" and isinstance(logo_data_url, str): if logo == "custom" and isinstance(logo_data_url, str):
update["qr_logo_data_url"] = logo_data_url 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) update["qr_url"] = _qr_url(invoice, request)
return response.model_copy(update=update) return response.model_copy(update=update)
@@ -455,6 +473,36 @@ def get_invoice_status(
return _public_invoice_status_response(db, invoice, request) 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( @router.get(
"/api/core/public/donation/{invoice_id}", "/api/core/public/donation/{invoice_id}",
response_model=InvoiceStatusResponse, response_model=InvoiceStatusResponse,
@@ -933,6 +981,12 @@ def create_invoice_for_user(
metadata = {"qr": {"logo": user.default_qr_logo}} metadata = {"qr": {"logo": user.default_qr_logo}}
if user.default_qr_logo == "custom" and user.default_qr_logo_data_url: if user.default_qr_logo == "custom" and user.default_qr_logo_data_url:
metadata["qr"]["logo_data_url"] = 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) address, subaddress_index = create_subaddress_for_user(db, user=user)
invoice = Invoice( invoice = Invoice(
user_id=user.id, user_id=user.id,
+31
View File
@@ -26,6 +26,10 @@ class InvoiceCreate(BaseModel):
amount_fiat: Decimal | None = Field(default=None, gt=0) amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 2 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 metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field( expires_at: datetime | None = Field(
default=None, default=None,
@@ -49,6 +53,17 @@ class InvoiceCreate(BaseModel):
raise ValueError("currency is required when amount_fiat is provided") raise ValueError("currency is required when amount_fiat is provided")
return self 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): class DonationCreate(BaseModel):
amount_xmr: Decimal = Field(gt=0) amount_xmr: Decimal = Field(gt=0)
@@ -90,6 +105,10 @@ class InvoiceCreateUser(BaseModel):
amount_fiat: Decimal | None = Field(default=None, gt=0) amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 2 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 metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field( expires_at: datetime | None = Field(
default=None, default=None,
@@ -113,6 +132,17 @@ class InvoiceCreateUser(BaseModel):
raise ValueError("currency is required when amount_fiat is provided") raise ValueError("currency is required when amount_fiat is provided")
return self 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): class InvoiceListResponse(BaseModel):
items: list[InvoiceResponse] items: list[InvoiceResponse]
@@ -145,6 +175,7 @@ class InvoiceStatusResponse(BaseModel):
btcpay_redirect_automatically: bool | None = None btcpay_redirect_automatically: bool | None = None
btcpay_order_id: str | None = None btcpay_order_id: str | None = None
btcpay_order_number: str | None = None btcpay_order_number: str | None = None
checkout_continue_available: bool = False
quote: dict[str, Any] | None = None quote: dict[str, Any] | None = None
qr_url: str | None = None qr_url: str | None = None
+32
View File
@@ -125,6 +125,37 @@ export async function createInvoiceAction(
} }
const recipientName = String(formData.get("recipient_name") ?? "").trim(); const recipientName = String(formData.get("recipient_name") ?? "").trim();
const description = String(formData.get("description") ?? "").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 expiresAtRaw = String(formData.get("expires_at") ?? "").trim();
const expiresDate = String(formData.get("expires_date") ?? "").trim(); const expiresDate = String(formData.get("expires_date") ?? "").trim();
const expiresTime = String(formData.get("expires_time") ?? "").trim(); const expiresTime = String(formData.get("expires_time") ?? "").trim();
@@ -216,6 +247,7 @@ export async function createInvoiceAction(
currency: isFiat ? currency : null, currency: isFiat ? currency : null,
confirmation_target: confirmationTarget, confirmation_target: confirmationTarget,
expires_at: expiresAt, expires_at: expiresAt,
checkout_continue_url: checkoutContinueUrlRaw || null,
metadata: metadata:
Object.keys(metadata).length > 0 || qrPayload Object.keys(metadata).length > 0 || qrPayload
? { ? {
@@ -73,6 +73,11 @@ export default function IntegrationsPage() {
Create an invoice via{" "} Create an invoice via{" "}
<span className="font-mono text-ink">POST /api/core/invoices</span>. <span className="font-mono text-ink">POST /api/core/invoices</span>.
</li> </li>
<li>
Optional: include{" "}
<span className="font-mono text-ink">checkout_continue_url</span> to show a
customer-facing Continue button after confirmation.
</li>
<li> <li>
Redirect the customer to <span className="font-mono text-ink">invoice_url</span>. Redirect the customer to <span className="font-mono text-ink">invoice_url</span>.
</li> </li>
+18
View File
@@ -43,6 +43,11 @@ const endpointGroups = [
path: "/api/core/public/invoice/{invoice_id}", path: "/api/core/public/invoice/{invoice_id}",
description: "Read invoice status and payment details without auth.", 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: [], optional: [],
notes: ["Returns invoice status and payment details."], 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", method: "GET",
path: "/api/core/invoices", path: "/api/core/invoices",
@@ -352,6 +368,7 @@ const endpointRequirements = [
required: ["amount_xmr or amount_fiat + currency"], required: ["amount_xmr or amount_fiat + currency"],
optional: [ optional: [
"confirmation_target (default 2, 0-10)", "confirmation_target (default 2, 0-10)",
"checkout_continue_url (https URL, optional post-confirmation Continue button)",
"metadata", "metadata",
"expires_at (ISO 8601)", "expires_at (ISO 8601)",
], ],
@@ -614,6 +631,7 @@ Content-Type: application/json
"amount_fiat": "100.00", "amount_fiat": "100.00",
"currency": "USD", "currency": "USD",
"confirmation_target": 2, "confirmation_target": 2,
"checkout_continue_url": "https://merchant.example/thanks",
"metadata": { "order_id": "ORDER-1234" } "metadata": { "order_id": "ORDER-1234" }
}`}</code> }`}</code>
</pre> </pre>
@@ -29,6 +29,7 @@ type InvoiceStatusResponse = {
status: InvoiceStatus; status: InvoiceStatus;
confirmation_target: number; confirmation_target: number;
confirmations: number; confirmations: number;
checkout_continue_available?: boolean;
qr_logo?: "monero" | "none" | "custom" | null; qr_logo?: "monero" | "none" | "custom" | null;
qr_logo_data_url?: string | null; qr_logo_data_url?: string | null;
created_at: string; created_at: string;
@@ -162,6 +163,8 @@ export default async function InvoiceStatusDetailPage({
const isInvalid = invoice.status === "invalid"; const isInvalid = invoice.status === "invalid";
const hasDetectedPayment = const hasDetectedPayment =
invoice.status === "payment_detected" || invoice.status === "confirmed"; invoice.status === "payment_detected" || invoice.status === "confirmed";
const checkoutContinueAvailable =
invoice.status === "confirmed" && Boolean(invoice.checkout_continue_available);
const timelineItems: { const timelineItems: {
label: string; label: string;
timestamp: ReturnType<typeof formatTimestamp>; timestamp: ReturnType<typeof formatTimestamp>;
@@ -326,6 +329,15 @@ export default async function InvoiceStatusDetailPage({
</section> </section>
<section className="mt-6 grid gap-4"> <section className="mt-6 grid gap-4">
{checkoutContinueAvailable ? (
<a
className="inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href={`/api/core/public/invoice/${encodeURIComponent(invoiceId)}/continue`}
rel="noreferrer"
>
Continue to merchant
</a>
) : null}
<InvoiceStatusActions /> <InvoiceStatusActions />
</section> </section>
</main> </main>
+14
View File
@@ -30,6 +30,7 @@ type InvoiceStatusResponse = {
status: InvoiceStatus; status: InvoiceStatus;
confirmation_target: number; confirmation_target: number;
confirmations: number; confirmations: number;
checkout_continue_available?: boolean;
created_at: string; created_at: string;
expires_at: string | null; expires_at: string | null;
detected_at: string | null; detected_at: string | null;
@@ -149,6 +150,10 @@ export default async function BtcpayModalInvoicePage({
const useClassicCheckout = const useClassicCheckout =
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic"; isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed"; const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed";
const checkoutContinueAvailable =
!isBtcpayInvoice &&
invoice.status === "confirmed" &&
Boolean(invoice.checkout_continue_available);
return ( return (
<main className="min-h-screen bg-cream px-6 py-6 text-ink"> <main className="min-h-screen bg-cream px-6 py-6 text-ink">
@@ -241,6 +246,15 @@ export default async function BtcpayModalInvoicePage({
</p> </p>
</div> </div>
</div> </div>
{checkoutContinueAvailable ? (
<a
className="mt-5 inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href={`/api/core/public/invoice/${encodeURIComponent(invoiceId)}/continue`}
rel="noreferrer"
>
Continue to merchant
</a>
) : null}
</div> </div>
<div className="mt-5"> <div className="mt-5">
+16
View File
@@ -376,6 +376,22 @@ export default function CreateInvoiceCard() {
placeholder="Order 1042" placeholder="Order 1042"
/> />
</div> </div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="checkout_continue_url">
Continue URL (optional)
</label>
<input
className={inputClass}
id="checkout_continue_url"
name="checkout_continue_url"
type="url"
placeholder="https://merchant.example/thanks"
/>
<p className="text-sm text-ink-soft">
After confirmation, the hosted invoice page shows a &quot;Continue&quot; button
that navigates to this URL.
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<label className={labelClass} htmlFor="qr_color"> <label className={labelClass} htmlFor="qr_color">
QR accent color QR accent color
+27
View File
@@ -114,6 +114,7 @@ export default function InvoicePanel({
const [expiresTime, setExpiresTime] = useState(""); const [expiresTime, setExpiresTime] = useState("");
const [recipientName, setRecipientName] = useState(""); const [recipientName, setRecipientName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [checkoutContinueUrl, setCheckoutContinueUrl] = useState("");
const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState<string | null>(null); const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState(searchQuery ?? ""); const [searchInput, setSearchInput] = useState(searchQuery ?? "");
const [qrLogoMode, setQrLogoMode] = useState< const [qrLogoMode, setQrLogoMode] = useState<
@@ -260,6 +261,7 @@ export default function InvoicePanel({
setExpiresTime(""); setExpiresTime("");
setRecipientName(""); setRecipientName("");
setDescription(""); setDescription("");
setCheckoutContinueUrl("");
setQrLogoMode("account_default"); setQrLogoMode("account_default");
setQrLogoDataUrl(null); setQrLogoDataUrl(null);
}; };
@@ -1044,6 +1046,23 @@ export default function InvoicePanel({
onChange={(event) => setDescription(event.target.value)} onChange={(event) => setDescription(event.target.value)}
/> />
</div> </div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_checkout_continue_url">
Continue URL (optional)
</label>
<input
className={inputClass}
id="wizard_checkout_continue_url"
name="checkout_continue_url"
type="url"
placeholder="https://merchant.example/thanks"
value={checkoutContinueUrl}
onChange={(event) => setCheckoutContinueUrl(event.target.value)}
/>
<p className="text-sm text-ink-soft">
After confirmation, the hosted invoice page can show a Continue button.
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_qr_logo_mode"> <label className={labelClass} htmlFor="wizard_qr_logo_mode">
QR logo QR logo
@@ -1118,6 +1137,11 @@ export default function InvoicePanel({
<input type="hidden" name="expires_at" value={expiryValue} /> <input type="hidden" name="expires_at" value={expiryValue} />
<input type="hidden" name="recipient_name" value={recipientName} /> <input type="hidden" name="recipient_name" value={recipientName} />
<input type="hidden" name="description" value={description} /> <input type="hidden" name="description" value={description} />
<input
type="hidden"
name="checkout_continue_url"
value={checkoutContinueUrl}
/>
<input type="hidden" name="qr_logo_mode" value={qrLogoMode} /> <input type="hidden" name="qr_logo_mode" value={qrLogoMode} />
<input <input
type="hidden" type="hidden"
@@ -1177,6 +1201,9 @@ export default function InvoicePanel({
<p className="mt-2 text-sm text-ink"> <p className="mt-2 text-sm text-ink">
Description: <strong>{description || "-"}</strong> Description: <strong>{description || "-"}</strong>
</p> </p>
<p className="mt-2 text-sm text-ink">
Continue URL: <strong>{checkoutContinueUrl || "-"}</strong>
</p>
</div> </div>
</div> </div>
) : null} ) : null}