mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
Add optional post-confirmation continue URL
This commit is contained in:
@@ -125,6 +125,37 @@ export async function createInvoiceAction(
|
||||
}
|
||||
const recipientName = String(formData.get("recipient_name") ?? "").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 expiresDate = String(formData.get("expires_date") ?? "").trim();
|
||||
const expiresTime = String(formData.get("expires_time") ?? "").trim();
|
||||
@@ -216,6 +247,7 @@ export async function createInvoiceAction(
|
||||
currency: isFiat ? currency : null,
|
||||
confirmation_target: confirmationTarget,
|
||||
expires_at: expiresAt,
|
||||
checkout_continue_url: checkoutContinueUrlRaw || null,
|
||||
metadata:
|
||||
Object.keys(metadata).length > 0 || qrPayload
|
||||
? {
|
||||
|
||||
@@ -73,6 +73,11 @@ export default function IntegrationsPage() {
|
||||
Create an invoice via{" "}
|
||||
<span className="font-mono text-ink">POST /api/core/invoices</span>.
|
||||
</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>
|
||||
Redirect the customer to <span className="font-mono text-ink">invoice_url</span>.
|
||||
</li>
|
||||
|
||||
@@ -43,6 +43,11 @@ const endpointGroups = [
|
||||
path: "/api/core/public/invoice/{invoice_id}",
|
||||
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: [],
|
||||
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",
|
||||
path: "/api/core/invoices",
|
||||
@@ -352,6 +368,7 @@ const endpointRequirements = [
|
||||
required: ["amount_xmr or amount_fiat + currency"],
|
||||
optional: [
|
||||
"confirmation_target (default 2, 0-10)",
|
||||
"checkout_continue_url (https URL, optional post-confirmation Continue button)",
|
||||
"metadata",
|
||||
"expires_at (ISO 8601)",
|
||||
],
|
||||
@@ -614,6 +631,7 @@ Content-Type: application/json
|
||||
"amount_fiat": "100.00",
|
||||
"currency": "USD",
|
||||
"confirmation_target": 2,
|
||||
"checkout_continue_url": "https://merchant.example/thanks",
|
||||
"metadata": { "order_id": "ORDER-1234" }
|
||||
}`}</code>
|
||||
</pre>
|
||||
|
||||
@@ -29,6 +29,7 @@ type InvoiceStatusResponse = {
|
||||
status: InvoiceStatus;
|
||||
confirmation_target: number;
|
||||
confirmations: number;
|
||||
checkout_continue_available?: boolean;
|
||||
qr_logo?: "monero" | "none" | "custom" | null;
|
||||
qr_logo_data_url?: string | null;
|
||||
created_at: string;
|
||||
@@ -162,6 +163,8 @@ export default async function InvoiceStatusDetailPage({
|
||||
const isInvalid = invoice.status === "invalid";
|
||||
const hasDetectedPayment =
|
||||
invoice.status === "payment_detected" || invoice.status === "confirmed";
|
||||
const checkoutContinueAvailable =
|
||||
invoice.status === "confirmed" && Boolean(invoice.checkout_continue_available);
|
||||
const timelineItems: {
|
||||
label: string;
|
||||
timestamp: ReturnType<typeof formatTimestamp>;
|
||||
@@ -326,6 +329,15 @@ export default async function InvoiceStatusDetailPage({
|
||||
</section>
|
||||
|
||||
<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 />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -30,6 +30,7 @@ type InvoiceStatusResponse = {
|
||||
status: InvoiceStatus;
|
||||
confirmation_target: number;
|
||||
confirmations: number;
|
||||
checkout_continue_available?: boolean;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
detected_at: string | null;
|
||||
@@ -149,6 +150,10 @@ export default async function BtcpayModalInvoicePage({
|
||||
const useClassicCheckout =
|
||||
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
|
||||
const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed";
|
||||
const checkoutContinueAvailable =
|
||||
!isBtcpayInvoice &&
|
||||
invoice.status === "confirmed" &&
|
||||
Boolean(invoice.checkout_continue_available);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
|
||||
@@ -241,6 +246,15 @@ export default async function BtcpayModalInvoicePage({
|
||||
</p>
|
||||
</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 className="mt-5">
|
||||
|
||||
@@ -376,6 +376,22 @@ export default function CreateInvoiceCard() {
|
||||
placeholder="Order 1042"
|
||||
/>
|
||||
</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 "Continue" button
|
||||
that navigates to this URL.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className={labelClass} htmlFor="qr_color">
|
||||
QR accent color
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function InvoicePanel({
|
||||
const [expiresTime, setExpiresTime] = useState("");
|
||||
const [recipientName, setRecipientName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [checkoutContinueUrl, setCheckoutContinueUrl] = useState("");
|
||||
const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState<string | null>(null);
|
||||
const [searchInput, setSearchInput] = useState(searchQuery ?? "");
|
||||
const [qrLogoMode, setQrLogoMode] = useState<
|
||||
@@ -260,6 +261,7 @@ export default function InvoicePanel({
|
||||
setExpiresTime("");
|
||||
setRecipientName("");
|
||||
setDescription("");
|
||||
setCheckoutContinueUrl("");
|
||||
setQrLogoMode("account_default");
|
||||
setQrLogoDataUrl(null);
|
||||
};
|
||||
@@ -1044,6 +1046,23 @@ export default function InvoicePanel({
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</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">
|
||||
<label className={labelClass} htmlFor="wizard_qr_logo_mode">
|
||||
QR logo
|
||||
@@ -1118,6 +1137,11 @@ export default function InvoicePanel({
|
||||
<input type="hidden" name="expires_at" value={expiryValue} />
|
||||
<input type="hidden" name="recipient_name" value={recipientName} />
|
||||
<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"
|
||||
@@ -1177,6 +1201,9 @@ export default function InvoicePanel({
|
||||
<p className="mt-2 text-sm text-ink">
|
||||
Description: <strong>{description || "-"}</strong>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-ink">
|
||||
Continue URL: <strong>{checkoutContinueUrl || "-"}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user