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 */} +
+
+
+

Card on file

+ +
+
+

+ 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 +}