Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ec3817e
feat(payments): add connectStatusLabel helper for UI display
burkongla May 23, 2026
8408e68
feat(payments): add useStartHelperPaymentConnect for helper onboarding
burkongla May 23, 2026
2984e87
feat(payments): add usePaymentStatus hook for Connect status display
burkongla May 23, 2026
3a3e6a9
feat(payments): add helper payouts settings page
burkongla May 23, 2026
ec2c367
feat(payments): show Connect status next to org Set up payouts button
burkongla May 23, 2026
d2d9399
feat(payments): add cap-format helpers for dollar-string conversion
burkongla May 23, 2026
f174b21
feat(payments): add org spending-caps read + update hooks
burkongla May 23, 2026
97c15f4
feat(payments): add spending-caps section to org payment settings
burkongla May 23, 2026
8121bfb
feat(payments): add useSetupPaymentMethod and surface default_payment…
burkongla May 23, 2026
6b6f7c6
feat(payments): add Card-on-file UI to org settings and helper payout…
burkongla May 23, 2026
0c90f5e
feat(payments): add useAuthorizeTicket mutation hook
burkongla May 23, 2026
d9bbd66
feat(payments): authorize ticket payment immediately after creation
burkongla May 23, 2026
cb6794b
feat(payments): add stripe-js loader and expose clientSecret from use…
burkongla May 23, 2026
7fb68d9
feat(payments): add ConfirmPaymentModal for SCA challenges
burkongla May 24, 2026
ab91e51
feat(payments): surface ConfirmPaymentModal for SCA-required ticket h…
burkongla May 24, 2026
f65d163
feat(payments): add Payouts entry to helper sidebar Settings
burkongla May 24, 2026
28def47
feat(payments): add SupportSandboxBanner for public support routes
burkongla May 24, 2026
03c87fb
feat(payments): render SupportSandboxBanner on public support layout
burkongla May 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions src/app/helper/settings/payouts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client"

import { Sidebar } from "@/components/layout/sidebar"
import { Header } from "@/components/layout/header"
import { Button } from "@/components/ui/button"
import { Landmark, Info } from "lucide-react"
import { useUser } from "@/contexts/user-context"
import { useStartHelperPaymentConnect } from "@/hooks/usePaymentConnect"
import { useSetupPaymentMethod } from "@/hooks/useSetupPaymentMethod"
import { usePaymentStatus } from "@/hooks/usePaymentStatus"
import { connectStatusLabel } from "@/lib/payment-status"

