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 ? ( +
+ + View receipt + + {invoice.btcpay_redirect_url ? ( + + Return to store + + ) : null} +
+ ) : 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 + Payment QR + ) : 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 paid +

+
+ +
+
+
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
+
+
+ +
+ + View receipt + + {redirectUrl ? ( + + {formatReturnLabel(redirectUrl)} + + ) : null} +
+
+ ); + } + 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; }