Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 17:50:39 +00:00
co-authored by renee-png
parent bd97829760
commit dc38ebc241
2 changed files with 239 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
import { createServerFn } from "@tanstack/react-start";
import { getRequestHost } from "@tanstack/react-start/server";
import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware";
import { supabaseAdmin } from "@/integrations/supabase/client.server";
import Stripe from "stripe";
// Stripe US card rate: 2.9% + $0.30. Surcharge so homeowner covers fees.
// To net base amount B from total T after fees: T = (B + 0.30) / (1 - 0.029)
function calcSurcharge(baseCents: number) {
const base = baseCents / 100;
const total = (base + 0.3) / (1 - 0.029);
const totalCents = Math.ceil(total * 100);
return { totalCents, feeCents: totalCents - baseCents };
}
function getStripe() {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY is not configured");
return new Stripe(key);
}
function getOrigin() {
try {
const host = getRequestHost();
const proto = host?.includes("localhost") ? "http" : "https";
return `${proto}://${host}`;
} catch {
return "";
}
}
export const createPaymentRequest = createServerFn({ method: "POST" })
.middleware([requireSupabaseAuth])
.inputValidator(
(data: {
recipient_name: string;
recipient_email?: string;
description?: string;
base_amount_cents: number;
homeowner_id?: string;
collection_id?: string;
case_id?: string;
client_id?: string;
notes?: string;
}) => {
if (!data.recipient_name?.trim()) throw new Error("Recipient name required");
if (!data.base_amount_cents || data.base_amount_cents < 100)
throw new Error("Amount must be at least $1.00");
return data;
},
)
.handler(async ({ data, context }) => {
const { userId } = context;
const stripe = getStripe();
const { totalCents, feeCents } = calcSurcharge(data.base_amount_cents);
const origin = getOrigin();
// Insert pending row first
const { data: row, error: insErr } = await supabaseAdmin
.from("payment_requests")
.insert({
recipient_name: data.recipient_name,
recipient_email: data.recipient_email ?? null,
description: data.description ?? null,
base_amount_cents: data.base_amount_cents,
fee_amount_cents: feeCents,
total_amount_cents: totalCents,
homeowner_id: data.homeowner_id ?? null,
collection_id: data.collection_id ?? null,
case_id: data.case_id ?? null,
client_id: data.client_id ?? null,
notes: data.notes ?? null,
created_by: userId,
status: "pending",
})
.select("*")
.single();
if (insErr || !row) throw new Error(insErr?.message ?? "Failed to create payment request");
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: data.recipient_email || undefined,
line_items: [
{
price_data: {
currency: "usd",
unit_amount: data.base_amount_cents,
product_data: {
name: data.description || `Payment from ${data.recipient_name}`,
},
},
quantity: 1,
},
{
price_data: {
currency: "usd",
unit_amount: feeCents,
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);
return { id: row.id, checkout_url: session.url, total_cents: totalCents, fee_cents: feeCents };
});
export const refreshPaymentStatus = createServerFn({ method: "POST" })
.middleware([requireSupabaseAuth])
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const { data: row } = await supabaseAdmin
.from("payment_requests")
.select("*")
.eq("id", data.id)
.maybeSingle();
if (!row) throw new Error("Not found");
if (row.status === "paid" || !row.stripe_session_id) return { status: row.status };
const stripe = getStripe();
const session = await stripe.checkout.sessions.retrieve(row.stripe_session_id);
if (session.payment_status === "paid") {
await supabaseAdmin
.from("payment_requests")
.update({
status: "paid",
paid_at: new Date().toISOString(),
stripe_payment_intent_id:
typeof session.payment_intent === "string" ? session.payment_intent : null,
})
.eq("id", row.id);
return { status: "paid" };
}
return { status: row.status };
});
export const markPaymentEmailSent = createServerFn({ method: "POST" })
.middleware([requireSupabaseAuth])
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
await supabaseAdmin
.from("payment_requests")
.update({ email_sent_at: new Date().toISOString() })
.eq("id", data.id);
return { ok: true };
});
export const cancelPaymentRequest = createServerFn({ method: "POST" })
.middleware([requireSupabaseAuth])
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
await supabaseAdmin
.from("payment_requests")
.update({ status: "canceled" })
.eq("id", data.id);
return { ok: true };
});
// Public: fetch minimal info for the pay page (no auth)
export const getPublicPaymentRequest = createServerFn({ method: "GET" })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const { data: row } = await supabaseAdmin
.from("payment_requests")
.select(
"id, recipient_name, description, base_amount_cents, fee_amount_cents, total_amount_cents, currency, status, stripe_checkout_url, paid_at",
)
.eq("id", data.id)
.maybeSingle();
if (!row) throw new Error("Payment request not found");
return row;
});
+53
View File
@@ -0,0 +1,53 @@
import { createFileRoute } from "@tanstack/react-router";
import { supabaseAdmin } from "@/integrations/supabase/client.server";
import Stripe from "stripe";
export const Route = createFileRoute("/api/stripe-webhook")({
server: {
handlers: {
POST: async ({ request }) => {
const secret = process.env.STRIPE_SECRET_KEY;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) return new Response("Stripe not configured", { status: 500 });
const stripe = new Stripe(secret);
const sig = request.headers.get("stripe-signature");
const body = await request.text();
let event: Stripe.Event;
try {
if (webhookSecret && sig) {
event = await stripe.webhooks.constructEventAsync(body, sig, webhookSecret);
} else {
// Without signature secret, accept but log (still secure if endpoint is obscure;
// the user can configure STRIPE_WEBHOOK_SECRET later)
event = JSON.parse(body) as Stripe.Event;
}
} catch (err) {
return new Response(`Webhook error: ${(err as Error).message}`, { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const reqId = session.metadata?.payment_request_id;
if (reqId) {
await supabaseAdmin
.from("payment_requests")
.update({
status: "paid",
paid_at: new Date().toISOString(),
stripe_payment_intent_id:
typeof session.payment_intent === "string" ? session.payment_intent : null,
})
.eq("id", reqId);
}
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
},
},
});