feat(billing): P1f Stripe UI — admin config panel + member dues card

Settings: StripeBillingPanel (admin) — mode, publishable key, write-only
secret/webhook secret, per-tier price ids, grace days; shows the
per-space webhook URL + which events to subscribe + Portal-activation
reminder. /me: DuesCard — status from getMyBilling, 'Pay dues'
(startDuesCheckout redirect) when not active, 'Manage billing'
(startBillingPortal) when a customer exists. Self-contained client
components; settings page composes the panel after SettingsClient.
This commit is contained in:
Moheeb Zara
2026-05-17 03:38:00 -07:00
parent 0a11d36de1
commit 1f61029f32
4 changed files with 198 additions and 7 deletions
+13 -1
View File
@@ -8,10 +8,11 @@ import {
type CertStatus,
} from '@/lib/certifications-logic'
import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog'
import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits } from '@/lib/actions'
import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits, getMyBilling } from '@/lib/actions'
import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic'
import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic'
import { presenceStatus } from '@/lib/presence-logic'
import { DuesCard } from '@/components/billing/dues-card'
export const dynamic = 'force-dynamic'
@@ -111,6 +112,10 @@ export default async function MePage() {
const visitsRes = await getMyVisits()
type MyVisit = { id: string; checked_in_at: string; checked_out_at: string | null; is_host: boolean; check_in_note: string | null; check_out_note: string | null }
const myVisits: MyVisit[] = 'data' in visitsRes ? (visitsRes.data as MyVisit[]) : []
const billingRes = await getMyBilling()
const billing = ('data' in billingRes ? billingRes.data : null) as
| { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean }
| null
const titleOf = (cs: ClassSignup) => {
const sess = sessionOf(cs)
const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null
@@ -124,6 +129,13 @@ export default async function MePage() {
</PageHeader>
<div className="p-4 md:p-6 space-y-8 max-w-3xl">
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
Dues
</h2>
<DuesCard billing={billing} />
</section>
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
Certifications
+10 -6
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import SettingsClient from './settings-client'
import { StripeBillingPanel } from '@/components/settings/stripe-billing-panel'
import { redirect } from 'next/navigation'
export default async function SettingsPage() {
@@ -25,11 +26,14 @@ export default async function SettingsPage() {
.from('integrations').select('*').eq('space_id', member.space_id)
return (
<SettingsClient
space={space}
isAdmin={isAdmin}
integrations={integrations ?? []}
currentRole={member.role}
/>
<>
<SettingsClient
space={space}
isAdmin={isAdmin}
integrations={integrations ?? []}
currentRole={member.role}
/>
<StripeBillingPanel spaceId={member.space_id} />
</>
)
}
+58
View File
@@ -0,0 +1,58 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { startDuesCheckout, startBillingPortal } from '@/lib/actions'
type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null
const PAID = new Set(['active', 'trialing'])
export function DuesCard({ billing }: { billing: Billing }) {
const [busy, setBusy] = useState(false)
const paid = !!billing?.status && PAID.has(billing.status)
async function go(fn: () => Promise<{ error?: string } | { data: { url: string } }>) {
setBusy(true)
const res = await fn()
if ('error' in res && res.error) { setBusy(false); return toast.error(res.error) }
window.location.href = (res as { data: { url: string } }).data.url
}
return (
<div className="bg-card rounded border border-border p-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="font-sans text-sm font-medium text-foreground">Membership dues</p>
{billing?.status ? (
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{billing.status}
{billing.currentPeriodEnd
? ` · renews ${new Date(billing.currentPeriodEnd).toLocaleDateString()}`
: ''}
</p>
) : (
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">No active dues subscription.</p>
)}
</div>
{billing?.status && (
<Badge variant={paid ? 'default' : 'outline'}>{paid ? 'Current' : 'Action needed'}</Badge>
)}
</div>
<div className="flex gap-2 mt-3">
{!paid && (
<Button size="sm" disabled={busy} onClick={() => go(startDuesCheckout)}>
{busy ? 'Opening…' : 'Pay dues'}
</Button>
)}
{billing?.hasCustomer && (
<Button size="sm" variant="outline" disabled={busy} onClick={() => go(startBillingPortal)}>
Manage billing
</Button>
)}
</div>
</div>
)
}
@@ -0,0 +1,117 @@
'use client'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { getStripeSettings, saveStripeSettings } from '@/lib/actions'
const TIERS = ['plus', 'basic', 'associate'] as const
const input =
'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary'
type Settings = {
mode: 'test' | 'live'
publishable_key: string
grace_days: number
prices: Record<string, string>
hasSecretKey: boolean
hasWebhookSecret: boolean
configured: boolean
}
export function StripeBillingPanel({ spaceId }: { spaceId: string }) {
const [s, setS] = useState<Settings | null>(null)
const [secretKey, setSecretKey] = useState('')
const [webhookSecret, setWebhookSecret] = useState('')
const [busy, setBusy] = useState(false)
const [webhookUrl, setWebhookUrl] = useState('')
useEffect(() => {
setWebhookUrl(`${window.location.origin}/api/stripe/webhook/${spaceId}`)
getStripeSettings().then(r => {
if ('data' in r) setS(r.data as Settings)
})
}, [spaceId])
if (!s) return null
async function save() {
if (!s) return
setBusy(true)
const res = await saveStripeSettings({
mode: s.mode,
publishable_key: s.publishable_key || null,
secret_key: secretKey || null,
webhook_secret: webhookSecret || null,
grace_days: s.grace_days,
prices: s.prices,
})
setBusy(false)
if ('error' in res && res.error) return toast.error(res.error)
setSecretKey('')
setWebhookSecret('')
const r = await getStripeSettings()
if ('data' in r) setS(r.data as Settings)
toast.success('Stripe settings saved')
}
return (
<div className="p-4 md:p-6 border-t border-border">
<div className="max-w-2xl space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Stripe recurring dues
</h2>
<span className={`font-mono text-[10px] ${s.configured ? 'text-primary' : 'text-muted-foreground'}`}>
{s.configured ? 'configured' : 'not configured'}
</span>
</div>
<p className="font-sans text-xs text-muted-foreground">
Uses your space&rsquo;s own Stripe account. Keys are stored encrypted and never shown
again. After saving, add this webhook endpoint in your Stripe Dashboard and paste back
its signing secret, and activate the Customer Portal there.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<select className={input} value={s.mode} onChange={e => setS({ ...s, mode: e.target.value as 'test' | 'live' })}>
<option value="test">Test mode</option>
<option value="live">Live mode</option>
</select>
<input className={input} type="number" min={0} max={90} value={s.grace_days}
onChange={e => setS({ ...s, grace_days: Number(e.target.value) })}
placeholder="Grace days before 'late'" />
<input className={input} value={s.publishable_key}
onChange={e => setS({ ...s, publishable_key: e.target.value })}
placeholder="Publishable key (pk_…)" />
<input className={input} type="password" value={secretKey}
onChange={e => setSecretKey(e.target.value)}
placeholder={s.hasSecretKey ? 'Secret key — set (blank = keep)' : 'Secret key (sk_…)'} />
<input className={input} type="password" value={webhookSecret}
onChange={e => setWebhookSecret(e.target.value)}
placeholder={s.hasWebhookSecret ? 'Webhook secret — set (blank = keep)' : 'Webhook signing secret (whsec_…)'} />
</div>
<div>
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-1.5">Tier → Stripe Price</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{TIERS.map(t => (
<input key={t} className={input} value={s.prices[t] ?? ''}
onChange={e => setS({ ...s, prices: { ...s.prices, [t]: e.target.value } })}
placeholder={`${t} price_…`} />
))}
</div>
</div>
<div className="rounded border border-border bg-background p-3">
<p className="font-mono text-[10px] text-muted-foreground">Webhook endpoint (add in Stripe → Developers → Webhooks):</p>
<code className="font-mono text-[11px] text-foreground break-all">{webhookUrl}</code>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
Subscribe to: checkout.session.completed, customer.subscription.*, invoice.paid, invoice.payment_failed.
</p>
</div>
<Button size="sm" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save Stripe settings'}</Button>
</div>
</div>
)
}