From ec3817e52276768318874e624494d97cb349f24c Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 16:32:54 +0200 Subject: [PATCH 01/18] feat(payments): add connectStatusLabel helper for UI display Co-Authored-By: Claude Opus 4.7 --- src/lib/__tests__/payment-status.test.ts | 48 ++++++++++++++++++++++++ src/lib/payment-status.ts | 35 +++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/lib/__tests__/payment-status.test.ts create mode 100644 src/lib/payment-status.ts 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/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" } +} From 8408e68b0498b0cc904be2511c70a84cef130cd2 Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 16:33:34 +0200 Subject: [PATCH 02/18] feat(payments): add useStartHelperPaymentConnect for helper onboarding Co-Authored-By: Claude Opus 4.7 --- src/hooks/__tests__/usePaymentConnect.test.ts | 49 +++++++++++++++++++ src/hooks/usePaymentConnect.ts | 28 +++++++++++ 2 files changed, 77 insertions(+) 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/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 } + }, + }) +} From 2984e875657dac97c70da664db1892c7865a0fb6 Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 16:34:11 +0200 Subject: [PATCH 03/18] feat(payments): add usePaymentStatus hook for Connect status display Co-Authored-By: Claude Opus 4.7 --- src/hooks/__tests__/usePaymentStatus.test.ts | 89 ++++++++++++++++++++ src/hooks/usePaymentStatus.ts | 39 +++++++++ 2 files changed, 128 insertions(+) create mode 100644 src/hooks/__tests__/usePaymentStatus.test.ts create mode 100644 src/hooks/usePaymentStatus.ts 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/usePaymentStatus.ts b/src/hooks/usePaymentStatus.ts new file mode 100644 index 0000000..10d39ea --- /dev/null +++ b/src/hooks/usePaymentStatus.ts @@ -0,0 +1,39 @@ +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 +} + +/** + * Read Connect status fields for an organization or a user. Returns null when + * no row exists yet (i.e. before payments-create-account has run). + */ +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", + ) + .eq("id", scopeId) + .maybeSingle() + if (error) throw error + return data as ConnectStatusFields | null + }, + }) +} From 3a3e6a9fa6d09931b7285427d2c52532b8c71202 Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 16:34:44 +0200 Subject: [PATCH 04/18] feat(payments): add helper payouts settings page Co-Authored-By: Claude Opus 4.7 --- src/app/helper/settings/payouts/page.tsx | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/app/helper/settings/payouts/page.tsx diff --git a/src/app/helper/settings/payouts/page.tsx b/src/app/helper/settings/payouts/page.tsx new file mode 100644 index 0000000..6626c4e --- /dev/null +++ b/src/app/helper/settings/payouts/page.tsx @@ -0,0 +1,66 @@ +"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 { 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 handleSetupPayouts = async () => { + try { + const { url } = await startConnect.mutateAsync() + window.location.assign(url) + } catch (err) { + console.error("Failed to start helper Connect onboarding:", 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} + +
+
+
+
+
+
+ ) +} From ec2c36768a2f9dc89d2f2b7caea827eaa1ec7204 Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 16:35:18 +0200 Subject: [PATCH 05/18] feat(payments): show Connect status next to org Set up payouts button Co-Authored-By: Claude Opus 4.7 --- src/app/settings/payment/page.tsx | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/settings/payment/page.tsx b/src/app/settings/payment/page.tsx index ede7021..03d681e 100644 --- a/src/app/settings/payment/page.tsx +++ b/src/app/settings/payment/page.tsx @@ -11,6 +11,8 @@ 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 { usePaymentStatus } from "@/hooks/usePaymentStatus" +import { connectStatusLabel } from "@/lib/payment-status" import { DistributionPreview } from "@/components/payment/distribution-preview" import { useProjectSelection } from "@/contexts/project-context" import { cn } from "@/lib/utils" @@ -27,6 +29,11 @@ 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" const handleSetupPayouts = async () => { if (!project?.organization_id) return @@ -755,14 +762,19 @@ export default function PaymentSettingsPage() { payouts from support.

