Fixed expired Stripe URLs
X-Lovable-Edit-ID: edt-ae7e7f93-49ac-4567-853a-1af5913e6c1f Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -189,3 +189,65 @@ export const getPublicPaymentRequest = createServerFn({ method: "GET" })
|
||||
if (!row) throw new Error("Payment request not found");
|
||||
return row;
|
||||
});
|
||||
|
||||
// Public: always creates a fresh Stripe Checkout session for the pay page.
|
||||
// Old stripe_checkout_url values become invalid when the Stripe key is
|
||||
// rotated or after 24h, so we never reuse the stored URL on click.
|
||||
export const startPublicCheckout = createServerFn({ method: "POST" })
|
||||
.inputValidator((data: { id: string }) => data)
|
||||
.handler(async ({ data }) => {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data: row, error } = await supabaseAdmin
|
||||
.from("payment_requests")
|
||||
.select("*")
|
||||
.eq("id", data.id)
|
||||
.maybeSingle();
|
||||
if (error || !row) throw new Error("Payment request not found");
|
||||
if (row.status === "paid") throw new Error("This payment has already been paid");
|
||||
if (row.status === "canceled") throw new Error("This payment request was canceled");
|
||||
|
||||
const stripe = getStripe();
|
||||
const origin = getOrigin();
|
||||
const successUrl = `${origin}/pay/${row.id}?status=success`;
|
||||
const cancelUrl = `${origin}/pay/${row.id}?status=cancel`;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
payment_method_types: ["card"],
|
||||
customer_email: row.recipient_email || undefined,
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
unit_amount: row.base_amount_cents,
|
||||
product_data: {
|
||||
name: row.description || `Payment from ${row.recipient_name}`,
|
||||
},
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
unit_amount: row.fee_amount_cents,
|
||||
product_data: { name: "Processing fee (2.9% + $0.30)" },
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata: { payment_request_id: row.id },
|
||||
});
|
||||
|
||||
await supabaseAdmin
|
||||
.from("payment_requests")
|
||||
.update({
|
||||
stripe_session_id: session.id,
|
||||
stripe_checkout_url: session.url ?? null,
|
||||
})
|
||||
.eq("id", row.id);
|
||||
|
||||
if (!session.url) throw new Error("Stripe did not return a checkout URL");
|
||||
return { checkout_url: session.url };
|
||||
});
|
||||
|
||||
+24
-16
@@ -2,11 +2,11 @@ import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CheckCircle2, CreditCard, XCircle } from "lucide-react";
|
||||
import { CheckCircle2, CreditCard, XCircle, Loader2 } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { getPublicPaymentRequest } from "@/lib/payments.functions";
|
||||
import { getPublicPaymentRequest, startPublicCheckout } from "@/lib/payments.functions";
|
||||
import { JusticeIcon } from "@/components/justice-icon";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/pay/$id")({
|
||||
validateSearch: (s: Record<string, unknown>) => ({
|
||||
@@ -43,6 +43,7 @@ function PayPage() {
|
||||
const data = Route.useLoaderData();
|
||||
const search = useSearch({ from: "/pay/$id" });
|
||||
const [row, setRow] = useState(data);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRow(data);
|
||||
@@ -104,19 +105,26 @@ function PayPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{row.stripe_checkout_url ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
window.location.href = row.stripe_checkout_url!;
|
||||
}}
|
||||
>
|
||||
Pay {formatCurrency(row.total_amount_cents / 100)} with card
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">Checkout link unavailable</Badge>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={starting}
|
||||
onClick={async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
const res = await startPublicCheckout({ data: { id: row.id } });
|
||||
window.location.href = res.checkout_url;
|
||||
} catch (e: any) {
|
||||
setStarting(false);
|
||||
toast.error("Could not start checkout", {
|
||||
description: e?.message ?? "Please try again in a moment.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{starting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Pay {formatCurrency(row.total_amount_cents / 100)} with card
|
||||
</Button>
|
||||
|
||||
{search.status === "cancel" && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-3">
|
||||
|
||||
Reference in New Issue
Block a user