Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
197 lines
6.8 KiB
TypeScript
197 lines
6.8 KiB
TypeScript
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 { 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([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 anon-key client; RLS policy "pr_select_public_by_id" allows public reads
|
|
const { createClient } = await import("@supabase/supabase-js");
|
|
const url = process.env.SUPABASE_URL;
|
|
const anonKey = process.env.SUPABASE_PUBLISHABLE_KEY;
|
|
if (!url || !anonKey) throw new Error("Supabase env not configured");
|
|
const sb = createClient(url, anonKey, {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
});
|
|
const { data: row } = await sb
|
|
.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;
|
|
});
|