- +
+ + + Status: {orgStatusLabel} + +
From d2d93997650b434e5e60e53a798341b8a823516a Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 22:27:05 +0200 Subject: [PATCH 06/18] feat(payments): add cap-format helpers for dollar-string conversion Co-Authored-By: Claude Opus 4.7 --- src/lib/__tests__/cap-format.test.ts | 36 ++++++++++++++++++++++++++++ src/lib/cap-format.ts | 20 ++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/lib/__tests__/cap-format.test.ts create mode 100644 src/lib/cap-format.ts 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/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) +} From f174b21445642813dafef36c9978cfa0f8ad646d Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 22:27:54 +0200 Subject: [PATCH 07/18] feat(payments): add org spending-caps read + update hooks Co-Authored-By: Claude Opus 4.7 --- .../__tests__/useOrgSpendingCaps.test.ts | 113 ++++++++++++++++++ src/hooks/useOrgSpendingCaps.ts | 60 ++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/hooks/__tests__/useOrgSpendingCaps.test.ts create mode 100644 src/hooks/useOrgSpendingCaps.ts 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/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] }) + }, + }) +} From 97c15f42b90fa7b83c12ada7b7e3c65e693846a5 Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 22:29:43 +0200 Subject: [PATCH 08/18] feat(payments): add spending-caps section to org payment settings Co-Authored-By: Claude Opus 4.7 --- src/app/settings/payment/page.tsx | 104 ++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/app/settings/payment/page.tsx b/src/app/settings/payment/page.tsx index 03d681e..f690506 100644 --- a/src/app/settings/payment/page.tsx +++ b/src/app/settings/payment/page.tsx @@ -12,7 +12,9 @@ import { Logo } from "@/components/brand/logo" import { useProject, useProjectPaymentSettings, useUpdateProjectPaymentSettings } from "@/hooks/useProject" import { useStartPaymentConnect } from "@/hooks/usePaymentConnect" 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" @@ -35,6 +37,42 @@ export default function PaymentSettingsPage() { ? 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 handleSetupPayouts = async () => { if (!project?.organization_id) return try { @@ -730,6 +768,72 @@ export default function PaymentSettingsPage() { + {/* 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 */}
From 8121bfb73128d8e4790e255f6baba36f48c453dd Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 23:07:45 +0200 Subject: [PATCH 09/18] feat(payments): add useSetupPaymentMethod and surface default_payment_method_id Co-Authored-By: Claude Opus 4.7 --- .../__tests__/useSetupPaymentMethod.test.ts | 85 +++++++++++++++++++ src/hooks/usePaymentStatus.ts | 14 +-- src/hooks/useSetupPaymentMethod.ts | 40 +++++++++ 3 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 src/hooks/__tests__/useSetupPaymentMethod.test.ts create mode 100644 src/hooks/useSetupPaymentMethod.ts 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/usePaymentStatus.ts b/src/hooks/usePaymentStatus.ts index 10d39ea..e1bd8f2 100644 --- a/src/hooks/usePaymentStatus.ts +++ b/src/hooks/usePaymentStatus.ts @@ -10,9 +10,13 @@ interface UsePaymentStatusArgs { scopeId: string } +export type PaymentStatusData = ConnectStatusFields & { + default_payment_method_id: string | null +} + /** - * Read Connect status fields for an organization or a user. Returns null when - * no row exists yet (i.e. before payments-create-account has run). + * 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" @@ -24,16 +28,16 @@ export function usePaymentStatus({ scope, scopeId }: UsePaymentStatusArgs) { retry: false, staleTime: 60_000, refetchOnWindowFocus: false, - queryFn: async (): Promise => { + queryFn: async (): Promise => { const { data, error } = await supabase .from(table) .select( - "stripe_account_id, stripe_details_submitted, stripe_charges_enabled, stripe_payouts_enabled", + "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 ConnectStatusFields | null + 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 } + }, + }) +} From 6b6f7c60e679f5bffac153090012c5c8069c69e7 Mon Sep 17 00:00:00 2001 From: Stian Date: Sat, 23 May 2026 23:09:24 +0200 Subject: [PATCH 10/18] feat(payments): add Card-on-file UI to org settings and helper payouts pages Co-Authored-By: Claude Opus 4.7 --- src/app/helper/settings/payouts/page.tsx | 40 ++++++++++++++++++++ src/app/settings/payment/page.tsx | 47 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/app/helper/settings/payouts/page.tsx b/src/app/helper/settings/payouts/page.tsx index 6626c4e..6d24d42 100644 --- a/src/app/helper/settings/payouts/page.tsx +++ b/src/app/helper/settings/payouts/page.tsx @@ -6,6 +6,7 @@ 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" @@ -14,6 +15,8 @@ export default function HelperPayoutsPage() { 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 { @@ -24,6 +27,16 @@ export default function HelperPayoutsPage() { } } + 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" @@ -58,6 +71,33 @@ export default function HelperPayoutsPage() {
+ +
+
+

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 f690506..d5a76c1 100644 --- a/src/app/settings/payment/page.tsx +++ b/src/app/settings/payment/page.tsx @@ -11,6 +11,7 @@ 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" @@ -73,6 +74,22 @@ export default function PaymentSettingsPage() { } } + 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 try { @@ -768,6 +785,36 @@ 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 */}
From 0c90f5effcffdfa3083c40d067c296405b773c6c Mon Sep 17 00:00:00 2001 From: Stian Date: Sun, 24 May 2026 00:21:23 +0200 Subject: [PATCH 11/18] feat(payments): add useAuthorizeTicket mutation hook Wraps payments-authorize-ticket edge function in a typed React Query mutation. Returns a discriminated union so callers can branch on requires_checkout vs sla_covered vs authorized/failed without unsafe casts. Co-Authored-By: Claude Opus 4.7 --- .../__tests__/useAuthorizeTicket.test.ts | 128 ++++++++++++++++++ src/hooks/useAuthorizeTicket.ts | 89 ++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/hooks/__tests__/useAuthorizeTicket.test.ts create mode 100644 src/hooks/useAuthorizeTicket.ts diff --git a/src/hooks/__tests__/useAuthorizeTicket.test.ts b/src/hooks/__tests__/useAuthorizeTicket.test.ts new file mode 100644 index 0000000..a716287 --- /dev/null +++ b/src/hooks/__tests__/useAuthorizeTicket.test.ts @@ -0,0 +1,128 @@ +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("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/useAuthorizeTicket.ts b/src/hooks/useAuthorizeTicket.ts new file mode 100644 index 0000000..d5ec500 --- /dev/null +++ b/src/hooks/useAuthorizeTicket.ts @@ -0,0 +1,89 @@ +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 + } + +/** + * 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, + } + }, + }) +} From d9bbd661ddfb9b0774f27858a13509c833cf286b Mon Sep 17 00:00:00 2001 From: Stian Date: Sun, 24 May 2026 00:23:32 +0200 Subject: [PATCH 12/18] feat(payments): authorize ticket payment immediately after creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a customer-created ticket succeeds, invoke payments-authorize-ticket inline. Three branches: - requires_checkout: redirect to Stripe-hosted Checkout (first-time payer) - sla_covered / authorized: continue silently - requires_action / failed: warn for now (full SCA UI in Phase 7d3) Authorize failures don't block the conversation — the ticket exists and payment can be retried later. Co-Authored-By: Claude Opus 4.7 --- src/app/support/chat/page.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/app/support/chat/page.tsx b/src/app/support/chat/page.tsx index 91ae983..7c3f6fb 100644 --- a/src/app/support/chat/page.tsx +++ b/src/app/support/chat/page.tsx @@ -13,6 +13,7 @@ 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 { useTicketMessages, useSendMessage } from "@/hooks/useTicketMessages" import { useRealtimeMessages } from "@/hooks/useRealtimeMessages" import { useTicketParticipants, useEnsureParticipant, type ParticipantWithUser } from "@/hooks/useTicketParticipants" @@ -159,6 +160,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 +419,24 @@ 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" || authResult.status === "failed") { + console.warn( + `Ticket ${ticket.id} authorize returned ${authResult.status}; manual resolution needed.`, + ) + } + } catch (err) { + console.error("Failed to authorize ticket payment:", err) + } + setTicketCreated(true) setTicketId(ticket.id) const firstMessageContent = message.trim() From cb6794b89933a5ac2b4e0cb3b8a8f4927f421f0e Mon Sep 17 00:00:00 2001 From: Stian Date: Sun, 24 May 2026 01:07:21 +0200 Subject: [PATCH 13/18] feat(payments): add stripe-js loader and expose clientSecret from useAuthorizeTicket Adds @stripe/stripe-js as a runtime dep, with a memoized loadStripe() wrapper in src/lib/stripe.ts that returns null when the publishable key is missing (handy for tests). useAuthorizeTicket now carries the PI's client_secret on requires_action results so the chat page can launch the upcoming SCA confirmation modal. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 10 ++++++++ package.json | 1 + .../__tests__/useAuthorizeTicket.test.ts | 25 +++++++++++++++++++ src/hooks/useAuthorizeTicket.ts | 2 ++ src/lib/stripe.ts | 21 ++++++++++++++++ 5 files changed, 59 insertions(+) create mode 100644 src/lib/stripe.ts 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/hooks/__tests__/useAuthorizeTicket.test.ts b/src/hooks/__tests__/useAuthorizeTicket.test.ts index a716287..2df028e 100644 --- a/src/hooks/__tests__/useAuthorizeTicket.test.ts +++ b/src/hooks/__tests__/useAuthorizeTicket.test.ts @@ -111,6 +111,31 @@ describe("useAuthorizeTicket", () => { ); }); + 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, diff --git a/src/hooks/useAuthorizeTicket.ts b/src/hooks/useAuthorizeTicket.ts index d5ec500..ff1b5e4 100644 --- a/src/hooks/useAuthorizeTicket.ts +++ b/src/hooks/useAuthorizeTicket.ts @@ -34,6 +34,7 @@ export type AuthorizeTicketResult = stripePaymentIntentId?: string holdAmountSmallestUnit?: number holdExpiresAt?: string + clientSecret?: string } /** @@ -83,6 +84,7 @@ export function useAuthorizeTicket() { 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/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 +} From 7fb68d96d79e80233903f547c1c712be1c745c37 Mon Sep 17 00:00:00 2001 From: Stian Date: Sun, 24 May 2026 02:34:42 +0200 Subject: [PATCH 14/18] feat(payments): add ConfirmPaymentModal for SCA challenges Lazy-loads Stripe.js and calls stripe.confirmCardPayment(clientSecret). On success (PI status requires_capture or succeeded) callbacks onResolved("authorized"); on Stripe error surfaces the message inline without dismissing the modal so the user can cancel. Co-Authored-By: Claude Opus 4.7 --- .../payment/ConfirmPaymentModal.tsx | 74 +++++++++++++++++++ .../__tests__/ConfirmPaymentModal.test.tsx | 65 ++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/components/payment/ConfirmPaymentModal.tsx create mode 100644 src/components/payment/__tests__/ConfirmPaymentModal.test.tsx 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() + }) +}) From ab91e51c97fc2505d2e6843e24e7409242d15b6a Mon Sep 17 00:00:00 2001 From: Stian Date: Sun, 24 May 2026 02:43:26 +0200 Subject: [PATCH 15/18] feat(payments): surface ConfirmPaymentModal for SCA-required ticket holds When the off-session hold for a returning customer lands in requires_action with a client_secret, render the modal so the customer can complete 3DS without leaving the chat. On 'authorized' resume the chat (set ticketCreated + ticketId); on cancel just dismiss. Co-Authored-By: Claude Opus 4.7 --- src/app/support/chat/page.tsx | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/app/support/chat/page.tsx b/src/app/support/chat/page.tsx index 7c3f6fb..4d49907 100644 --- a/src/app/support/chat/page.tsx +++ b/src/app/support/chat/page.tsx @@ -14,6 +14,7 @@ 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" @@ -66,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") @@ -428,10 +431,15 @@ export default function UserSupportChatPage() { window.location.assign(authResult.checkoutUrl) return } - if (authResult.status === "requires_action" || authResult.status === "failed") { - console.warn( - `Ticket ${ticket.id} authorize returned ${authResult.status}; manual resolution needed.`, - ) + 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) @@ -673,6 +681,19 @@ export default function UserSupportChatPage() { ) : undefined } /> + {pendingSca && ( + { + if (status === "authorized") { + setTicketCreated(true) + setTicketId(pendingSca.ticketId) + } + setPendingSca(null) + }} + onCancel={() => setPendingSca(null)} + /> + )}
) } From f65d163a9d0771a5a8f32e543ff17d120fb05736 Mon Sep 17 00:00:00 2001 From: Stian Date: Sun, 24 May 2026 02:51:38 +0200 Subject: [PATCH 16/18] feat(payments): add Payouts entry to helper sidebar Settings Helpers were missing a direct link to /helper/settings/payouts in the sidebar. Place it as the first sub-item under Settings, matching the admin Settings > Payment ordering. Co-Authored-By: Claude Opus 4.7 --- src/components/layout/sidebar.tsx | 1 + 1 file changed, 1 insertion(+) 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" }, ], From 28def475631c4f90e421276c70c421d78bfd3790 Mon Sep 17 00:00:00 2001 From: Stian Date: Sun, 24 May 2026 02:59:51 +0200 Subject: [PATCH 17/18] feat(payments): add SupportSandboxBanner for public support routes The /support layout has its own shell with no ProjectProvider, so the existing dashboard SandboxBanner doesn't fire there. New component resolves the project from the slug param (preferred) or project query param, and renders an amber strip warning customers that Stripe test mode is in use when project.sandbox is true. Co-Authored-By: Claude Opus 4.7 --- .../__tests__/support-sandbox-banner.test.tsx | 34 +++++++++++++++++++ .../layout/support-sandbox-banner.tsx | 32 +++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/components/layout/__tests__/support-sandbox-banner.test.tsx create mode 100644 src/components/layout/support-sandbox-banner.tsx 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/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. + +
+ ) +} From 03c87fb9e3c015c902a6edfea4689bacae7302d9 Mon Sep 17 00:00:00 2001 From: Stian Date: Sun, 24 May 2026 03:00:25 +0200 Subject: [PATCH 18/18] feat(payments): render SupportSandboxBanner on public support layout Customers landing on /support//... now see an amber strip between the support header and main content, warning that Stripe test mode is in use. The banner short-circuits to null on non-sandbox projects so production traffic is unaffected. Co-Authored-By: Claude Opus 4.7 --- src/app/support/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) 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}
)