diff --git a/package-lock.json b/package-lock.json
index f50425a..f7c28a3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -37,6 +37,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@stripe/stripe-js": "^9.6.0",
"@supabase/supabase-js": "^2.99.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
@@ -3603,6 +3604,15 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
+ "node_modules/@stripe/stripe-js": {
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.6.0.tgz",
+ "integrity": "sha512-v5MebYvJbddSRn15fknxTVwypJPzjeIXI1Q2HBxCBrQieWna8PC+RXEVUZ3F4ANAeILEj97HzFlr0r7CXvG7ZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.16"
+ }
+ },
"node_modules/@supabase/auth-js": {
"version": "2.99.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.1.tgz",
diff --git a/package.json b/package.json
index ccb19ed..60897bf 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@stripe/stripe-js": "^9.6.0",
"@supabase/supabase-js": "^2.99.1",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
diff --git a/src/app/helper/settings/payouts/page.tsx b/src/app/helper/settings/payouts/page.tsx
new file mode 100644
index 0000000..6d24d42
--- /dev/null
+++ b/src/app/helper/settings/payouts/page.tsx
@@ -0,0 +1,106 @@
+"use client"
+
+import { Sidebar } from "@/components/layout/sidebar"
+import { Header } from "@/components/layout/header"
+import { Button } from "@/components/ui/button"
+import { Landmark, Info } from "lucide-react"
+import { useUser } from "@/contexts/user-context"
+import { useStartHelperPaymentConnect } from "@/hooks/usePaymentConnect"
+import { useSetupPaymentMethod } from "@/hooks/useSetupPaymentMethod"
+import { usePaymentStatus } from "@/hooks/usePaymentStatus"
+import { connectStatusLabel } from "@/lib/payment-status"
+
+export default function HelperPayoutsPage() {
+ const { user } = useUser()
+ const userId = user?.id ?? ""
+ const startConnect = useStartHelperPaymentConnect()
+ const status = usePaymentStatus({ scope: "user", scopeId: userId })
+ const setupCard = useSetupPaymentMethod()
+ const hasCardOnFile = !!status.data?.default_payment_method_id
+
+ const handleSetupPayouts = async () => {
+ try {
+ const { url } = await startConnect.mutateAsync()
+ window.location.assign(url)
+ } catch (err) {
+ console.error("Failed to start helper Connect onboarding:", err)
+ }
+ }
+
+ const handleAddOrReplaceCard = async () => {
+ if (!userId) return
+ try {
+ const { checkoutUrl } = await setupCard.mutateAsync({ scope: "user" })
+ window.location.assign(checkoutUrl)
+ } catch (err) {
+ console.error("Failed to start card setup:", err)
+ }
+ }
+
+ const label = status.data
+ ? connectStatusLabel(status.data).label
+ : "Not set up"
+
+ return (
+
+
+
+
+
+
+
+
+
Set up payouts
+
+
+
+ Connect your Stripe account so we can send your payouts when you
+ help on tickets. We use Stripe for all payouts.
+
+
+
+
+ Status: {label}
+
+
+
+
+
+
+
Card on file
+
+
+
+ Used to place authorization holds when you open a support ticket
+ you pay for yourself. Stored securely with Stripe.
+
+
+
+
+ {hasCardOnFile
+ ? Card on file
+ : "No card yet"}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/settings/payment/page.tsx b/src/app/settings/payment/page.tsx
index ede7021..d5a76c1 100644
--- a/src/app/settings/payment/page.tsx
+++ b/src/app/settings/payment/page.tsx
@@ -11,6 +11,11 @@ import { Info, Landmark } from "lucide-react"
import { Logo } from "@/components/brand/logo"
import { useProject, useProjectPaymentSettings, useUpdateProjectPaymentSettings } from "@/hooks/useProject"
import { useStartPaymentConnect } from "@/hooks/usePaymentConnect"
+import { useSetupPaymentMethod } from "@/hooks/useSetupPaymentMethod"
+import { usePaymentStatus } from "@/hooks/usePaymentStatus"
+import { useOrgSpendingCaps, useUpdateOrgSpendingCaps } from "@/hooks/useOrgSpendingCaps"
+import { connectStatusLabel } from "@/lib/payment-status"
+import { formatCapDollars, parseCapDollars } from "@/lib/cap-format"
import { DistributionPreview } from "@/components/payment/distribution-preview"
import { useProjectSelection } from "@/contexts/project-context"
import { cn } from "@/lib/utils"
@@ -27,6 +32,63 @@ export default function PaymentSettingsPage() {
// Project (used for organization_id when starting Connect onboarding)
const { data: project } = useProject(projectId || "")
const startConnect = useStartPaymentConnect()
+ const orgId = project?.organization_id ?? ""
+ const orgStatus = usePaymentStatus({ scope: "organization", scopeId: orgId })
+ const orgStatusLabel = orgStatus.data
+ ? connectStatusLabel(orgStatus.data).label
+ : "Not set up"
+
+ // Spending caps state — bound to inputs as dollar strings; "" = no cap.
+ const orgCaps = useOrgSpendingCaps(orgId)
+ const updateCaps = useUpdateOrgSpendingCaps()
+ const [orgMonthlyCap, setOrgMonthlyCap] = useState("")
+ const [defaultUserCap, setDefaultUserCap] = useState("")
+ const [capsOriginal, setCapsOriginal] = useState<{ org: string; user: string } | null>(null)
+
+ /* eslint-disable react-hooks/set-state-in-effect */
+ useEffect(() => {
+ if (orgCaps.data) {
+ const org = formatCapDollars(orgCaps.data.monthly_spend_cap_smallest_unit)
+ const user = formatCapDollars(orgCaps.data.default_user_monthly_cap_smallest_unit)
+ setOrgMonthlyCap(org)
+ setDefaultUserCap(user)
+ setCapsOriginal({ org, user })
+ }
+ }, [orgCaps.data])
+ /* eslint-enable react-hooks/set-state-in-effect */
+
+ const hasCapsChanges = capsOriginal !== null &&
+ (orgMonthlyCap !== capsOriginal.org || defaultUserCap !== capsOriginal.user)
+
+ const handleSaveCaps = async () => {
+ if (!orgId) return
+ try {
+ await updateCaps.mutateAsync({
+ organizationId: orgId,
+ monthlySpendCapSmallestUnit: parseCapDollars(orgMonthlyCap),
+ defaultUserMonthlyCapSmallestUnit: parseCapDollars(defaultUserCap),
+ })
+ setCapsOriginal({ org: orgMonthlyCap, user: defaultUserCap })
+ } catch (err) {
+ console.error("Failed to save spending caps:", err)
+ }
+ }
+
+ const setupCard = useSetupPaymentMethod()
+ const hasCardOnFile = !!orgStatus.data?.default_payment_method_id
+
+ const handleAddOrReplaceCard = async () => {
+ if (!orgId) return
+ try {
+ const { checkoutUrl } = await setupCard.mutateAsync({
+ scope: "organization",
+ organizationId: orgId,
+ })
+ window.location.assign(checkoutUrl)
+ } catch (err) {
+ console.error("Failed to start card setup:", err)
+ }
+ }
const handleSetupPayouts = async () => {
if (!project?.organization_id) return
@@ -723,6 +785,102 @@ export default function PaymentSettingsPage() {
+ {/* Card on file section */}
+
+
+
+ Used to place authorization holds on employer-billed tickets.
+ Stored securely with Stripe.
+
+
+
+
+ {hasCardOnFile
+ ? Card on file
+ : "No card yet"}
+
+
+
+
+ {/* Spending caps Section */}
+
+
+
+
Spending caps
+
+
+
+
+
+
+ Monthly limits enforced when employer-billed tickets are
+ authorized. Leave blank for no cap.
+
+
+
+
+
+ Org-wide monthly cap
+
+
+
+ setOrgMonthlyCap(e.target.value)}
+ className={cn(
+ "w-[230px] text-right pr-[3px] border-border"
+ )}
+ placeholder="No cap"
+ disabled={!orgId}
+ />
+ USD/month
+
+
+
+
+
+ Default per-user monthly cap
+
+
+
+ setDefaultUserCap(e.target.value)}
+ className={cn(
+ "w-[230px] text-right pr-[3px] border-border"
+ )}
+ placeholder="No cap"
+ disabled={!orgId}
+ />
+ USD/user/month
+
+
+
+
+
{/* Set up payouts Section */}
@@ -755,14 +913,19 @@ export default function PaymentSettingsPage() {
payouts from support.
-
+
+
+
+ Status: {orgStatusLabel}
+
+
diff --git a/src/app/support/chat/page.tsx b/src/app/support/chat/page.tsx
index 91ae983..4d49907 100644
--- a/src/app/support/chat/page.tsx
+++ b/src/app/support/chat/page.tsx
@@ -13,6 +13,8 @@ import { useState, useEffect, useMemo } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { useProject, useProjectBySlug, useProjectPaymentSettings, useProjectBranding, useProjects } from "@/hooks/useProject"
import { useCreateTicket } from "@/hooks/useTickets"
+import { useAuthorizeTicket } from "@/hooks/useAuthorizeTicket"
+import { ConfirmPaymentModal } from "@/components/payment/ConfirmPaymentModal"
import { useTicketMessages, useSendMessage } from "@/hooks/useTicketMessages"
import { useRealtimeMessages } from "@/hooks/useRealtimeMessages"
import { useTicketParticipants, useEnsureParticipant, type ParticipantWithUser } from "@/hooks/useTicketParticipants"
@@ -65,6 +67,8 @@ export default function UserSupportChatPage() {
const [projectSearch, setProjectSearch] = useState("")
/** When user creates ticket without being signed in, first message is not persisted; we show it locally. */
const [pendingFirstMessage, setPendingFirstMessage] = useState(null)
+ /** Surfaces the ConfirmPaymentModal when an off-session hold lands in requires_action (SCA). */
+ const [pendingSca, setPendingSca] = useState<{ ticketId: string; clientSecret: string } | null>(null)
// Get project_id and optional existing ticket from query params
const projectIdParam = searchParams.get("project")
@@ -159,6 +163,7 @@ export default function UserSupportChatPage() {
// Ticket creation and messaging
const createTicket = useCreateTicket()
+ const authorizeTicket = useAuthorizeTicket()
const sendMessage = useSendMessage()
const ensureParticipant = useEnsureParticipant()
const { data: messagesData } = useTicketMessages(ticketId)
@@ -417,6 +422,29 @@ export default function UserSupportChatPage() {
priority: "medium",
})
+ try {
+ const authResult = await authorizeTicket.mutateAsync({
+ ticketId: ticket.id,
+ payerType: "user",
+ })
+ if (authResult.status === "requires_checkout") {
+ window.location.assign(authResult.checkoutUrl)
+ return
+ }
+ if (authResult.status === "requires_action") {
+ if (authResult.clientSecret) {
+ setPendingSca({ ticketId: ticket.id, clientSecret: authResult.clientSecret })
+ return
+ }
+ console.warn(`Ticket ${ticket.id} requires_action but no client_secret returned`)
+ }
+ if (authResult.status === "failed") {
+ console.warn(`Ticket ${ticket.id} authorize failed; manual resolution needed.`)
+ }
+ } catch (err) {
+ console.error("Failed to authorize ticket payment:", err)
+ }
+
setTicketCreated(true)
setTicketId(ticket.id)
const firstMessageContent = message.trim()
@@ -653,6 +681,19 @@ export default function UserSupportChatPage() {
) : undefined
}
/>
+ {pendingSca && (
+ {
+ if (status === "authorized") {
+ setTicketCreated(true)
+ setTicketId(pendingSca.ticketId)
+ }
+ setPendingSca(null)
+ }}
+ onCancel={() => setPendingSca(null)}
+ />
+ )}
)
}
diff --git a/src/app/support/layout.tsx b/src/app/support/layout.tsx
index deccf86..29266b7 100644
--- a/src/app/support/layout.tsx
+++ b/src/app/support/layout.tsx
@@ -4,6 +4,7 @@ import type React from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Logo } from "@/components/brand/logo"
+import { SupportSandboxBanner } from "@/components/layout/support-sandbox-banner"
import { useParams, useSearchParams } from "next/navigation"
export default function SupportLayout({
@@ -40,6 +41,7 @@ export default function SupportLayout({
+
{children}
)
diff --git a/src/components/layout/__tests__/support-sandbox-banner.test.tsx b/src/components/layout/__tests__/support-sandbox-banner.test.tsx
new file mode 100644
index 0000000..47d0bb0
--- /dev/null
+++ b/src/components/layout/__tests__/support-sandbox-banner.test.tsx
@@ -0,0 +1,34 @@
+import { describe, it, expect, vi } from "vitest"
+import { render, screen } from "@testing-library/react"
+
+const useProjectBySlug = vi.fn()
+const useProject = vi.fn()
+vi.mock("@/hooks/useProject", () => ({
+ useProjectBySlug: (slug: string) => useProjectBySlug(slug),
+ useProject: (id: string) => useProject(id),
+}))
+
+import { SupportSandboxBanner } from "../support-sandbox-banner"
+
+describe("SupportSandboxBanner", () => {
+ it("renders the sandbox warning when the slug-resolved project is sandbox", () => {
+ useProjectBySlug.mockReturnValue({ data: { sandbox: true, name: "Acme" } })
+ useProject.mockReturnValue({ data: null })
+ render()
+ expect(screen.getByText(/sandbox/i)).toBeInTheDocument()
+ })
+
+ it("renders nothing when the resolved project is not sandbox", () => {
+ useProjectBySlug.mockReturnValue({ data: { sandbox: false, name: "Acme" } })
+ useProject.mockReturnValue({ data: null })
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+
+ it("falls back to the project-id lookup when no slug is provided", () => {
+ useProjectBySlug.mockReturnValue({ data: null })
+ useProject.mockReturnValue({ data: { sandbox: true, name: "Acme" } })
+ render()
+ expect(screen.getByText(/sandbox/i)).toBeInTheDocument()
+ })
+})
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index fd63084..0c5f240 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -183,6 +183,7 @@ export function Sidebar({ className }: SidebarProps) {
href: "#",
icon: "fi-rr-settings",
subItems: [
+ { name: "Payouts", href: "/helper/settings/payouts", icon: "fi-rr-credit-card" },
{ name: "Profile", href: "/helper/settings/profile", icon: "fi-rr-user" },
{ name: "Availability", href: "/helper/settings/availability", icon: "fi-rr-list-check" },
],
diff --git a/src/components/layout/support-sandbox-banner.tsx b/src/components/layout/support-sandbox-banner.tsx
new file mode 100644
index 0000000..f9d9462
--- /dev/null
+++ b/src/components/layout/support-sandbox-banner.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import { Info } from "lucide-react"
+import { useProject, useProjectBySlug } from "@/hooks/useProject"
+
+interface Props {
+ slug: string | null
+ projectId: string | null
+}
+
+/**
+ * Standalone sandbox banner for the public /support layout, which has no
+ * ProjectProvider. We resolve the project either by slug (preferred — the
+ * URL form is `/support/[slug]/...`) or by the `project` query param. When
+ * `project.sandbox === true` we render an amber strip warning that no real
+ * charges happen; otherwise we render nothing.
+ */
+export function SupportSandboxBanner({ slug, projectId }: Props) {
+ const bySlug = useProjectBySlug(slug ?? "")
+ const byId = useProject(projectId ?? "")
+ const project = (slug ? bySlug.data : byId.data) as { sandbox?: boolean } | null | undefined
+ if (!project?.sandbox) return null
+
+ return (
+
+
+
+ This is a sandbox project. Payments use Stripe test mode — no real charges will be made.
+
+
+ )
+}
diff --git a/src/components/payment/ConfirmPaymentModal.tsx b/src/components/payment/ConfirmPaymentModal.tsx
new file mode 100644
index 0000000..26ba7a3
--- /dev/null
+++ b/src/components/payment/ConfirmPaymentModal.tsx
@@ -0,0 +1,74 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { getStripe } from "@/lib/stripe"
+
+interface Props {
+ clientSecret: string
+ onResolved: (status: "authorized" | "requires_action") => void
+ onCancel: () => void
+}
+
+/**
+ * Lightweight modal: takes a PaymentIntent client_secret and asks Stripe.js
+ * to confirm it. On success (PI moves to requires_capture / succeeded) we
+ * call onResolved("authorized"). If Stripe still wants another step, we
+ * call onResolved("requires_action") so the caller can decide what to do
+ * next.
+ */
+export function ConfirmPaymentModal({ clientSecret, onResolved, onCancel }: Props) {
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleConfirm = async () => {
+ setBusy(true)
+ setError(null)
+ try {
+ const stripe = await getStripe()
+ if (!stripe) {
+ setError("Stripe is not configured.")
+ return
+ }
+ const result = await stripe.confirmCardPayment(clientSecret)
+ if (result.error) {
+ setError(result.error.message ?? "Payment confirmation failed.")
+ return
+ }
+ const piStatus = result.paymentIntent?.status
+ if (piStatus === "requires_capture" || piStatus === "succeeded") {
+ onResolved("authorized")
+ } else {
+ onResolved("requires_action")
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Payment confirmation failed.")
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
Confirm your payment
+
+ Your bank requires an extra confirmation step before we can place a hold for this ticket.
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/payment/__tests__/ConfirmPaymentModal.test.tsx b/src/components/payment/__tests__/ConfirmPaymentModal.test.tsx
new file mode 100644
index 0000000..be32558
--- /dev/null
+++ b/src/components/payment/__tests__/ConfirmPaymentModal.test.tsx
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+
+const confirmCardPayment = vi.fn()
+vi.mock("@/lib/stripe", () => ({
+ getStripe: () =>
+ Promise.resolve({ confirmCardPayment }),
+}))
+
+import { ConfirmPaymentModal } from "../ConfirmPaymentModal"
+
+describe("ConfirmPaymentModal", () => {
+ beforeEach(() => {
+ confirmCardPayment.mockReset()
+ })
+
+ it("calls stripe.confirmCardPayment with the client secret on click", async () => {
+ confirmCardPayment.mockResolvedValueOnce({
+ paymentIntent: { status: "requires_capture" },
+ })
+ const onResolved = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByRole("button", { name: /confirm payment/i }))
+ await waitFor(() => expect(confirmCardPayment).toHaveBeenCalledWith("pi_1_secret_x"))
+ await waitFor(() => expect(onResolved).toHaveBeenCalledWith("authorized"))
+ })
+
+ it("reports failure when Stripe returns an error", async () => {
+ confirmCardPayment.mockResolvedValueOnce({
+ error: { message: "Your card was declined." },
+ })
+ const onResolved = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByRole("button", { name: /confirm payment/i }))
+ await waitFor(() =>
+ expect(screen.getByText(/your card was declined/i)).toBeInTheDocument(),
+ )
+ expect(onResolved).not.toHaveBeenCalled()
+ })
+
+ it("calls onCancel when the user clicks cancel", () => {
+ const onCancel = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByRole("button", { name: /cancel/i }))
+ expect(onCancel).toHaveBeenCalled()
+ })
+})
diff --git a/src/hooks/__tests__/useAuthorizeTicket.test.ts b/src/hooks/__tests__/useAuthorizeTicket.test.ts
new file mode 100644
index 0000000..2df028e
--- /dev/null
+++ b/src/hooks/__tests__/useAuthorizeTicket.test.ts
@@ -0,0 +1,153 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement } from "react";
+
+vi.mock("@/lib/supabase/client", () => ({
+ supabase: {
+ functions: { invoke: vi.fn() },
+ },
+}));
+
+import { supabase } from "@/lib/supabase/client";
+import { useAuthorizeTicket } from "../useAuthorizeTicket";
+
+function makeWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) =>
+ createElement(QueryClientProvider, { client: queryClient }, children);
+}
+
+describe("useAuthorizeTicket", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("returns the authorized response when the backend places a hold", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: {
+ payment_id: "pay-1",
+ stripe_payment_intent_id: "pi_1",
+ status: "authorized",
+ hold_amount_smallest_unit: 5500,
+ hold_expires_at: "2026-06-01T00:00:00Z",
+ },
+ error: null,
+ } as never);
+ const { result } = renderHook(() => useAuthorizeTicket(), {
+ wrapper: makeWrapper(),
+ });
+ const out = await result.current.mutateAsync({
+ ticketId: "ticket-1",
+ payerType: "user",
+ });
+ expect(out.status).toBe("authorized");
+ expect(supabase.functions.invoke).toHaveBeenCalledWith(
+ "payments-authorize-ticket",
+ { body: { ticket_id: "ticket-1", payer_type: "user" } },
+ );
+ });
+
+ it("returns requires_checkout with a checkout URL", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: {
+ ticket_id: "ticket-2",
+ status: "requires_checkout",
+ checkout_url: "https://checkout.stripe.com/c/pay/xyz",
+ hold_amount_smallest_unit: 5500,
+ },
+ error: null,
+ } as never);
+ const { result } = renderHook(() => useAuthorizeTicket(), {
+ wrapper: makeWrapper(),
+ });
+ const out = await result.current.mutateAsync({
+ ticketId: "ticket-2",
+ payerType: "user",
+ });
+ expect(out.status).toBe("requires_checkout");
+ if (out.status === "requires_checkout") {
+ expect(out.checkoutUrl).toBe("https://checkout.stripe.com/c/pay/xyz");
+ }
+ });
+
+ it("returns sla_covered for SLA-tagged tickets", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: {
+ ticket_id: "ticket-3",
+ status: "sla_covered",
+ sla_id: "sla-1",
+ hold_amount_smallest_unit: 0,
+ message: "Ticket is covered by an SLA — no hold needed",
+ },
+ error: null,
+ } as never);
+ const { result } = renderHook(() => useAuthorizeTicket(), {
+ wrapper: makeWrapper(),
+ });
+ const out = await result.current.mutateAsync({
+ ticketId: "ticket-3",
+ payerType: "user",
+ });
+ expect(out.status).toBe("sla_covered");
+ });
+
+ it("forwards organizationId when payerType is organization", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: { status: "authorized" },
+ error: null,
+ } as never);
+ const { result } = renderHook(() => useAuthorizeTicket(), {
+ wrapper: makeWrapper(),
+ });
+ await result.current.mutateAsync({
+ ticketId: "ticket-4",
+ payerType: "organization",
+ organizationId: "org-1",
+ });
+ expect(supabase.functions.invoke).toHaveBeenCalledWith(
+ "payments-authorize-ticket",
+ { body: { ticket_id: "ticket-4", payer_type: "organization", organization_id: "org-1" } },
+ );
+ });
+
+ it("forwards client_secret on requires_action responses", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: {
+ payment_id: "pay-action",
+ stripe_payment_intent_id: "pi_action",
+ status: "requires_action",
+ client_secret: "pi_action_secret_abc",
+ hold_amount_smallest_unit: 5500,
+ hold_expires_at: "2026-06-01T00:00:00Z",
+ },
+ error: null,
+ } as never);
+ const { result } = renderHook(() => useAuthorizeTicket(), {
+ wrapper: makeWrapper(),
+ });
+ const out = await result.current.mutateAsync({
+ ticketId: "ticket-action",
+ payerType: "user",
+ });
+ expect(out.status).toBe("requires_action");
+ if (out.status === "requires_action") {
+ expect(out.clientSecret).toBe("pi_action_secret_abc");
+ }
+ });
+
+ it("rejects when the function returns an error", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: null,
+ error: { message: "monthly_cap_exceeded" },
+ } as never);
+ const { result } = renderHook(() => useAuthorizeTicket(), {
+ wrapper: makeWrapper(),
+ });
+ await waitFor(async () => {
+ await expect(
+ result.current.mutateAsync({ ticketId: "ticket-5", payerType: "user" }),
+ ).rejects.toThrow("monthly_cap_exceeded");
+ });
+ });
+});
diff --git a/src/hooks/__tests__/useOrgSpendingCaps.test.ts b/src/hooks/__tests__/useOrgSpendingCaps.test.ts
new file mode 100644
index 0000000..64d4644
--- /dev/null
+++ b/src/hooks/__tests__/useOrgSpendingCaps.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement } from "react";
+
+vi.mock("@/lib/supabase/client", () => ({
+ supabase: {
+ from: vi.fn(),
+ },
+}));
+
+import { supabase } from "@/lib/supabase/client";
+import { useOrgSpendingCaps, useUpdateOrgSpendingCaps } from "../useOrgSpendingCaps";
+
+function makeWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) =>
+ createElement(QueryClientProvider, { client: queryClient }, children);
+}
+
+describe("useOrgSpendingCaps", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("returns the org's cap fields", async () => {
+ const chain = {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ maybeSingle: vi.fn().mockResolvedValue({
+ data: {
+ monthly_spend_cap_smallest_unit: 100000,
+ default_user_monthly_cap_smallest_unit: 20000,
+ },
+ error: null,
+ }),
+ };
+ vi.mocked(supabase.from).mockReturnValue(chain as unknown as ReturnType);
+
+ const { result } = renderHook(() => useOrgSpendingCaps("org-1"), {
+ wrapper: makeWrapper(),
+ });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(supabase.from).toHaveBeenCalledWith("organizations_payments_config");
+ expect(result.current.data?.monthly_spend_cap_smallest_unit).toBe(100000);
+ expect(result.current.data?.default_user_monthly_cap_smallest_unit).toBe(20000);
+ });
+
+ it("is disabled when orgId is empty", () => {
+ const chain = {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
+ };
+ vi.mocked(supabase.from).mockReturnValue(chain as unknown as ReturnType);
+
+ const { result } = renderHook(() => useOrgSpendingCaps(""), {
+ wrapper: makeWrapper(),
+ });
+ expect(result.current.fetchStatus).toBe("idle");
+ });
+});
+
+describe("useUpdateOrgSpendingCaps", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("sends an update to organizations_payments_config with both cap fields", async () => {
+ const eq = vi.fn().mockResolvedValue({ data: null, error: null });
+ const update = vi.fn().mockReturnValue({ eq });
+ vi.mocked(supabase.from).mockReturnValue(
+ { update } as unknown as ReturnType,
+ );
+
+ const { result } = renderHook(() => useUpdateOrgSpendingCaps(), {
+ wrapper: makeWrapper(),
+ });
+
+ await result.current.mutateAsync({
+ organizationId: "org-1",
+ monthlySpendCapSmallestUnit: 100000,
+ defaultUserMonthlyCapSmallestUnit: null,
+ });
+
+ expect(supabase.from).toHaveBeenCalledWith("organizations_payments_config");
+ expect(update).toHaveBeenCalledWith({
+ monthly_spend_cap_smallest_unit: 100000,
+ default_user_monthly_cap_smallest_unit: null,
+ });
+ expect(eq).toHaveBeenCalledWith("id", "org-1");
+ });
+
+ it("rejects when the update returns an error", async () => {
+ const eq = vi.fn().mockResolvedValue({ data: null, error: { message: "no perms" } });
+ const update = vi.fn().mockReturnValue({ eq });
+ vi.mocked(supabase.from).mockReturnValue(
+ { update } as unknown as ReturnType,
+ );
+
+ const { result } = renderHook(() => useUpdateOrgSpendingCaps(), {
+ wrapper: makeWrapper(),
+ });
+ await waitFor(async () => {
+ await expect(
+ result.current.mutateAsync({
+ organizationId: "org-1",
+ monthlySpendCapSmallestUnit: 100,
+ defaultUserMonthlyCapSmallestUnit: 50,
+ }),
+ ).rejects.toThrow("no perms");
+ });
+ });
+});
diff --git a/src/hooks/__tests__/usePaymentConnect.test.ts b/src/hooks/__tests__/usePaymentConnect.test.ts
index ac76bff..a1a150b 100644
--- a/src/hooks/__tests__/usePaymentConnect.test.ts
+++ b/src/hooks/__tests__/usePaymentConnect.test.ts
@@ -68,3 +68,52 @@ describe("useStartPaymentConnect", () => {
});
});
});
+
+import { useStartHelperPaymentConnect } from "../usePaymentConnect";
+
+describe("useStartHelperPaymentConnect", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("invokes payments-create-account then payments-link-account with scope=user", async () => {
+ vi.mocked(supabase.functions.invoke)
+ .mockResolvedValueOnce({
+ data: { scope: "user", stripe_account_id: "acct_helper_1" },
+ error: null,
+ } as never)
+ .mockResolvedValueOnce({
+ data: { scope: "user", url: "https://connect.stripe.com/onboarding/helper" },
+ error: null,
+ } as never);
+
+ const { result } = renderHook(() => useStartHelperPaymentConnect(), {
+ wrapper: makeWrapper(),
+ });
+
+ const out = await result.current.mutateAsync();
+
+ expect(out).toEqual({ url: "https://connect.stripe.com/onboarding/helper" });
+ expect(supabase.functions.invoke).toHaveBeenNthCalledWith(
+ 1,
+ "payments-create-account",
+ { body: { scope: "user" } },
+ );
+ expect(supabase.functions.invoke).toHaveBeenNthCalledWith(
+ 2,
+ "payments-link-account",
+ { body: { scope: "user" } },
+ );
+ });
+
+ it("rejects if payments-create-account returns an error", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: null,
+ error: { message: "nope" },
+ } as never);
+ const { result } = renderHook(() => useStartHelperPaymentConnect(), {
+ wrapper: makeWrapper(),
+ });
+ await waitFor(async () => {
+ await expect(result.current.mutateAsync()).rejects.toThrow("nope");
+ });
+ });
+});
diff --git a/src/hooks/__tests__/usePaymentStatus.test.ts b/src/hooks/__tests__/usePaymentStatus.test.ts
new file mode 100644
index 0000000..e7b3d73
--- /dev/null
+++ b/src/hooks/__tests__/usePaymentStatus.test.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement } from "react";
+
+vi.mock("@/lib/supabase/client", () => ({
+ supabase: {
+ from: vi.fn(),
+ },
+}));
+
+import { supabase } from "@/lib/supabase/client";
+import { usePaymentStatus } from "../usePaymentStatus";
+
+function makeWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) =>
+ createElement(QueryClientProvider, { client: queryClient }, children);
+}
+
+function mockSingleResolve(resolveWith: { data: unknown; error: unknown }) {
+ const chain = {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ maybeSingle: vi.fn().mockResolvedValue(resolveWith),
+ };
+ return chain;
+}
+
+describe("usePaymentStatus", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("queries organizations_payments_config when scope is organization", async () => {
+ const chain = mockSingleResolve({
+ data: {
+ stripe_account_id: "acct_org_1",
+ stripe_details_submitted: true,
+ stripe_charges_enabled: true,
+ stripe_payouts_enabled: true,
+ },
+ error: null,
+ });
+ vi.mocked(supabase.from).mockReturnValue(chain as unknown as ReturnType);
+
+ const { result } = renderHook(
+ () => usePaymentStatus({ scope: "organization", scopeId: "org-1" }),
+ { wrapper: makeWrapper() },
+ );
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(supabase.from).toHaveBeenCalledWith("organizations_payments_config");
+ expect(result.current.data?.stripe_account_id).toBe("acct_org_1");
+ });
+
+ it("queries users_payments_config when scope is user", async () => {
+ const chain = mockSingleResolve({
+ data: {
+ stripe_account_id: null,
+ stripe_details_submitted: false,
+ stripe_charges_enabled: false,
+ stripe_payouts_enabled: false,
+ },
+ error: null,
+ });
+ vi.mocked(supabase.from).mockReturnValue(chain as unknown as ReturnType);
+
+ const { result } = renderHook(
+ () => usePaymentStatus({ scope: "user", scopeId: "user-1" }),
+ { wrapper: makeWrapper() },
+ );
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(supabase.from).toHaveBeenCalledWith("users_payments_config");
+ expect(result.current.data?.stripe_account_id).toBe(null);
+ });
+
+ it("is disabled when scopeId is empty", () => {
+ const chain = mockSingleResolve({ data: null, error: null });
+ vi.mocked(supabase.from).mockReturnValue(chain as unknown as ReturnType);
+
+ const { result } = renderHook(
+ () => usePaymentStatus({ scope: "user", scopeId: "" }),
+ { wrapper: makeWrapper() },
+ );
+ expect(result.current.fetchStatus).toBe("idle");
+ });
+});
diff --git a/src/hooks/__tests__/useSetupPaymentMethod.test.ts b/src/hooks/__tests__/useSetupPaymentMethod.test.ts
new file mode 100644
index 0000000..47884cd
--- /dev/null
+++ b/src/hooks/__tests__/useSetupPaymentMethod.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement } from "react";
+
+vi.mock("@/lib/supabase/client", () => ({
+ supabase: {
+ functions: { invoke: vi.fn() },
+ },
+}));
+
+import { supabase } from "@/lib/supabase/client";
+import { useSetupPaymentMethod } from "../useSetupPaymentMethod";
+
+function makeWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) =>
+ createElement(QueryClientProvider, { client: queryClient }, children);
+}
+
+describe("useSetupPaymentMethod", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("invokes payments-setup-method with scope=user and returns the checkout URL", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: {
+ scope: "user",
+ checkout_url: "https://checkout.stripe.com/c/pay/abc",
+ stripe_customer_id: "cus_u1",
+ },
+ error: null,
+ } as never);
+
+ const { result } = renderHook(() => useSetupPaymentMethod(), {
+ wrapper: makeWrapper(),
+ });
+
+ const out = await result.current.mutateAsync({ scope: "user" });
+
+ expect(out.checkoutUrl).toBe("https://checkout.stripe.com/c/pay/abc");
+ expect(supabase.functions.invoke).toHaveBeenCalledWith(
+ "payments-setup-method",
+ { body: { scope: "user" } },
+ );
+ });
+
+ it("invokes with scope=organization and forwards organizationId", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: {
+ scope: "organization",
+ checkout_url: "https://checkout.stripe.com/c/pay/org",
+ stripe_customer_id: "cus_org",
+ },
+ error: null,
+ } as never);
+
+ const { result } = renderHook(() => useSetupPaymentMethod(), {
+ wrapper: makeWrapper(),
+ });
+
+ await result.current.mutateAsync({ scope: "organization", organizationId: "org-1" });
+
+ expect(supabase.functions.invoke).toHaveBeenCalledWith(
+ "payments-setup-method",
+ { body: { scope: "organization", organization_id: "org-1" } },
+ );
+ });
+
+ it("rejects when payments-setup-method returns an error", async () => {
+ vi.mocked(supabase.functions.invoke).mockResolvedValueOnce({
+ data: null,
+ error: { message: "boom" },
+ } as never);
+ const { result } = renderHook(() => useSetupPaymentMethod(), {
+ wrapper: makeWrapper(),
+ });
+ await waitFor(async () => {
+ await expect(
+ result.current.mutateAsync({ scope: "user" }),
+ ).rejects.toThrow("boom");
+ });
+ });
+});
diff --git a/src/hooks/useAuthorizeTicket.ts b/src/hooks/useAuthorizeTicket.ts
new file mode 100644
index 0000000..ff1b5e4
--- /dev/null
+++ b/src/hooks/useAuthorizeTicket.ts
@@ -0,0 +1,91 @@
+import { useMutation } from "@tanstack/react-query"
+import { supabase } from "@/lib/supabase/client"
+
+export type AuthorizeTicketStatus =
+ | "authorized"
+ | "requires_action"
+ | "requires_checkout"
+ | "sla_covered"
+ | "failed"
+ | "pending"
+
+interface AuthorizeArgs {
+ ticketId: string
+ payerType: "user" | "organization"
+ organizationId?: string
+}
+
+export type AuthorizeTicketResult =
+ | {
+ status: "requires_checkout"
+ ticketId?: string
+ checkoutUrl: string
+ holdAmountSmallestUnit?: number
+ }
+ | {
+ status: "sla_covered"
+ ticketId?: string
+ slaId?: string
+ message?: string
+ }
+ | {
+ status: Exclude
+ paymentId?: string
+ stripePaymentIntentId?: string
+ holdAmountSmallestUnit?: number
+ holdExpiresAt?: string
+ clientSecret?: string
+ }
+
+/**
+ * Invoke the backend `payments-authorize-ticket` edge function after a ticket
+ * is created. Returns a discriminated union: the caller decides whether to
+ * redirect (requires_checkout), no-op (sla_covered / authorized), or surface
+ * an error (requires_action / failed).
+ */
+export function useAuthorizeTicket() {
+ return useMutation({
+ mutationFn: async (args: AuthorizeArgs): Promise => {
+ const body: Record = {
+ ticket_id: args.ticketId,
+ payer_type: args.payerType,
+ }
+ if (args.payerType === "organization") {
+ if (!args.organizationId) {
+ throw new Error("organizationId is required for payer_type=organization")
+ }
+ body.organization_id = args.organizationId
+ }
+ const resp = await supabase.functions.invoke("payments-authorize-ticket", { body })
+ if (resp.error) {
+ throw new Error(resp.error.message || "Failed to authorize ticket")
+ }
+ const data = (resp.data ?? {}) as Record
+ const status = data.status as AuthorizeTicketStatus
+ if (status === "requires_checkout") {
+ return {
+ status,
+ ticketId: data.ticket_id as string | undefined,
+ checkoutUrl: data.checkout_url as string,
+ holdAmountSmallestUnit: data.hold_amount_smallest_unit as number | undefined,
+ }
+ }
+ if (status === "sla_covered") {
+ return {
+ status,
+ ticketId: data.ticket_id as string | undefined,
+ slaId: data.sla_id as string | undefined,
+ message: data.message as string | undefined,
+ }
+ }
+ return {
+ status,
+ paymentId: data.payment_id as string | undefined,
+ stripePaymentIntentId: data.stripe_payment_intent_id as string | undefined,
+ holdAmountSmallestUnit: data.hold_amount_smallest_unit as number | undefined,
+ holdExpiresAt: data.hold_expires_at as string | undefined,
+ clientSecret: data.client_secret as string | undefined,
+ }
+ },
+ })
+}
diff --git a/src/hooks/useOrgSpendingCaps.ts b/src/hooks/useOrgSpendingCaps.ts
new file mode 100644
index 0000000..21e11ce
--- /dev/null
+++ b/src/hooks/useOrgSpendingCaps.ts
@@ -0,0 +1,60 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { supabase } from "@/lib/supabase/client"
+
+export interface OrgSpendingCaps {
+ monthly_spend_cap_smallest_unit: number | null
+ default_user_monthly_cap_smallest_unit: number | null
+}
+
+/**
+ * Read the org's two monthly spending caps. Null means "no cap" / unlimited.
+ */
+export function useOrgSpendingCaps(orgId: string) {
+ return useQuery({
+ queryKey: ["org-spending-caps", orgId],
+ enabled: !!orgId,
+ retry: false,
+ staleTime: 60_000,
+ refetchOnWindowFocus: false,
+ queryFn: async (): Promise => {
+ const { data, error } = await supabase
+ .from("organizations_payments_config")
+ .select(
+ "monthly_spend_cap_smallest_unit, default_user_monthly_cap_smallest_unit",
+ )
+ .eq("id", orgId)
+ .maybeSingle()
+ if (error) throw error
+ return data as OrgSpendingCaps | null
+ },
+ })
+}
+
+export interface UpdateOrgSpendingCapsArgs {
+ organizationId: string
+ monthlySpendCapSmallestUnit: number | null
+ defaultUserMonthlyCapSmallestUnit: number | null
+}
+
+/**
+ * Update both cap fields on `organizations_payments_config`. Null clears
+ * the cap.
+ */
+export function useUpdateOrgSpendingCaps() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (args: UpdateOrgSpendingCapsArgs) => {
+ const { error } = await supabase
+ .from("organizations_payments_config")
+ .update({
+ monthly_spend_cap_smallest_unit: args.monthlySpendCapSmallestUnit,
+ default_user_monthly_cap_smallest_unit: args.defaultUserMonthlyCapSmallestUnit,
+ })
+ .eq("id", args.organizationId)
+ if (error) throw new Error(error.message || "Failed to update spending caps")
+ },
+ onSuccess: (_data, args) => {
+ queryClient.invalidateQueries({ queryKey: ["org-spending-caps", args.organizationId] })
+ },
+ })
+}
diff --git a/src/hooks/usePaymentConnect.ts b/src/hooks/usePaymentConnect.ts
index dfc9715..4b194a9 100644
--- a/src/hooks/usePaymentConnect.ts
+++ b/src/hooks/usePaymentConnect.ts
@@ -31,3 +31,31 @@ export function useStartPaymentConnect() {
},
})
}
+
+/**
+ * Helper-side Connect onboarding: invokes payments-create-account (scope=user)
+ * then payments-link-account, returning the Stripe-hosted onboarding URL the
+ * caller should redirect to. No organizationId — the authenticated user is
+ * the scope.
+ */
+export function useStartHelperPaymentConnect() {
+ return useMutation({
+ mutationFn: async () => {
+ const created = await supabase.functions.invoke("payments-create-account", {
+ body: { scope: "user" },
+ })
+ if (created.error) {
+ throw new Error(created.error.message || "Failed to create Connect account")
+ }
+ const linked = await supabase.functions.invoke("payments-link-account", {
+ body: { scope: "user" },
+ })
+ if (linked.error) {
+ throw new Error(linked.error.message || "Failed to create onboarding link")
+ }
+ const url = (linked.data as { url?: string } | null)?.url
+ if (!url) throw new Error("No onboarding URL returned")
+ return { url }
+ },
+ })
+}
diff --git a/src/hooks/usePaymentStatus.ts b/src/hooks/usePaymentStatus.ts
new file mode 100644
index 0000000..e1bd8f2
--- /dev/null
+++ b/src/hooks/usePaymentStatus.ts
@@ -0,0 +1,43 @@
+import { useQuery } from "@tanstack/react-query"
+import { supabase } from "@/lib/supabase/client"
+import type { ConnectStatusFields } from "@/lib/payment-status"
+
+export type PaymentStatusScope = "organization" | "user"
+
+interface UsePaymentStatusArgs {
+ scope: PaymentStatusScope
+ /** organization id or user id depending on scope. Empty disables the query. */
+ scopeId: string
+}
+
+export type PaymentStatusData = ConnectStatusFields & {
+ default_payment_method_id: string | null
+}
+
+/**
+ * Read Connect status fields + the default payment method id for an
+ * organization or a user. Returns null when no row exists yet.
+ */
+export function usePaymentStatus({ scope, scopeId }: UsePaymentStatusArgs) {
+ const table = scope === "organization"
+ ? "organizations_payments_config"
+ : "users_payments_config"
+ return useQuery({
+ queryKey: ["payment-status", scope, scopeId],
+ enabled: !!scopeId,
+ retry: false,
+ staleTime: 60_000,
+ refetchOnWindowFocus: false,
+ queryFn: async (): Promise => {
+ const { data, error } = await supabase
+ .from(table)
+ .select(
+ "stripe_account_id, stripe_details_submitted, stripe_charges_enabled, stripe_payouts_enabled, default_payment_method_id",
+ )
+ .eq("id", scopeId)
+ .maybeSingle()
+ if (error) throw error
+ return data as PaymentStatusData | null
+ },
+ })
+}
diff --git a/src/hooks/useSetupPaymentMethod.ts b/src/hooks/useSetupPaymentMethod.ts
new file mode 100644
index 0000000..38bbbb3
--- /dev/null
+++ b/src/hooks/useSetupPaymentMethod.ts
@@ -0,0 +1,40 @@
+import { useMutation } from "@tanstack/react-query"
+import { supabase } from "@/lib/supabase/client"
+
+export type SetupPaymentMethodScope = "organization" | "user"
+
+interface SetupArgs {
+ scope: SetupPaymentMethodScope
+ organizationId?: string
+}
+
+interface SetupResult {
+ checkoutUrl: string
+}
+
+/**
+ * Kick off a Stripe-hosted SetupIntent Checkout for adding / replacing the
+ * default card on file. Returns the checkout URL the caller should redirect
+ * to. The `setup_intent.succeeded` webhook persists the resulting payment
+ * method on the matching `*_payments_config` row.
+ */
+export function useSetupPaymentMethod() {
+ return useMutation({
+ mutationFn: async ({ scope, organizationId }: SetupArgs): Promise => {
+ const body: Record = { scope }
+ if (scope === "organization") {
+ if (!organizationId) {
+ throw new Error("organizationId is required for scope=organization")
+ }
+ body.organization_id = organizationId
+ }
+ const resp = await supabase.functions.invoke("payments-setup-method", { body })
+ if (resp.error) {
+ throw new Error(resp.error.message || "Failed to start card setup")
+ }
+ const checkoutUrl = (resp.data as { checkout_url?: string } | null)?.checkout_url
+ if (!checkoutUrl) throw new Error("No checkout URL returned")
+ return { checkoutUrl }
+ },
+ })
+}
diff --git a/src/lib/__tests__/cap-format.test.ts b/src/lib/__tests__/cap-format.test.ts
new file mode 100644
index 0000000..822b654
--- /dev/null
+++ b/src/lib/__tests__/cap-format.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from "vitest";
+import { formatCapDollars, parseCapDollars } from "../cap-format";
+
+describe("formatCapDollars", () => {
+ it("renders an empty string for null (no cap)", () => {
+ expect(formatCapDollars(null)).toBe("");
+ });
+
+ it("renders dollar amounts with two decimals", () => {
+ expect(formatCapDollars(10000)).toBe("100.00");
+ expect(formatCapDollars(50)).toBe("0.50");
+ expect(formatCapDollars(0)).toBe("0.00");
+ });
+});
+
+describe("parseCapDollars", () => {
+ it("treats an empty string as null (no cap)", () => {
+ expect(parseCapDollars("")).toBe(null);
+ expect(parseCapDollars(" ")).toBe(null);
+ });
+
+ it("parses dollar strings into smallest units", () => {
+ expect(parseCapDollars("100")).toBe(10000);
+ expect(parseCapDollars("100.00")).toBe(10000);
+ expect(parseCapDollars("0.50")).toBe(50);
+ });
+
+ it("rounds fractional cents to the nearest cent", () => {
+ expect(parseCapDollars("1.234")).toBe(123);
+ expect(parseCapDollars("1.235")).toBe(124);
+ });
+
+ it("returns null for non-numeric input", () => {
+ expect(parseCapDollars("nope")).toBe(null);
+ });
+});
diff --git a/src/lib/__tests__/payment-status.test.ts b/src/lib/__tests__/payment-status.test.ts
new file mode 100644
index 0000000..4f586aa
--- /dev/null
+++ b/src/lib/__tests__/payment-status.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect } from "vitest";
+import { connectStatusLabel } from "../payment-status";
+
+describe("connectStatusLabel", () => {
+ it("returns 'Not set up' when there is no Stripe account id", () => {
+ const r = connectStatusLabel({
+ stripe_account_id: null,
+ stripe_details_submitted: false,
+ stripe_charges_enabled: false,
+ stripe_payouts_enabled: false,
+ });
+ expect(r.label).toBe("Not set up");
+ expect(r.variant).toBe("inactive");
+ });
+
+ it("returns 'Pending verification' when an account exists but details aren't submitted", () => {
+ const r = connectStatusLabel({
+ stripe_account_id: "acct_x",
+ stripe_details_submitted: false,
+ stripe_charges_enabled: false,
+ stripe_payouts_enabled: false,
+ });
+ expect(r.label).toBe("Pending verification");
+ expect(r.variant).toBe("pending");
+ });
+
+ it("returns 'Active' when details are submitted and both charges + payouts are enabled", () => {
+ const r = connectStatusLabel({
+ stripe_account_id: "acct_x",
+ stripe_details_submitted: true,
+ stripe_charges_enabled: true,
+ stripe_payouts_enabled: true,
+ });
+ expect(r.label).toBe("Active");
+ expect(r.variant).toBe("active");
+ });
+
+ it("returns 'Restricted' when details are submitted but charges or payouts are disabled", () => {
+ const r = connectStatusLabel({
+ stripe_account_id: "acct_x",
+ stripe_details_submitted: true,
+ stripe_charges_enabled: true,
+ stripe_payouts_enabled: false,
+ });
+ expect(r.label).toBe("Restricted");
+ expect(r.variant).toBe("warning");
+ });
+});
diff --git a/src/lib/cap-format.ts b/src/lib/cap-format.ts
new file mode 100644
index 0000000..1051e21
--- /dev/null
+++ b/src/lib/cap-format.ts
@@ -0,0 +1,20 @@
+/**
+ * Convert a smallest-unit cap (null = unlimited) into the dollar string the
+ * caps UI binds to. Null becomes the empty input.
+ */
+export function formatCapDollars(cents: number | null): string {
+ if (cents === null) return ""
+ return (cents / 100).toFixed(2)
+}
+
+/**
+ * Parse an input string into a smallest-unit value. Empty / whitespace /
+ * non-numeric input means "no cap" (null). Rounded to the nearest cent.
+ */
+export function parseCapDollars(input: string): number | null {
+ const trimmed = input.trim()
+ if (trimmed === "") return null
+ const parsed = Number(trimmed)
+ if (!Number.isFinite(parsed)) return null
+ return Math.round(parsed * 100)
+}
diff --git a/src/lib/payment-status.ts b/src/lib/payment-status.ts
new file mode 100644
index 0000000..022f2ce
--- /dev/null
+++ b/src/lib/payment-status.ts
@@ -0,0 +1,35 @@
+export interface ConnectStatusFields {
+ stripe_account_id: string | null
+ stripe_details_submitted: boolean
+ stripe_charges_enabled: boolean
+ stripe_payouts_enabled: boolean
+}
+
+export interface ConnectStatusResult {
+ label: "Not set up" | "Pending verification" | "Active" | "Restricted"
+ variant: "inactive" | "pending" | "active" | "warning"
+}
+
+/**
+ * Map the four Stripe Connect status fields into a single label + UI variant.
+ *
+ * no account → "Not set up"
+ * account but onboarding incomplete → "Pending verification"
+ * onboarding complete + both flags enabled → "Active"
+ * onboarding complete but a flag is disabled → "Restricted"
+ * (e.g. Stripe flagged the account, or extra info needed mid-life)
+ */
+export function connectStatusLabel(
+ fields: ConnectStatusFields,
+): ConnectStatusResult {
+ if (!fields.stripe_account_id) {
+ return { label: "Not set up", variant: "inactive" }
+ }
+ if (!fields.stripe_details_submitted) {
+ return { label: "Pending verification", variant: "pending" }
+ }
+ if (fields.stripe_charges_enabled && fields.stripe_payouts_enabled) {
+ return { label: "Active", variant: "active" }
+ }
+ return { label: "Restricted", variant: "warning" }
+}
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
new file mode 100644
index 0000000..20bde15
--- /dev/null
+++ b/src/lib/stripe.ts
@@ -0,0 +1,21 @@
+import { loadStripe, type Stripe } from "@stripe/stripe-js"
+
+let stripePromise: Promise | null = null
+
+/**
+ * Lazy, memoized Stripe.js loader. Reads the publishable key from the env.
+ * Returns null in environments without a configured key (e.g. tests) so
+ * callers can surface a useful error instead of crashing.
+ */
+export function getStripe(): Promise {
+ if (!stripePromise) {
+ const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
+ if (!key) {
+ console.warn("NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set")
+ stripePromise = Promise.resolve(null)
+ } else {
+ stripePromise = loadStripe(key)
+ }
+ }
+ return stripePromise
+}