From 6eb8d381c9ecadd1a44d68151b21cbafcdee5a74 Mon Sep 17 00:00:00 2001
From: xmrcheckout
Date: Thu, 8 Jan 2026 11:27:31 +0000
Subject: [PATCH] BTCPay: add paid/receipt flow and redirect support
---
api/app/main.py | 21 +-
api/app/models.py | 2 +-
api/app/qr_codes.py | 12 +
api/app/routes.py | 28 ++
api/app/schemas.py | 7 +-
ui/app/(app)/dashboard/page.tsx | 2 +-
ui/app/i/[invoiceId]/page.tsx | 37 ++-
ui/app/i/[invoiceId]/receipt/page.tsx | 253 ++++++++++++++++++
.../btcpay-checkout-style-section.tsx | 2 +-
ui/components/btcpay-classic-checkout.tsx | 121 +++++++++
ui/public/modal/btcpay.js | 2 +-
11 files changed, 475 insertions(+), 12 deletions(-)
create mode 100644 ui/app/i/[invoiceId]/receipt/page.tsx
diff --git a/api/app/main.py b/api/app/main.py
index 2c3e829..7779f79 100644
--- a/api/app/main.py
+++ b/api/app/main.py
@@ -201,7 +201,13 @@ def startup():
connection.execute(
text(
"ALTER TABLE users "
- "ADD COLUMN IF NOT EXISTS btcpay_checkout_style VARCHAR DEFAULT 'standard'"
+ "ADD COLUMN IF NOT EXISTS btcpay_checkout_style VARCHAR DEFAULT 'btcpay_classic'"
+ )
+ )
+ connection.execute(
+ text(
+ "ALTER TABLE users "
+ "ALTER COLUMN btcpay_checkout_style SET DEFAULT 'btcpay_classic'"
)
)
connection.execute(
@@ -212,10 +218,21 @@ def startup():
)
connection.execute(
text(
- "UPDATE users SET btcpay_checkout_style = 'standard' "
+ "UPDATE users SET btcpay_checkout_style = 'btcpay_classic' "
"WHERE btcpay_checkout_style IS NULL"
)
)
+ connection.execute(
+ text(
+ "UPDATE users "
+ "SET btcpay_checkout_style = 'btcpay_classic' "
+ "WHERE btcpay_checkout_style = 'standard' "
+ "AND NOT EXISTS ("
+ " SELECT 1 FROM profile_history ph "
+ " WHERE ph.user_id = users.id AND ph.field_name = 'btcpay_checkout_style'"
+ ")"
+ )
+ )
connection.execute(
text(
"UPDATE users SET default_confirmation_target = 2 "
diff --git a/api/app/models.py b/api/app/models.py
index 86cb3f0..6f3d1b4 100644
--- a/api/app/models.py
+++ b/api/app/models.py
@@ -128,7 +128,7 @@ class User(Base):
default_confirmation_target = Column(Integer, nullable=False, server_default="2")
default_qr_logo = Column(String, nullable=False, server_default="monero")
default_qr_logo_data_url = Column(String, nullable=True)
- btcpay_checkout_style = Column(String, nullable=False, server_default="standard")
+ btcpay_checkout_style = Column(String, nullable=False, server_default="btcpay_classic")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
diff --git a/api/app/qr_codes.py b/api/app/qr_codes.py
index e46e335..8adc747 100644
--- a/api/app/qr_codes.py
+++ b/api/app/qr_codes.py
@@ -37,12 +37,24 @@ def ensure_invoice_qr_png(
detail="QR storage is not configured",
)
os.makedirs(storage_dir, exist_ok=True)
+ try:
+ os.chmod(storage_dir, 0o755)
+ except Exception:
+ pass
filename = f"{invoice.id}.png"
path = os.path.join(storage_dir, filename)
if os.path.exists(path):
+ try:
+ os.chmod(path, 0o644)
+ except Exception:
+ pass
return path
png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings)
_atomic_write(path, png_bytes)
+ try:
+ os.chmod(path, 0o644)
+ except Exception:
+ pass
return path
diff --git a/api/app/routes.py b/api/app/routes.py
index 84ae959..cdbf088 100644
--- a/api/app/routes.py
+++ b/api/app/routes.py
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_DOWN
import csv
import io
+import json
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import StreamingResponse
@@ -239,6 +240,11 @@ def _public_invoice_status_response(
response = InvoiceStatusResponse.model_validate(invoice)
metadata = invoice.metadata_json or {}
update: dict[str, Any] = {}
+ if invoice.total_paid_atomic is not None:
+ total_paid = (Decimal(invoice.total_paid_atomic) / Decimal("1000000000000")).quantize(
+ Decimal("0.000000000001"), rounding=ROUND_DOWN
+ )
+ update["amount_paid_xmr"] = format_xmr_amount(total_paid)
btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None
if isinstance(btcpay_data, dict):
amount = btcpay_data.get("amount")
@@ -250,8 +256,30 @@ def _public_invoice_status_response(
user = db.query(User).filter(User.id == invoice.user_id).first()
if user and user.btcpay_checkout_style:
update["btcpay_checkout_style"] = user.btcpay_checkout_style
+ checkout = btcpay_data.get("checkout")
+ if isinstance(checkout, dict):
+ redirect_url = checkout.get("redirectURL")
+ if isinstance(redirect_url, str) and redirect_url.strip():
+ update["btcpay_redirect_url"] = redirect_url.strip()
+ redirect_auto = checkout.get("redirectAutomatically")
+ if isinstance(redirect_auto, bool):
+ update["btcpay_redirect_automatically"] = redirect_auto
if isinstance(metadata, dict) and isinstance(metadata.get("quote"), dict):
update["quote"] = metadata.get("quote")
+ if isinstance(metadata, dict) and isinstance(metadata.get("posData"), str):
+ try:
+ pos_data = json.loads(metadata.get("posData", ""))
+ except (TypeError, ValueError, json.JSONDecodeError):
+ pos_data = None
+ if isinstance(pos_data, dict):
+ woo = pos_data.get("WooCommerce")
+ if isinstance(woo, dict):
+ order_id = woo.get("Order ID")
+ order_number = woo.get("Order Number")
+ if order_id is not None:
+ update["btcpay_order_id"] = str(order_id)
+ if order_number is not None:
+ update["btcpay_order_number"] = str(order_number)
qr = metadata.get("qr") if isinstance(metadata, dict) else None
if isinstance(qr, dict):
logo = qr.get("logo")
diff --git a/api/app/schemas.py b/api/app/schemas.py
index 5d95c3d..85b6237 100644
--- a/api/app/schemas.py
+++ b/api/app/schemas.py
@@ -126,6 +126,7 @@ class InvoiceStatusResponse(BaseModel):
address: str
subaddress_index: int | None = None
amount_xmr: Decimal
+ amount_paid_xmr: str | None = None
status: InvoiceStatus
confirmation_target: int
confirmations: int = 0
@@ -140,6 +141,10 @@ class InvoiceStatusResponse(BaseModel):
btcpay_amount: str | None = None
btcpay_currency: str | None = None
btcpay_checkout_style: BtcpayCheckoutStyle | None = None
+ btcpay_redirect_url: str | None = None
+ btcpay_redirect_automatically: bool | None = None
+ btcpay_order_id: str | None = None
+ btcpay_order_number: str | None = None
quote: dict[str, Any] | None = None
qr_url: str | None = None
@@ -156,7 +161,7 @@ class ProfileResponse(BaseModel):
default_confirmation_target: conint(ge=0, le=10) = 2
default_qr_logo: QrLogoMode = "monero"
default_qr_logo_data_url: str | None = None
- btcpay_checkout_style: BtcpayCheckoutStyle = "standard"
+ btcpay_checkout_style: BtcpayCheckoutStyle = "btcpay_classic"
created_at: datetime
diff --git a/ui/app/(app)/dashboard/page.tsx b/ui/app/(app)/dashboard/page.tsx
index 7a5d8cd..135f047 100644
--- a/ui/app/(app)/dashboard/page.tsx
+++ b/ui/app/(app)/dashboard/page.tsx
@@ -411,7 +411,7 @@ export default async function DashboardPage({
initialStyle={
profileData?.btcpay_checkout_style === "btcpay_classic"
? "btcpay_classic"
- : "standard"
+ : "btcpay_classic"
}
/>
diff --git a/ui/app/i/[invoiceId]/page.tsx b/ui/app/i/[invoiceId]/page.tsx
index cd4fe05..9d2be33 100644
--- a/ui/app/i/[invoiceId]/page.tsx
+++ b/ui/app/i/[invoiceId]/page.tsx
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import BtcpayModalBridge from "../../../components/btcpay-modal-bridge";
-import BtcpayModalCloseButton from "../../../components/btcpay-modal-close-button";
import BtcpayClassicCheckout from "../../../components/btcpay-classic-checkout";
import InvoicePaymentDetails from "../../../components/invoice-payment-details";
import InvoiceStatusAutoRefresh from "../../../components/invoice-status-auto-refresh";
@@ -27,6 +26,7 @@ type InvoiceStatusResponse = {
address: string;
subaddress_index: number | null;
amount_xmr: string;
+ amount_paid_xmr?: string | null;
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
@@ -37,6 +37,10 @@ type InvoiceStatusResponse = {
btcpay_amount?: string | null;
btcpay_currency?: string | null;
btcpay_checkout_style?: "standard" | "btcpay_classic" | null;
+ btcpay_redirect_url?: string | null;
+ btcpay_redirect_automatically?: boolean | null;
+ btcpay_order_id?: string | null;
+ btcpay_order_number?: string | null;
qr_logo?: "monero" | "none" | "custom" | null;
qr_logo_data_url?: string | null;
quote?: {
@@ -104,7 +108,6 @@ export default async function BtcpayModalInvoicePage({
Invoice not found
-
The invoice id does not match a known invoice. Check the id and try
@@ -125,7 +128,6 @@ export default async function BtcpayModalInvoicePage({
Status unavailable
-
We could not load this invoice status. Refresh the page or try again
@@ -146,6 +148,7 @@ export default async function BtcpayModalInvoicePage({
const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency);
const useClassicCheckout =
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
+ const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed";
return (
@@ -161,17 +164,22 @@ export default async function BtcpayModalInvoicePage({
-
@@ -187,7 +195,6 @@ export default async function BtcpayModalInvoicePage({
-
@@ -247,6 +254,26 @@ export default async function BtcpayModalInvoicePage({
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
/>
+ {shouldShowBtcpayActions ? (
+
+ ) : null}
>
)}
diff --git a/ui/app/i/[invoiceId]/receipt/page.tsx b/ui/app/i/[invoiceId]/receipt/page.tsx
new file mode 100644
index 0000000..7c5154d
--- /dev/null
+++ b/ui/app/i/[invoiceId]/receipt/page.tsx
@@ -0,0 +1,253 @@
+import type { Metadata } from "next";
+import { headers } from "next/headers";
+
+import BtcpayModalBridge from "../../../../components/btcpay-modal-bridge";
+import { formatRelativeTime } from "../../../../components/relative-time";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = {
+ title: "Receipt",
+};
+
+const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
+
+type InvoiceStatus =
+ | "pending"
+ | "payment_detected"
+ | "confirmed"
+ | "expired"
+ | "invalid";
+
+type InvoiceStatusResponse = {
+ id: string;
+ address: string;
+ amount_xmr: string;
+ amount_paid_xmr?: string | null;
+ status: InvoiceStatus;
+ confirmation_target: number;
+ confirmations: number;
+ created_at: string;
+ confirmed_at: string | null;
+ btcpay_amount?: string | null;
+ btcpay_currency?: string | null;
+ btcpay_redirect_url?: string | null;
+ btcpay_order_id?: string | null;
+ btcpay_order_number?: string | null;
+ quote?: {
+ fiat_amount: string;
+ fiat_currency: string;
+ rate: string;
+ source: string;
+ quoted_at: string;
+ } | null;
+ qr_url?: string | null;
+};
+
+const formatFiatAmount = (value: string, currency: string) => {
+ const amount = Number.parseFloat(value);
+ if (!Number.isFinite(amount)) {
+ return `${value} ${currency}`;
+ }
+ try {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency,
+ maximumFractionDigits: 2,
+ minimumFractionDigits: 2,
+ }).format(amount);
+ } catch {
+ return `${amount.toFixed(2)} ${currency}`;
+ }
+};
+
+const receiptTitle = (redirectUrl: string | null | undefined) => {
+ if (!redirectUrl) {
+ return "Receipt";
+ }
+ try {
+ const parsed = new URL(redirectUrl);
+ return parsed.hostname ? `${parsed.hostname}` : "Receipt";
+ } catch {
+ return "Receipt";
+ }
+};
+
+const resolveQrSrc = (invoiceId: string, qrUrl: string | null | undefined) => {
+ if (!qrUrl) {
+ return `/qr/${encodeURIComponent(invoiceId)}.png`;
+ }
+ if (qrUrl.startsWith("/qr/")) {
+ return qrUrl;
+ }
+ try {
+ const parsed = new URL(qrUrl);
+ if (parsed.pathname.startsWith("/qr/")) {
+ return parsed.pathname;
+ }
+ } catch {
+ // Ignore malformed URLs and fall back to the provided value.
+ }
+ return qrUrl;
+};
+
+const closeLinkClassName =
+ "rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white";
+
+export default async function BtcpayReceiptPage({
+ params,
+}: {
+ params: Promise<{ invoiceId: string }>;
+}) {
+ const { invoiceId } = await params;
+ const requestHeaders = await headers();
+ const forwardedProto = requestHeaders.get("x-forwarded-proto");
+ const forwardedHost = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
+ const response = await fetch(
+ `${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
+ {
+ cache: "no-store",
+ headers: {
+ ...(forwardedProto ? { "x-forwarded-proto": forwardedProto } : {}),
+ ...(forwardedHost ? { "x-forwarded-host": forwardedHost } : {}),
+ },
+ }
+ );
+
+ if (response.status === 404) {
+ return (
+
+
+
+
+
+
Invoice not found
+
+ The invoice id does not match a known invoice. Check the id and try again.
+
+
+
+
+ );
+ }
+
+ if (!response.ok) {
+ return (
+
+
+
+
+
+
Status unavailable
+
+ We could not load this invoice status. Refresh the page or try again later.
+
+
+
+
+ );
+ }
+
+ const invoice = (await response.json()) as InvoiceStatusResponse;
+ const orderId = invoice.btcpay_order_number ?? invoice.btcpay_order_id ?? null;
+ const receiptTimestamp = invoice.confirmed_at ?? invoice.created_at;
+ const timestampLabel = new Date(receiptTimestamp).toLocaleString();
+ const timestampRelative = formatRelativeTime(receiptTimestamp);
+ const totalFiat =
+ invoice.btcpay_amount && invoice.btcpay_currency && invoice.btcpay_currency !== "XMR"
+ ? formatFiatAmount(invoice.btcpay_amount, invoice.btcpay_currency)
+ : invoice.btcpay_amount && invoice.btcpay_currency
+ ? `${invoice.btcpay_amount} ${invoice.btcpay_currency}`
+ : null;
+ const exchangeRate =
+ invoice.quote?.rate && invoice.quote.fiat_currency
+ ? formatFiatAmount(invoice.quote.rate, invoice.quote.fiat_currency)
+ : null;
+ const paidXmr = invoice.amount_paid_xmr ?? invoice.amount_xmr;
+ const qrSrc = resolveQrSrc(invoiceId, invoice.qr_url);
+ const storeTitle = receiptTitle(invoice.btcpay_redirect_url);
+
+ return (
+
+
+
+
+
+ {invoice.status !== "confirmed" ? (
+
+
{storeTitle}
+ {orderId ?
Order ID: {orderId}
: null}
+
+ {timestampLabel}
+
+
+ This receipt is available after the payment is confirmed.
+
+
+ ) : (
+ <>
+
+
{storeTitle}
+ {orderId ?
Order ID: {orderId}
: null}
+
+ {timestampLabel}
+
+
+
+
+
+
+
- Total
+ - {totalFiat ?? "-"}
+
+
+
- Paid
+
-
+
{paidXmr} XMR
+ {totalFiat ? {totalFiat}
: null}
+
+
+
+
- Rate
+ - {exchangeRate ?? "-"}
+
+
+
- Destination
+ -
+ {invoice.address}
+
+
+
+
+
+
+ {qrSrc ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : null}
+
+
+
+ Powered by XMR Checkout
+
+ >
+ )}
+
+
+ );
+}
diff --git a/ui/components/btcpay-checkout-style-section.tsx b/ui/components/btcpay-checkout-style-section.tsx
index e5a0b91..a88fb98 100644
--- a/ui/components/btcpay-checkout-style-section.tsx
+++ b/ui/components/btcpay-checkout-style-section.tsx
@@ -63,7 +63,7 @@ export default function BtcpayCheckoutStyleSection({
}
>
-
+
diff --git a/ui/components/btcpay-classic-checkout.tsx b/ui/components/btcpay-classic-checkout.tsx
index 77e7d30..1947222 100644
--- a/ui/components/btcpay-classic-checkout.tsx
+++ b/ui/components/btcpay-classic-checkout.tsx
@@ -22,13 +22,19 @@ type QuotePayload = {
};
type BtcpayClassicCheckoutProps = {
+ invoiceId: string;
address: string;
amountXmr: string;
+ amountPaidXmr: string | null;
btcpayAmount: string | null;
btcpayCurrency: string | null;
quote: QuotePayload | null;
status: InvoiceStatus;
confirmationTarget: number;
+ redirectUrl: string | null;
+ redirectAutomatically: boolean | null;
+ orderId: string | null;
+ orderNumber: string | null;
qrLogoMode?: "monero" | "none" | "custom" | null;
qrLogoDataUrl?: string | null;
};
@@ -56,6 +62,15 @@ const formatFiatAmount = (value: string, currency: string) => {
}
};
+const formatReturnLabel = (redirectUrl: string) => {
+ try {
+ const parsed = new URL(redirectUrl);
+ return parsed.hostname ? `Return to ${parsed.hostname}` : "Return to store";
+ } catch {
+ return "Return to store";
+ }
+};
+
const statusMessage = (
status: InvoiceStatus,
confirmationTarget: number
@@ -76,19 +91,31 @@ const statusMessage = (
};
export default function BtcpayClassicCheckout({
+ invoiceId,
address,
amountXmr,
+ amountPaidXmr,
btcpayAmount,
btcpayCurrency,
quote,
status,
confirmationTarget,
+ redirectUrl,
+ redirectAutomatically,
+ orderId,
+ orderNumber,
qrLogoMode,
qrLogoDataUrl,
}: BtcpayClassicCheckoutProps) {
const [qrDataUrl, setQrDataUrl] = useState(null);
const [copiedField, setCopiedField] = useState(null);
const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]);
+ const formattedPaidAmount = useMemo(() => {
+ if (!amountPaidXmr) {
+ return formattedAmount;
+ }
+ return formatXmrAmount(amountPaidXmr);
+ }, [amountPaidXmr, formattedAmount]);
const uri = useMemo(
() => buildMoneroUri(address, formattedAmount),
[address, formattedAmount]
@@ -102,6 +129,22 @@ export default function BtcpayClassicCheckout({
? `1 XMR = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
: null;
+ useEffect(() => {
+ if (status !== "confirmed" || !redirectUrl || !redirectAutomatically) {
+ return;
+ }
+ const timer = window.setTimeout(() => {
+ try {
+ window.top?.location.assign(redirectUrl);
+ } catch {
+ window.location.assign(redirectUrl);
+ }
+ }, 3000);
+ return () => {
+ window.clearTimeout(timer);
+ };
+ }, [redirectAutomatically, redirectUrl, status]);
+
const handleCopy = async (value: string, field: string) => {
try {
await navigator.clipboard.writeText(value);
@@ -162,6 +205,84 @@ export default function BtcpayClassicCheckout({
? "/monero-logo.svg"
: null;
+ if (status === "confirmed") {
+ return (
+
+
+
+
+
+
- Invoice id
+ - {invoiceId}
+
+ {orderNumber || orderId ? (
+
+
- Order id
+ - {orderNumber ?? orderId}
+
+ ) : null}
+
+
- Total price
+ - {formattedAmount} XMR
+
+
+
- Total fiat
+ - {totalFiat ?? "-"}
+
+
+
- Exchange rate
+ - {exchangeRate ?? "-"}
+
+
+
- Network cost
+ - Added by wallet
+
+
+
- Amount paid
+ - {formattedPaidAmount} XMR
+
+
+
+
+
+ );
+ }
+
return (
diff --git a/ui/public/modal/btcpay.js b/ui/public/modal/btcpay.js
index f2eeb8b..47cd64b 100644
--- a/ui/public/modal/btcpay.js
+++ b/ui/public/modal/btcpay.js
@@ -119,7 +119,7 @@
}
state.handlers.forEach(function (handler) {
try {
- handler(event.data);
+ handler(event);
} catch (error) {
return;
}