+ 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({