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:
gpt-engineer-app[bot]
2026-05-11 14:51:14 +00:00
co-authored by renee-png
2 changed files with 86 additions and 16 deletions
+62
View File
@@ -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
View File
@@ -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">