diff --git a/bun.lockb b/bun.lockb index 050aece..52098e0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 115edd1..760cef2 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "react-resizable-panels": "^4.6.5", "recharts": "^2.15.4", "sonner": "^2.0.7", + "stripe": "^17", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "tw-animate-css": "^1.3.4", diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 9044bf2..0c72c6f 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -22,6 +22,7 @@ import { UserPlus, User as UserIcon, Inbox as InboxIcon, + CreditCard, } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; @@ -50,6 +51,7 @@ const NAV: NavItem[] = [ { to: "/inbox", label: "Inbox", icon: InboxIcon }, { to: "/invoices", label: "Invoices", icon: Receipt }, { to: "/messages", label: "Messages", icon: MessageSquare }, + { to: "/payments", label: "Payments", icon: CreditCard }, { to: "/documents", label: "Pleadings", icon: FolderOpen }, { to: "/settings", label: "Settings", icon: SettingsIcon }, { to: "/status", label: "Status Updates", icon: Activity }, diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 4ce7b79..85e0b56 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -2327,6 +2327,110 @@ export type Database = { }, ] } + payment_requests: { + Row: { + base_amount_cents: number + case_id: string | null + client_id: string | null + collection_id: string | null + created_at: string + created_by: string | null + currency: string + description: string | null + email_sent_at: string | null + fee_amount_cents: number + homeowner_id: string | null + id: string + notes: string | null + paid_at: string | null + recipient_email: string | null + recipient_name: string + status: string + stripe_checkout_url: string | null + stripe_payment_intent_id: string | null + stripe_session_id: string | null + total_amount_cents: number + updated_at: string + } + Insert: { + base_amount_cents: number + case_id?: string | null + client_id?: string | null + collection_id?: string | null + created_at?: string + created_by?: string | null + currency?: string + description?: string | null + email_sent_at?: string | null + fee_amount_cents?: number + homeowner_id?: string | null + id?: string + notes?: string | null + paid_at?: string | null + recipient_email?: string | null + recipient_name: string + status?: string + stripe_checkout_url?: string | null + stripe_payment_intent_id?: string | null + stripe_session_id?: string | null + total_amount_cents: number + updated_at?: string + } + Update: { + base_amount_cents?: number + case_id?: string | null + client_id?: string | null + collection_id?: string | null + created_at?: string + created_by?: string | null + currency?: string + description?: string | null + email_sent_at?: string | null + fee_amount_cents?: number + homeowner_id?: string | null + id?: string + notes?: string | null + paid_at?: string | null + recipient_email?: string | null + recipient_name?: string + status?: string + stripe_checkout_url?: string | null + stripe_payment_intent_id?: string | null + stripe_session_id?: string | null + total_amount_cents?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "payment_requests_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "payment_requests_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + { + foreignKeyName: "payment_requests_collection_id_fkey" + columns: ["collection_id"] + isOneToOne: false + referencedRelation: "collections" + referencedColumns: ["id"] + }, + { + foreignKeyName: "payment_requests_homeowner_id_fkey" + columns: ["homeowner_id"] + isOneToOne: false + referencedRelation: "homeowners" + referencedColumns: ["id"] + }, + ] + } profiles: { Row: { avatar_url: string | null 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/routeTree.gen.ts b/src/routeTree.gen.ts index 2711e30..8921027 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as TasksIndexRouteImport } from './routes/tasks.index' import { Route as StatusIndexRouteImport } from './routes/status.index' import { Route as SettingsIndexRouteImport } from './routes/settings.index' +import { Route as PaymentsIndexRouteImport } from './routes/payments.index' import { Route as MessagesIndexRouteImport } from './routes/messages.index' import { Route as InvoicesIndexRouteImport } from './routes/invoices.index' import { Route as InboxIndexRouteImport } from './routes/inbox.index' @@ -37,6 +38,7 @@ import { Route as SettingsFormTemplatesRouteImport } from './routes/settings.for import { Route as SettingsFeesRouteImport } from './routes/settings.fees' import { Route as SettingsCustomFieldsRouteImport } from './routes/settings.custom-fields' import { Route as SettingsClientFieldsRouteImport } from './routes/settings.client-fields' +import { Route as PayIdRouteImport } from './routes/pay.$id' import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId' import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap' import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId' @@ -44,6 +46,7 @@ import { Route as CollectionsCollectionIdRouteImport } from './routes/collection import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId' import { Route as CasesNewRouteImport } from './routes/cases.new' import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId' +import { Route as ApiStripeWebhookRouteImport } from './routes/api.stripe-webhook' import { Route as AdminUsersRouteImport } from './routes/admin.users' import { Route as DocumentsTemplatesIndexRouteImport } from './routes/documents.templates.index' import { Route as DocumentsTemplatesNewRouteImport } from './routes/documents.templates.new' @@ -85,6 +88,11 @@ const SettingsIndexRoute = SettingsIndexRouteImport.update({ path: '/', getParentRoute: () => SettingsRoute, } as any) +const PaymentsIndexRoute = PaymentsIndexRouteImport.update({ + id: '/payments/', + path: '/payments/', + getParentRoute: () => rootRouteImport, +} as any) const MessagesIndexRoute = MessagesIndexRouteImport.update({ id: '/messages/', path: '/messages/', @@ -190,6 +198,11 @@ const SettingsClientFieldsRoute = SettingsClientFieldsRouteImport.update({ path: '/client-fields', getParentRoute: () => SettingsRoute, } as any) +const PayIdRoute = PayIdRouteImport.update({ + id: '/pay/$id', + path: '/pay/$id', + getParentRoute: () => rootRouteImport, +} as any) const InvoicesInvoiceIdRoute = InvoicesInvoiceIdRouteImport.update({ id: '/invoices/$invoiceId', path: '/invoices/$invoiceId', @@ -225,6 +238,11 @@ const CasesCaseIdRoute = CasesCaseIdRouteImport.update({ path: '/cases/$caseId', getParentRoute: () => rootRouteImport, } as any) +const ApiStripeWebhookRoute = ApiStripeWebhookRouteImport.update({ + id: '/api/stripe-webhook', + path: '/api/stripe-webhook', + getParentRoute: () => rootRouteImport, +} as any) const AdminUsersRoute = AdminUsersRouteImport.update({ id: '/admin/users', path: '/admin/users', @@ -258,6 +276,7 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteWithChildren '/setup': typeof SetupRoute '/admin/users': typeof AdminUsersRoute + '/api/stripe-webhook': typeof ApiStripeWebhookRoute '/cases/$caseId': typeof CasesCaseIdRoute '/cases/new': typeof CasesNewRoute '/clients/$clientId': typeof ClientsClientIdRoute @@ -265,6 +284,7 @@ export interface FileRoutesByFullPath { '/contacts/$contactId': typeof ContactsContactIdRoute '/hooks/poll-imap': typeof HooksPollImapRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute + '/pay/$id': typeof PayIdRoute '/settings/client-fields': typeof SettingsClientFieldsRoute '/settings/custom-fields': typeof SettingsCustomFieldsRoute '/settings/fees': typeof SettingsFeesRoute @@ -286,6 +306,7 @@ export interface FileRoutesByFullPath { '/inbox/': typeof InboxIndexRoute '/invoices/': typeof InvoicesIndexRoute '/messages/': typeof MessagesIndexRoute + '/payments/': typeof PaymentsIndexRoute '/settings/': typeof SettingsIndexRoute '/status/': typeof StatusIndexRoute '/tasks/': typeof TasksIndexRoute @@ -299,6 +320,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/setup': typeof SetupRoute '/admin/users': typeof AdminUsersRoute + '/api/stripe-webhook': typeof ApiStripeWebhookRoute '/cases/$caseId': typeof CasesCaseIdRoute '/cases/new': typeof CasesNewRoute '/clients/$clientId': typeof ClientsClientIdRoute @@ -306,6 +328,7 @@ export interface FileRoutesByTo { '/contacts/$contactId': typeof ContactsContactIdRoute '/hooks/poll-imap': typeof HooksPollImapRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute + '/pay/$id': typeof PayIdRoute '/settings/client-fields': typeof SettingsClientFieldsRoute '/settings/custom-fields': typeof SettingsCustomFieldsRoute '/settings/fees': typeof SettingsFeesRoute @@ -327,6 +350,7 @@ export interface FileRoutesByTo { '/inbox': typeof InboxIndexRoute '/invoices': typeof InvoicesIndexRoute '/messages': typeof MessagesIndexRoute + '/payments': typeof PaymentsIndexRoute '/settings': typeof SettingsIndexRoute '/status': typeof StatusIndexRoute '/tasks': typeof TasksIndexRoute @@ -342,6 +366,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteWithChildren '/setup': typeof SetupRoute '/admin/users': typeof AdminUsersRoute + '/api/stripe-webhook': typeof ApiStripeWebhookRoute '/cases/$caseId': typeof CasesCaseIdRoute '/cases/new': typeof CasesNewRoute '/clients/$clientId': typeof ClientsClientIdRoute @@ -349,6 +374,7 @@ export interface FileRoutesById { '/contacts/$contactId': typeof ContactsContactIdRoute '/hooks/poll-imap': typeof HooksPollImapRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute + '/pay/$id': typeof PayIdRoute '/settings/client-fields': typeof SettingsClientFieldsRoute '/settings/custom-fields': typeof SettingsCustomFieldsRoute '/settings/fees': typeof SettingsFeesRoute @@ -370,6 +396,7 @@ export interface FileRoutesById { '/inbox/': typeof InboxIndexRoute '/invoices/': typeof InvoicesIndexRoute '/messages/': typeof MessagesIndexRoute + '/payments/': typeof PaymentsIndexRoute '/settings/': typeof SettingsIndexRoute '/status/': typeof StatusIndexRoute '/tasks/': typeof TasksIndexRoute @@ -386,6 +413,7 @@ export interface FileRouteTypes { | '/settings' | '/setup' | '/admin/users' + | '/api/stripe-webhook' | '/cases/$caseId' | '/cases/new' | '/clients/$clientId' @@ -393,6 +421,7 @@ export interface FileRouteTypes { | '/contacts/$contactId' | '/hooks/poll-imap' | '/invoices/$invoiceId' + | '/pay/$id' | '/settings/client-fields' | '/settings/custom-fields' | '/settings/fees' @@ -414,6 +443,7 @@ export interface FileRouteTypes { | '/inbox/' | '/invoices/' | '/messages/' + | '/payments/' | '/settings/' | '/status/' | '/tasks/' @@ -427,6 +457,7 @@ export interface FileRouteTypes { | '/login' | '/setup' | '/admin/users' + | '/api/stripe-webhook' | '/cases/$caseId' | '/cases/new' | '/clients/$clientId' @@ -434,6 +465,7 @@ export interface FileRouteTypes { | '/contacts/$contactId' | '/hooks/poll-imap' | '/invoices/$invoiceId' + | '/pay/$id' | '/settings/client-fields' | '/settings/custom-fields' | '/settings/fees' @@ -455,6 +487,7 @@ export interface FileRouteTypes { | '/inbox' | '/invoices' | '/messages' + | '/payments' | '/settings' | '/status' | '/tasks' @@ -469,6 +502,7 @@ export interface FileRouteTypes { | '/settings' | '/setup' | '/admin/users' + | '/api/stripe-webhook' | '/cases/$caseId' | '/cases/new' | '/clients/$clientId' @@ -476,6 +510,7 @@ export interface FileRouteTypes { | '/contacts/$contactId' | '/hooks/poll-imap' | '/invoices/$invoiceId' + | '/pay/$id' | '/settings/client-fields' | '/settings/custom-fields' | '/settings/fees' @@ -497,6 +532,7 @@ export interface FileRouteTypes { | '/inbox/' | '/invoices/' | '/messages/' + | '/payments/' | '/settings/' | '/status/' | '/tasks/' @@ -512,6 +548,7 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRouteWithChildren SetupRoute: typeof SetupRoute AdminUsersRoute: typeof AdminUsersRoute + ApiStripeWebhookRoute: typeof ApiStripeWebhookRoute CasesCaseIdRoute: typeof CasesCaseIdRoute CasesNewRoute: typeof CasesNewRoute ClientsClientIdRoute: typeof ClientsClientIdRoute @@ -519,6 +556,7 @@ export interface RootRouteChildren { ContactsContactIdRoute: typeof ContactsContactIdRoute HooksPollImapRoute: typeof HooksPollImapRoute InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute + PayIdRoute: typeof PayIdRoute CalendarIndexRoute: typeof CalendarIndexRoute CasesIndexRoute: typeof CasesIndexRoute ClientsIndexRoute: typeof ClientsIndexRoute @@ -530,6 +568,7 @@ export interface RootRouteChildren { InboxIndexRoute: typeof InboxIndexRoute InvoicesIndexRoute: typeof InvoicesIndexRoute MessagesIndexRoute: typeof MessagesIndexRoute + PaymentsIndexRoute: typeof PaymentsIndexRoute StatusIndexRoute: typeof StatusIndexRoute TasksIndexRoute: typeof TasksIndexRoute DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute @@ -589,6 +628,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsIndexRouteImport parentRoute: typeof SettingsRoute } + '/payments/': { + id: '/payments/' + path: '/payments' + fullPath: '/payments/' + preLoaderRoute: typeof PaymentsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/messages/': { id: '/messages/' path: '/messages' @@ -736,6 +782,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsClientFieldsRouteImport parentRoute: typeof SettingsRoute } + '/pay/$id': { + id: '/pay/$id' + path: '/pay/$id' + fullPath: '/pay/$id' + preLoaderRoute: typeof PayIdRouteImport + parentRoute: typeof rootRouteImport + } '/invoices/$invoiceId': { id: '/invoices/$invoiceId' path: '/invoices/$invoiceId' @@ -785,6 +838,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CasesCaseIdRouteImport parentRoute: typeof rootRouteImport } + '/api/stripe-webhook': { + id: '/api/stripe-webhook' + path: '/api/stripe-webhook' + fullPath: '/api/stripe-webhook' + preLoaderRoute: typeof ApiStripeWebhookRouteImport + parentRoute: typeof rootRouteImport + } '/admin/users': { id: '/admin/users' path: '/admin/users' @@ -861,6 +921,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRouteWithChildren, SetupRoute: SetupRoute, AdminUsersRoute: AdminUsersRoute, + ApiStripeWebhookRoute: ApiStripeWebhookRoute, CasesCaseIdRoute: CasesCaseIdRoute, CasesNewRoute: CasesNewRoute, ClientsClientIdRoute: ClientsClientIdRoute, @@ -868,6 +929,7 @@ const rootRouteChildren: RootRouteChildren = { ContactsContactIdRoute: ContactsContactIdRoute, HooksPollImapRoute: HooksPollImapRoute, InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute, + PayIdRoute: PayIdRoute, CalendarIndexRoute: CalendarIndexRoute, CasesIndexRoute: CasesIndexRoute, ClientsIndexRoute: ClientsIndexRoute, @@ -879,6 +941,7 @@ const rootRouteChildren: RootRouteChildren = { InboxIndexRoute: InboxIndexRoute, InvoicesIndexRoute: InvoicesIndexRoute, MessagesIndexRoute: MessagesIndexRoute, + PaymentsIndexRoute: PaymentsIndexRoute, StatusIndexRoute: StatusIndexRoute, TasksIndexRoute: TasksIndexRoute, DocumentsPleadingNewRoute: DocumentsPleadingNewRoute, 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" }, + }); + }, + }, + }, +}); diff --git a/src/routes/pay.$id.tsx b/src/routes/pay.$id.tsx new file mode 100644 index 0000000..03a3068 --- /dev/null +++ b/src/routes/pay.$id.tsx @@ -0,0 +1,128 @@ +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 { formatCurrency } from "@/lib/format"; +import { getPublicPaymentRequest } from "@/lib/payments.functions"; +import { Scale } from "lucide-react"; + +export const Route = createFileRoute("/pay/$id")({ + validateSearch: (s: Record) => ({ + status: typeof s.status === "string" ? (s.status as string) : undefined, + }), + loader: async ({ params }) => { + return getPublicPaymentRequest({ data: { id: params.id } }); + }, + component: PayPage, + errorComponent: () => ( + +

Payment request not found.

+
+ ), +}); + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + Stage Law Firm, PLLC +
+ + {children} + +
+
+ ); +} + +function PayPage() { + const data = Route.useLoaderData(); + const search = useSearch({ from: "/pay/$id" }); + const [row, setRow] = useState(data); + + useEffect(() => { + setRow(data); + }, [data]); + + if (search.status === "success" || row.status === "paid") { + return ( + +
+ +

Payment received

+

+ Thank you, {row.recipient_name}. Your payment of{" "} + {formatCurrency(row.total_amount_cents / 100)} has been received. +

+
+
+ ); + } + + if (row.status === "canceled") { + return ( + +
+ +

Payment canceled

+

+ This payment request is no longer active. Please contact our office. +

+
+
+ ); + } + + return ( + +
+ +

Payment request

+

For {row.recipient_name}

+
+ + {row.description && ( +

{row.description}

+ )} + +
+
+ Amount + {formatCurrency(row.base_amount_cents / 100)} +
+
+ Processing fee + {formatCurrency(row.fee_amount_cents / 100)} +
+
+ Total + {formatCurrency(row.total_amount_cents / 100)} +
+
+ + {row.stripe_checkout_url ? ( + + ) : ( + Checkout link unavailable + )} + + {search.status === "cancel" && ( +

+ Payment was not completed. You can try again above. +

+ )} +
+ ); +} diff --git a/src/routes/payments.index.tsx b/src/routes/payments.index.tsx new file mode 100644 index 0000000..5d217f9 --- /dev/null +++ b/src/routes/payments.index.tsx @@ -0,0 +1,436 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import { ProtectedLayout } from "@/components/protected-layout"; +import { PageContainer, PageHeader } from "@/components/app-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { supabase } from "@/integrations/supabase/client"; +import { CreditCard, Plus, Copy, Mail, RefreshCw, X } from "lucide-react"; +import { formatCurrency, formatDateTime, statusBadgeClass } from "@/lib/format"; +import { toast } from "sonner"; +import { + createPaymentRequest, + refreshPaymentStatus, + cancelPaymentRequest, + markPaymentEmailSent, +} from "@/lib/payments.functions"; +import { useServerFn } from "@tanstack/react-start"; + +export const Route = createFileRoute("/payments/")({ + component: () => ( + + + + ), +}); + +interface Homeowner { + id: string; + first_name: string; + last_name: string; + email: string | null; + client_id: string; +} + +function PaymentsIndex() { + const [rows, setRows] = useState([]); + const [q, setQ] = useState(""); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(true); + + const create = useServerFn(createPaymentRequest); + const refresh = useServerFn(refreshPaymentStatus); + const cancel = useServerFn(cancelPaymentRequest); + const markSent = useServerFn(markPaymentEmailSent); + + const load = async () => { + const { data } = await supabase + .from("payment_requests") + .select("*, homeowner:homeowners(id, first_name, last_name)") + .order("created_at", { ascending: false }); + setRows(data ?? []); + setLoading(false); + }; + + useEffect(() => { + load(); + }, []); + + const filtered = useMemo(() => { + if (!q) return rows; + const s = q.toLowerCase(); + return rows.filter( + (r) => + r.recipient_name?.toLowerCase().includes(s) || + r.recipient_email?.toLowerCase().includes(s) || + r.description?.toLowerCase().includes(s), + ); + }, [rows, q]); + + const copyLink = async (r: any) => { + const url = `${window.location.origin}/pay/${r.id}`; + await navigator.clipboard.writeText(url); + toast.success("Payment link copied"); + }; + + const sendEmail = async (r: any) => { + if (!r.recipient_email) { + toast.error("No recipient email on file"); + return; + } + const url = `${window.location.origin}/pay/${r.id}`; + const html = ` +

Hello ${r.recipient_name},

+

You have a payment request for ${formatCurrency(r.total_amount_cents / 100)}${ + r.description ? ` (${r.description})` : "" + }.

+

Pay now

+

Or open this link: ${url}

+ `; + const { error } = await supabase.functions.invoke("send-smtp-email", { + body: { + to: r.recipient_email, + subject: `Payment request: ${formatCurrency(r.total_amount_cents / 100)}`, + html, + context: "payment_request", + }, + }); + if (error) { + toast.error(error.message); + return; + } + await markSent({ data: { id: r.id } }); + toast.success("Email sent"); + load(); + }; + + const onRefresh = async (id: string) => { + await refresh({ data: { id } }); + load(); + }; + + const onCancel = async (id: string) => { + if (!confirm("Cancel this payment request?")) return; + await cancel({ data: { id } }); + load(); + }; + + return ( + + setOpen(true)}> + New payment request + + } + /> + + + + setQ(e.target.value)} + /> + + + + + + + + + + + + + + + + + + + + {loading && ( + + + + )} + {!loading && filtered.length === 0 && ( + + + + )} + {filtered.map((r) => ( + + + + + + + + + + + ))} + +
RecipientDescriptionBaseFeeTotalStatusCreatedActions
+ Loading… +
+ + No payment requests yet. +
+
{r.recipient_name}
+ {r.recipient_email && ( +
{r.recipient_email}
+ )} +
{r.description ?? "—"} + {formatCurrency(r.base_amount_cents / 100)} + + {formatCurrency(r.fee_amount_cents / 100)} + + {formatCurrency(r.total_amount_cents / 100)} + + + {r.status} + + + {formatDateTime(r.created_at)} + +
+ + + {r.status === "pending" && ( + <> + + + + )} +
+
+
+
+ + { + setOpen(false); + load(); + }} + createFn={create} + /> +
+ ); +} + +function NewPaymentDialog({ + open, + onOpenChange, + onCreated, + createFn, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + onCreated: () => void; + createFn: ReturnType>; +}) { + const [homeowners, setHomeowners] = useState([]); + const [homeownerId, setHomeownerId] = useState(""); + const [recipientName, setRecipientName] = useState(""); + const [recipientEmail, setRecipientEmail] = useState(""); + const [description, setDescription] = useState(""); + const [amount, setAmount] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + (async () => { + const { data } = await supabase + .from("homeowners") + .select("id, first_name, last_name, email, client_id") + .is("archived_at", null) + .order("last_name"); + setHomeowners((data ?? []) as Homeowner[]); + })(); + }, [open]); + + useEffect(() => { + const ho = homeowners.find((h) => h.id === homeownerId); + if (ho) { + setRecipientName(`${ho.first_name} ${ho.last_name}`.trim()); + setRecipientEmail(ho.email ?? ""); + } + }, [homeownerId, homeowners]); + + const baseCents = Math.round((parseFloat(amount) || 0) * 100); + const preview = baseCents > 0 ? (baseCents + 30) / (1 - 0.029) : 0; + const totalPreview = Math.ceil(preview) / 100; + const feePreview = totalPreview - baseCents / 100; + + const submit = async () => { + if (!recipientName.trim() || baseCents < 100) { + toast.error("Recipient and amount (≥ $1.00) required"); + return; + } + setSubmitting(true); + try { + const ho = homeowners.find((h) => h.id === homeownerId); + await createFn({ + data: { + recipient_name: recipientName, + recipient_email: recipientEmail || undefined, + description: description || undefined, + base_amount_cents: baseCents, + homeowner_id: homeownerId || undefined, + client_id: ho?.client_id || undefined, + }, + }); + toast.success("Payment request created"); + setHomeownerId(""); + setRecipientName(""); + setRecipientEmail(""); + setDescription(""); + setAmount(""); + onCreated(); + } catch (e: any) { + toast.error(e?.message ?? "Failed to create"); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + New payment request + + A Stripe checkout link will be generated. The homeowner pays the base amount plus + processing fees (2.9% + $0.30). + + + +
+
+ + +
+ +
+
+ + setRecipientName(e.target.value)} /> +
+
+ + setRecipientEmail(e.target.value)} + /> +
+
+ +
+ +