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({
The invoice id does not match a known invoice. Check the id and try @@ -125,7 +128,6 @@ export default async function BtcpayModalInvoicePage({
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 (
+ The invoice id does not match a known invoice. Check the id and try again.
+
+ We could not load this invoice status. Refresh the page or try again later.
+ Order ID: {orderId}
+ {timestampLabel}
+
+ This receipt is available after the payment is confirmed.
+ Order ID: {orderId}
+ {timestampLabel}
+
+ Powered by XMR Checkout
+ Invoice not found
+ Status unavailable
+ {storeTitle}
+ {orderId ? {storeTitle}
+ {orderId ?
+
+
+ ) : null}
+
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
+ Invoice paid
+
+
+
+