export default function HelperPayoutsPage() {
const { user } = useUser()
const userId = user?.id ?? ""
const startConnect = useStartHelperPaymentConnect()
const status = usePaymentStatus({ scope: "user", scopeId: userId })
const setupCard = useSetupPaymentMethod()
const hasCardOnFile = !!status.data?.default_payment_method_id

const handleSetupPayouts = async () => {
try {
const { url } = await startConnect.mutateAsync()
window.location.assign(url)
} catch (err) {
console.error("Failed to start helper Connect onboarding:", err)
}
}

const handleAddOrReplaceCard = async () => {
if (!userId) return
try {
const { checkoutUrl } = await setupCard.mutateAsync({ scope: "user" })
window.location.assign(checkoutUrl)
} catch (err) {
console.error("Failed to start card setup:", err)
}
}

const label = status.data
? connectStatusLabel(status.data).label
: "Not set up"

return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header title="Payouts" subtitle="Set up payouts to your Stripe account" />
<main className="flex-1 p-6 overflow-y-auto">
<div className="max-w-3xl space-y-6">
<div className="bg-card rounded-lg border border-border p-6">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-base font-semibold text-foreground">Set up payouts</h2>
<Info className="w-4 h-4 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Connect your Stripe account so we can send your payouts when you
help on tickets. We use Stripe for all payouts.
</p>
<div className="mt-6 flex items-center gap-3">
<Button
className="w-fit px-5 py-2.5 text-[13px] font-medium bg-[#635bff] text-white hover:bg-[#5851e5]"
onClick={handleSetupPayouts}
disabled={!userId || startConnect.isPending}
>
<Landmark className="w-4 h-4" />
{startConnect.isPending ? "Starting..." : "Set up payouts"}
</Button>
<span className="text-sm text-muted-foreground">
Status: <span className="font-medium text-foreground">{label}</span>
</span>
</div>
</div>

<div className="bg-card rounded-lg border border-border p-6">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-base font-semibold text-foreground">Card on file</h2>
<Info className="w-4 h-4 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Used to place authorization holds when you open a support ticket
you pay for yourself. Stored securely with Stripe.
</p>
<div className="mt-6 flex items-center gap-3">
<Button
className="w-fit px-5 py-2.5 text-[13px] font-medium bg-[#635bff] text-white hover:bg-[#5851e5]"
onClick={handleAddOrReplaceCard}
disabled={!userId || setupCard.isPending}
>
{setupCard.isPending
? "Starting..."
: hasCardOnFile ? "Replace card" : "Add card"}
</Button>
<span className="text-sm text-muted-foreground">
{hasCardOnFile
? <span className="font-medium text-foreground">Card on file</span>
: "No card yet"}
</span>
</div>
</div>
</div>
</main>
</div>
</div>
)
}
179 changes: 171 additions & 8 deletions src/app/settings/payment/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { Info, Landmark } from "lucide-react"
import { Logo } from "@/components/brand/logo"
import { useProject, useProjectPaymentSettings, useUpdateProjectPaymentSettings } from "@/hooks/useProject"
import { useStartPaymentConnect } from "@/hooks/usePaymentConnect"
import { useSetupPaymentMethod } from "@/hooks/useSetupPaymentMethod"
import { usePaymentStatus } from "@/hooks/usePaymentStatus"
import { useOrgSpendingCaps, useUpdateOrgSpendingCaps } from "@/hooks/useOrgSpendingCaps"
import { connectStatusLabel } from "@/lib/payment-status"
import { formatCapDollars, parseCapDollars } from "@/lib/cap-format"
import { DistributionPreview } from "@/components/payment/distribution-preview"
import { useProjectSelection } from "@/contexts/project-context"
import { cn } from "@/lib/utils"
Expand All @@ -27,6 +32,63 @@ export default function PaymentSettingsPage() {
// Project (used for organization_id when starting Connect onboarding)
const { data: project } = useProject(projectId || "")
const startConnect = useStartPaymentConnect()
const orgId = project?.organization_id ?? ""
const orgStatus = usePaymentStatus({ scope: "organization", scopeId: orgId })
const orgStatusLabel = orgStatus.data
? connectStatusLabel(orgStatus.data).label
: "Not set up"

// Spending caps state — bound to inputs as dollar strings; "" = no cap.
const orgCaps = useOrgSpendingCaps(orgId)
const updateCaps = useUpdateOrgSpendingCaps()
const [orgMonthlyCap, setOrgMonthlyCap] = useState("")
const [defaultUserCap, setDefaultUserCap] = useState("")
const [capsOriginal, setCapsOriginal] = useState<{ org: string; user: string } | null>(null)

/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (orgCaps.data) {
const org = formatCapDollars(orgCaps.data.monthly_spend_cap_smallest_unit)
const user = formatCapDollars(orgCaps.data.default_user_monthly_cap_smallest_unit)
setOrgMonthlyCap(org)
setDefaultUserCap(user)
setCapsOriginal({ org, user })
}
}, [orgCaps.data])
/* eslint-enable react-hooks/set-state-in-effect */

const hasCapsChanges = capsOriginal !== null &&
(orgMonthlyCap !== capsOriginal.org || defaultUserCap !== capsOriginal.user)

const handleSaveCaps = async () => {
if (!orgId) return
try {
await updateCaps.mutateAsync({
organizationId: orgId,
monthlySpendCapSmallestUnit: parseCapDollars(orgMonthlyCap),
defaultUserMonthlyCapSmallestUnit: parseCapDollars(defaultUserCap),
})
setCapsOriginal({ org: orgMonthlyCap, user: defaultUserCap })
} catch (err) {
console.error("Failed to save spending caps:", err)
}
}

const setupCard = useSetupPaymentMethod()
const hasCardOnFile = !!orgStatus.data?.default_payment_method_id

