import { createServerFn } from "@tanstack/react-start"; import { getRequestHost } from "@tanstack/react-start/server"; import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; import { attachSupabaseAuth } from "@/integrations/supabase/auth-client-middleware"; 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([attachSupabaseAuth, 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, supabase } = context; const stripe = getStripe(); const { totalCents, feeCents } = calcSurcharge(data.base_amount_cents); const origin = getOrigin(); // Insert pending row first (uses authenticated client; RLS allows insert by auth users) const { data: row, error: insErr } = await supabase .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 supabase .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([attachSupabaseAuth, requireSupabaseAuth]) .inputValidator((data: { id: string }) => data) .handler(async ({ data, context }) => { const { supabase } = context; const { data: row } = await supabase .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 supabase .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([attachSupabaseAuth, requireSupabaseAuth]) .inputValidator((data: { id: string }) => data) .handler(async ({ data, context }) => { await context.supabase .from("payment_requests") .update({ email_sent_at: new Date().toISOString() }) .eq("id", data.id); return { ok: true }; }); export const cancelPaymentRequest = createServerFn({ method: "POST" }) .middleware([attachSupabaseAuth, requireSupabaseAuth]) .inputValidator((data: { id: string }) => data) .handler(async ({ data, context }) => { await context.supabase .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 }) => { // Use service-role client to bypass RLS; return only a strict whitelist // of non-sensitive fields for the public pay page. const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); 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; }); // 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 }; });