mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
BTCPay: add paid/receipt flow and redirect support
This commit is contained in:
+19
-2
@@ -201,7 +201,13 @@ def startup():
|
|||||||
connection.execute(
|
connection.execute(
|
||||||
text(
|
text(
|
||||||
"ALTER TABLE users "
|
"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(
|
connection.execute(
|
||||||
@@ -212,10 +218,21 @@ def startup():
|
|||||||
)
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE users SET btcpay_checkout_style = 'standard' "
|
"UPDATE users SET btcpay_checkout_style = 'btcpay_classic' "
|
||||||
"WHERE btcpay_checkout_style IS NULL"
|
"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(
|
connection.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE users SET default_confirmation_target = 2 "
|
"UPDATE users SET default_confirmation_target = 2 "
|
||||||
|
|||||||
+1
-1
@@ -128,7 +128,7 @@ class User(Base):
|
|||||||
default_confirmation_target = Column(Integer, nullable=False, server_default="2")
|
default_confirmation_target = Column(Integer, nullable=False, server_default="2")
|
||||||
default_qr_logo = Column(String, nullable=False, server_default="monero")
|
default_qr_logo = Column(String, nullable=False, server_default="monero")
|
||||||
default_qr_logo_data_url = Column(String, nullable=True)
|
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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,24 @@ def ensure_invoice_qr_png(
|
|||||||
detail="QR storage is not configured",
|
detail="QR storage is not configured",
|
||||||
)
|
)
|
||||||
os.makedirs(storage_dir, exist_ok=True)
|
os.makedirs(storage_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.chmod(storage_dir, 0o755)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
filename = f"{invoice.id}.png"
|
filename = f"{invoice.id}.png"
|
||||||
path = os.path.join(storage_dir, filename)
|
path = os.path.join(storage_dir, filename)
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return path
|
return path
|
||||||
png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings)
|
png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings)
|
||||||
_atomic_write(path, png_bytes)
|
_atomic_write(path, png_bytes)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from decimal import Decimal, ROUND_DOWN
|
from decimal import Decimal, ROUND_DOWN
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
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 StreamingResponse
|
||||||
@@ -239,6 +240,11 @@ def _public_invoice_status_response(
|
|||||||
response = InvoiceStatusResponse.model_validate(invoice)
|
response = InvoiceStatusResponse.model_validate(invoice)
|
||||||
metadata = invoice.metadata_json or {}
|
metadata = invoice.metadata_json or {}
|
||||||
update: dict[str, Any] = {}
|
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
|
btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None
|
||||||
if isinstance(btcpay_data, dict):
|
if isinstance(btcpay_data, dict):
|
||||||
amount = btcpay_data.get("amount")
|
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()
|
user = db.query(User).filter(User.id == invoice.user_id).first()
|
||||||
if user and user.btcpay_checkout_style:
|
if user and user.btcpay_checkout_style:
|
||||||
update["btcpay_checkout_style"] = 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):
|
if isinstance(metadata, dict) and isinstance(metadata.get("quote"), dict):
|
||||||
update["quote"] = metadata.get("quote")
|
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
|
qr = metadata.get("qr") if isinstance(metadata, dict) else None
|
||||||
if isinstance(qr, dict):
|
if isinstance(qr, dict):
|
||||||
logo = qr.get("logo")
|
logo = qr.get("logo")
|
||||||
|
|||||||
+6
-1
@@ -126,6 +126,7 @@ class InvoiceStatusResponse(BaseModel):
|
|||||||
address: str
|
address: str
|
||||||
subaddress_index: int | None = None
|
subaddress_index: int | None = None
|
||||||
amount_xmr: Decimal
|
amount_xmr: Decimal
|
||||||
|
amount_paid_xmr: str | None = None
|
||||||
status: InvoiceStatus
|
status: InvoiceStatus
|
||||||
confirmation_target: int
|
confirmation_target: int
|
||||||
confirmations: int = 0
|
confirmations: int = 0
|
||||||
@@ -140,6 +141,10 @@ class InvoiceStatusResponse(BaseModel):
|
|||||||
btcpay_amount: str | None = None
|
btcpay_amount: str | None = None
|
||||||
btcpay_currency: str | None = None
|
btcpay_currency: str | None = None
|
||||||
btcpay_checkout_style: BtcpayCheckoutStyle | 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
|
quote: dict[str, Any] | None = None
|
||||||
qr_url: str | None = None
|
qr_url: str | None = None
|
||||||
|
|
||||||
@@ -156,7 +161,7 @@ class ProfileResponse(BaseModel):
|
|||||||
default_confirmation_target: conint(ge=0, le=10) = 2
|
default_confirmation_target: conint(ge=0, le=10) = 2
|
||||||
default_qr_logo: QrLogoMode = "monero"
|
default_qr_logo: QrLogoMode = "monero"
|
||||||
default_qr_logo_data_url: str | None = None
|
default_qr_logo_data_url: str | None = None
|
||||||
btcpay_checkout_style: BtcpayCheckoutStyle = "standard"
|
btcpay_checkout_style: BtcpayCheckoutStyle = "btcpay_classic"
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ export default async function DashboardPage({
|
|||||||
initialStyle={
|
initialStyle={
|
||||||
profileData?.btcpay_checkout_style === "btcpay_classic"
|
profileData?.btcpay_checkout_style === "btcpay_classic"
|
||||||
? "btcpay_classic"
|
? "btcpay_classic"
|
||||||
: "standard"
|
: "btcpay_classic"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import BtcpayModalBridge from "../../../components/btcpay-modal-bridge";
|
import BtcpayModalBridge from "../../../components/btcpay-modal-bridge";
|
||||||
import BtcpayModalCloseButton from "../../../components/btcpay-modal-close-button";
|
|
||||||
import BtcpayClassicCheckout from "../../../components/btcpay-classic-checkout";
|
import BtcpayClassicCheckout from "../../../components/btcpay-classic-checkout";
|
||||||
import InvoicePaymentDetails from "../../../components/invoice-payment-details";
|
import InvoicePaymentDetails from "../../../components/invoice-payment-details";
|
||||||
import InvoiceStatusAutoRefresh from "../../../components/invoice-status-auto-refresh";
|
import InvoiceStatusAutoRefresh from "../../../components/invoice-status-auto-refresh";
|
||||||
@@ -27,6 +26,7 @@ type InvoiceStatusResponse = {
|
|||||||
address: string;
|
address: string;
|
||||||
subaddress_index: number | null;
|
subaddress_index: number | null;
|
||||||
amount_xmr: string;
|
amount_xmr: string;
|
||||||
|
amount_paid_xmr?: string | null;
|
||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
confirmation_target: number;
|
confirmation_target: number;
|
||||||
confirmations: number;
|
confirmations: number;
|
||||||
@@ -37,6 +37,10 @@ type InvoiceStatusResponse = {
|
|||||||
btcpay_amount?: string | null;
|
btcpay_amount?: string | null;
|
||||||
btcpay_currency?: string | null;
|
btcpay_currency?: string | null;
|
||||||
btcpay_checkout_style?: "standard" | "btcpay_classic" | 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?: "monero" | "none" | "custom" | null;
|
||||||
qr_logo_data_url?: string | null;
|
qr_logo_data_url?: string | null;
|
||||||
quote?: {
|
quote?: {
|
||||||
@@ -104,7 +108,6 @@ export default async function BtcpayModalInvoicePage({
|
|||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 font-serif text-2xl">Invoice not found</h1>
|
<h1 className="mt-2 font-serif text-2xl">Invoice not found</h1>
|
||||||
</div>
|
</div>
|
||||||
<BtcpayModalCloseButton />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-ink-soft">
|
<p className="mt-3 text-sm text-ink-soft">
|
||||||
The invoice id does not match a known invoice. Check the id and try
|
The invoice id does not match a known invoice. Check the id and try
|
||||||
@@ -125,7 +128,6 @@ export default async function BtcpayModalInvoicePage({
|
|||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 font-serif text-2xl">Status unavailable</h1>
|
<h1 className="mt-2 font-serif text-2xl">Status unavailable</h1>
|
||||||
</div>
|
</div>
|
||||||
<BtcpayModalCloseButton />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-ink-soft">
|
<p className="mt-3 text-sm text-ink-soft">
|
||||||
We could not load this invoice status. Refresh the page or try again
|
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 isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency);
|
||||||
const useClassicCheckout =
|
const useClassicCheckout =
|
||||||
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
|
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
|
||||||
|
const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed";
|
||||||
|
|
||||||
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">
|
||||||
@@ -161,17 +164,22 @@ export default async function BtcpayModalInvoicePage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusRefreshButton label="Refresh" className="text-xs" />
|
<StatusRefreshButton label="Refresh" className="text-xs" />
|
||||||
<BtcpayModalCloseButton />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BtcpayClassicCheckout
|
<BtcpayClassicCheckout
|
||||||
|
invoiceId={invoiceId}
|
||||||
address={invoice.address}
|
address={invoice.address}
|
||||||
amountXmr={invoice.amount_xmr}
|
amountXmr={invoice.amount_xmr}
|
||||||
|
amountPaidXmr={invoice.amount_paid_xmr ?? null}
|
||||||
btcpayAmount={invoice.btcpay_amount ?? null}
|
btcpayAmount={invoice.btcpay_amount ?? null}
|
||||||
btcpayCurrency={invoice.btcpay_currency ?? null}
|
btcpayCurrency={invoice.btcpay_currency ?? null}
|
||||||
quote={invoice.quote ?? null}
|
quote={invoice.quote ?? null}
|
||||||
status={invoice.status}
|
status={invoice.status}
|
||||||
confirmationTarget={confirmationTarget}
|
confirmationTarget={confirmationTarget}
|
||||||
|
redirectUrl={invoice.btcpay_redirect_url ?? null}
|
||||||
|
redirectAutomatically={invoice.btcpay_redirect_automatically ?? null}
|
||||||
|
orderId={invoice.btcpay_order_id ?? null}
|
||||||
|
orderNumber={invoice.btcpay_order_number ?? null}
|
||||||
qrLogoMode={invoice.qr_logo ?? "monero"}
|
qrLogoMode={invoice.qr_logo ?? "monero"}
|
||||||
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
|
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
|
||||||
/>
|
/>
|
||||||
@@ -187,7 +195,6 @@ export default async function BtcpayModalInvoicePage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusRefreshButton label="Refresh" className="text-xs" />
|
<StatusRefreshButton label="Refresh" className="text-xs" />
|
||||||
<BtcpayModalCloseButton />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -247,6 +254,26 @@ export default async function BtcpayModalInvoicePage({
|
|||||||
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
|
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{shouldShowBtcpayActions ? (
|
||||||
|
<div className="mt-6 grid gap-3">
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-sage px-6 py-3 text-sm font-semibold text-cream shadow-[0_14px_22px_rgba(93,122,106,0.25)] transition hover:-translate-y-0.5"
|
||||||
|
href={`/i/${encodeURIComponent(invoiceId)}/receipt`}
|
||||||
|
>
|
||||||
|
View receipt
|
||||||
|
</a>
|
||||||
|
{invoice.btcpay_redirect_url ? (
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white px-6 py-3 text-sm font-semibold text-sage transition hover:bg-cream"
|
||||||
|
href={invoice.btcpay_redirect_url}
|
||||||
|
target="_top"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Return to store
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<main className="min-h-screen bg-white px-4 py-10 text-ink">
|
||||||
|
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
|
||||||
|
<div className="mx-auto w-full max-w-4xl">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
|
||||||
|
Close
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<h1 className="text-3xl font-semibold">Invoice not found</h1>
|
||||||
|
<p className="mt-3 text-sm text-ink-soft">
|
||||||
|
The invoice id does not match a known invoice. Check the id and try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white px-4 py-10 text-ink">
|
||||||
|
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
|
||||||
|
<div className="mx-auto w-full max-w-4xl">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
|
||||||
|
Close
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<h1 className="text-3xl font-semibold">Status unavailable</h1>
|
||||||
|
<p className="mt-3 text-sm text-ink-soft">
|
||||||
|
We could not load this invoice status. Refresh the page or try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<main className="min-h-screen bg-white px-4 py-10 text-ink">
|
||||||
|
<BtcpayModalBridge invoiceId={invoiceId} status={invoice.status} />
|
||||||
|
<div className="mx-auto w-full max-w-4xl">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
|
||||||
|
Close
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoice.status !== "confirmed" ? (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight">{storeTitle}</h1>
|
||||||
|
{orderId ? <p className="mt-3 text-lg text-ink">Order ID: {orderId}</p> : null}
|
||||||
|
<p className="mt-1 text-base text-ink-soft" title={timestampRelative ?? undefined}>
|
||||||
|
{timestampLabel}
|
||||||
|
</p>
|
||||||
|
<p className="mt-10 text-sm text-ink-soft">
|
||||||
|
This receipt is available after the payment is confirmed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight">{storeTitle}</h1>
|
||||||
|
{orderId ? <p className="mt-3 text-lg text-ink">Order ID: {orderId}</p> : null}
|
||||||
|
<p className="mt-1 text-base text-ink-soft" title={timestampRelative ?? undefined}>
|
||||||
|
{timestampLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 border-y border-stroke">
|
||||||
|
<dl className="text-base">
|
||||||
|
<div className="flex items-center justify-between gap-6 py-5">
|
||||||
|
<dt className="text-ink-soft">Total</dt>
|
||||||
|
<dd className="text-right font-medium">{totalFiat ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-6 border-t border-stroke py-5">
|
||||||
|
<dt className="pt-0.5 text-ink-soft">Paid</dt>
|
||||||
|
<dd className="text-right">
|
||||||
|
<div className="font-medium">{paidXmr} XMR</div>
|
||||||
|
{totalFiat ? <div className="mt-2 text-ink-soft">{totalFiat}</div> : null}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-6 border-t border-stroke py-5">
|
||||||
|
<dt className="text-ink-soft">Rate</dt>
|
||||||
|
<dd className="text-right font-medium">{exchangeRate ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6 border-t border-stroke py-5">
|
||||||
|
<dt className="w-28 flex-none text-ink-soft sm:w-36">Destination</dt>
|
||||||
|
<dd className="min-w-0 flex-1 truncate font-mono text-xs text-ink">
|
||||||
|
{invoice.address}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex justify-center">
|
||||||
|
{qrSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
alt="Payment QR"
|
||||||
|
src={qrSrc}
|
||||||
|
className="h-64 w-64 bg-white p-2 sm:h-72 sm:w-72"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-10 text-center text-sm text-ink-soft">
|
||||||
|
Powered by XMR Checkout
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ export default function BtcpayCheckoutStyleSection({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="standard">Standard XMR Checkout</option>
|
<option value="standard">Standard XMR Checkout</option>
|
||||||
<option value="btcpay_classic">BTCPay classic</option>
|
<option value="btcpay_classic">BTCPay Classic</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-ink-soft">
|
<p className="text-xs text-ink-soft">
|
||||||
|
|||||||
@@ -22,13 +22,19 @@ type QuotePayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type BtcpayClassicCheckoutProps = {
|
type BtcpayClassicCheckoutProps = {
|
||||||
|
invoiceId: string;
|
||||||
address: string;
|
address: string;
|
||||||
amountXmr: string;
|
amountXmr: string;
|
||||||
|
amountPaidXmr: string | null;
|
||||||
btcpayAmount: string | null;
|
btcpayAmount: string | null;
|
||||||
btcpayCurrency: string | null;
|
btcpayCurrency: string | null;
|
||||||
quote: QuotePayload | null;
|
quote: QuotePayload | null;
|
||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
confirmationTarget: number;
|
confirmationTarget: number;
|
||||||
|
redirectUrl: string | null;
|
||||||
|
redirectAutomatically: boolean | null;
|
||||||
|
orderId: string | null;
|
||||||
|
orderNumber: string | null;
|
||||||
qrLogoMode?: "monero" | "none" | "custom" | null;
|
qrLogoMode?: "monero" | "none" | "custom" | null;
|
||||||
qrLogoDataUrl?: string | 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 = (
|
const statusMessage = (
|
||||||
status: InvoiceStatus,
|
status: InvoiceStatus,
|
||||||
confirmationTarget: number
|
confirmationTarget: number
|
||||||
@@ -76,19 +91,31 @@ const statusMessage = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function BtcpayClassicCheckout({
|
export default function BtcpayClassicCheckout({
|
||||||
|
invoiceId,
|
||||||
address,
|
address,
|
||||||
amountXmr,
|
amountXmr,
|
||||||
|
amountPaidXmr,
|
||||||
btcpayAmount,
|
btcpayAmount,
|
||||||
btcpayCurrency,
|
btcpayCurrency,
|
||||||
quote,
|
quote,
|
||||||
status,
|
status,
|
||||||
confirmationTarget,
|
confirmationTarget,
|
||||||
|
redirectUrl,
|
||||||
|
redirectAutomatically,
|
||||||
|
orderId,
|
||||||
|
orderNumber,
|
||||||
qrLogoMode,
|
qrLogoMode,
|
||||||
qrLogoDataUrl,
|
qrLogoDataUrl,
|
||||||
}: BtcpayClassicCheckoutProps) {
|
}: BtcpayClassicCheckoutProps) {
|
||||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||||
const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]);
|
const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]);
|
||||||
|
const formattedPaidAmount = useMemo(() => {
|
||||||
|
if (!amountPaidXmr) {
|
||||||
|
return formattedAmount;
|
||||||
|
}
|
||||||
|
return formatXmrAmount(amountPaidXmr);
|
||||||
|
}, [amountPaidXmr, formattedAmount]);
|
||||||
const uri = useMemo(
|
const uri = useMemo(
|
||||||
() => buildMoneroUri(address, formattedAmount),
|
() => buildMoneroUri(address, formattedAmount),
|
||||||
[address, formattedAmount]
|
[address, formattedAmount]
|
||||||
@@ -102,6 +129,22 @@ export default function BtcpayClassicCheckout({
|
|||||||
? `1 XMR = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
|
? `1 XMR = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
|
||||||
: null;
|
: 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) => {
|
const handleCopy = async (value: string, field: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
@@ -162,6 +205,84 @@ export default function BtcpayClassicCheckout({
|
|||||||
? "/monero-logo.svg"
|
? "/monero-logo.svg"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (status === "confirmed") {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-sage/15 text-sage">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-9 w-9"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Invoice paid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-6 grid gap-3 text-sm text-ink">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Invoice id</dt>
|
||||||
|
<dd className="max-w-[60%] truncate text-right font-semibold">{invoiceId}</dd>
|
||||||
|
</div>
|
||||||
|
{orderNumber || orderId ? (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Order id</dt>
|
||||||
|
<dd className="font-semibold">{orderNumber ?? orderId}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Total price</dt>
|
||||||
|
<dd className="font-semibold">{formattedAmount} XMR</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Total fiat</dt>
|
||||||
|
<dd className="font-semibold">{totalFiat ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Exchange rate</dt>
|
||||||
|
<dd className="font-semibold">{exchangeRate ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Network cost</dt>
|
||||||
|
<dd className="font-semibold">Added by wallet</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Amount paid</dt>
|
||||||
|
<dd className="font-semibold">{formattedPaidAmount} XMR</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-3">
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-sage px-6 py-3 text-sm font-semibold text-cream shadow-[0_14px_22px_rgba(93,122,106,0.25)] transition hover:-translate-y-0.5"
|
||||||
|
href={`/i/${encodeURIComponent(invoiceId)}/receipt`}
|
||||||
|
>
|
||||||
|
View receipt
|
||||||
|
</a>
|
||||||
|
{redirectUrl ? (
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white px-6 py-3 text-sm font-semibold text-sage transition hover:bg-cream"
|
||||||
|
href={redirectUrl}
|
||||||
|
target="_top"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{formatReturnLabel(redirectUrl)}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
|
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
}
|
}
|
||||||
state.handlers.forEach(function (handler) {
|
state.handlers.forEach(function (handler) {
|
||||||
try {
|
try {
|
||||||
handler(event.data);
|
handler(event);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user