const handleAddOrReplaceCard = async () => {
if (!orgId) return
try {
const { checkoutUrl } = await setupCard.mutateAsync({
scope: "organization",
organizationId: orgId,
})
window.location.assign(checkoutUrl)
} catch (err) {
console.error("Failed to start card setup:", err)
}
}

const handleSetupPayouts = async () => {
if (!project?.organization_id) return
Expand Down Expand Up @@ -723,6 +785,102 @@ export default function PaymentSettingsPage() {
</div>
</div>

{/* Card on file section */}
<div className="bg-card rounded-lg border border-border p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-foreground">Card on file</h2>
<Info className="w-4 h-4 text-muted-foreground" />
</div>
</div>
<p className="text-sm text-muted-foreground">
Used to place authorization holds on employer-billed tickets.
Stored securely with Stripe.
</p>
<div className="mt-6 flex items-center gap-3">
<Button
className="w-fit px-5 py-2.5 text-[13px] font-medium bg-[#635bff] text-white hover:bg-[#5851e5]"
onClick={handleAddOrReplaceCard}
disabled={!orgId || setupCard.isPending}
>
{setupCard.isPending
? "Starting..."
: hasCardOnFile ? "Replace card" : "Add card"}
</Button>
<span className="text-sm text-muted-foreground">
{hasCardOnFile
? <span className="font-medium text-foreground">Card on file</span>
: "No card yet"}
</span>
</div>
</div>

{/* Spending caps Section */}
<div className="bg-card rounded-lg border border-border p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-foreground">Spending caps</h2>
<Info className="w-4 h-4 text-muted-foreground" />
</div>
<Button
variant="outline"
size="sm"
className="text-muted-foreground border-border bg-transparent"
onClick={handleSaveCaps}
disabled={!orgId || !hasCapsChanges || updateCaps.isPending}
>
{updateCaps.isPending ? "Saving..." : "Save"}
</Button>
</div>

<p className="text-sm text-muted-foreground mb-6">
Monthly limits enforced when employer-billed tickets are
authorized. Leave blank for no cap.
</p>

<div className="flex flex-wrap gap-10">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-foreground">Org-wide monthly cap</span>
<Info className="w-4 h-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2">
<Input
type="number"
value={orgMonthlyCap}
onChange={(e) => setOrgMonthlyCap(e.target.value)}
className={cn(
"w-[230px] text-right pr-[3px] border-border"
)}
placeholder="No cap"
disabled={!orgId}
/>
<span className="text-sm text-muted-foreground whitespace-nowrap">USD/month</span>
</div>
</div>

<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-foreground">Default per-user monthly cap</span>
<Info className="w-4 h-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2">
<Input
type="number"
value={defaultUserCap}
onChange={(e) => setDefaultUserCap(e.target.value)}
className={cn(
"w-[230px] text-right pr-[3px] border-border"
)}
placeholder="No cap"
disabled={!orgId}
/>
<span className="text-sm text-muted-foreground whitespace-nowrap">USD/user/month</span>
</div>
</div>
</div>
</div>

{/* Set up payouts Section */}
<div className="bg-card rounded-lg border border-border p-6">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -755,14 +913,19 @@ export default function PaymentSettingsPage() {
payouts from support.
</p>

<Button
className="w-fit px-5 py-2.5 text-[13px] font-medium bg-[#635bff] text-white hover:bg-[#5851e5]"
onClick={handleSetupPayouts}
disabled={!project?.organization_id || startConnect.isPending}
>
<Landmark className="w-4 h-4" />
{startConnect.isPending ? "Starting..." : "Set up payouts"}
</Button>
<div className="flex items-center gap-3">
<Button
className="w-fit px-5 py-2.5 text-[13px] font-medium bg-[#635bff] text-white hover:bg-[#5851e5]"
onClick={handleSetupPayouts}
disabled={!project?.organization_id || startConnect.isPending}
>
<Landmark className="w-4 h-4" />
{startConnect.isPending ? "Starting..." : "Set up payouts"}
</Button>
<span className="text-sm text-muted-foreground">
Status: <span className="font-medium text-foreground">{orgStatusLabel}</span>
</span>
</div>
</div>
</div>
</div>
Expand Down
Loading