From dc38ebc24125b613b1b617a35a58dd11a3ad4307 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:50:39 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/payments.functions.ts | 186 +++++++++++++++++++++++++++++++ src/routes/api.stripe-webhook.ts | 53 +++++++++ 2 files changed, 239 insertions(+) create mode 100644 src/lib/payments.functions.ts create mode 100644 src/routes/api.stripe-webhook.ts diff --git a/src/lib/payments.functions.ts b/src/lib/payments.functions.ts new file mode 100644 index 0000000..ef3984a --- /dev/null +++ b/src/lib/payments.functions.ts @@ -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; + }); diff --git a/src/routes/api.stripe-webhook.ts b/src/routes/api.stripe-webhook.ts new file mode 100644 index 0000000..50114f8 --- /dev/null +++ b/src/routes/api.stripe-webhook.ts @@ -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" }, + }); + }, + }, + }, +});