diff --git a/lib/actions/stripe.ts b/lib/actions/stripe.ts index 67c04bc..9f4d7cd 100644 --- a/lib/actions/stripe.ts +++ b/lib/actions/stripe.ts @@ -1,13 +1,22 @@ 'use server' import { revalidatePath } from 'next/cache' +import { headers } from 'next/headers' import { createClient } from '@/lib/supabase/server' import { createAdminClient } from '@/lib/supabase/admin' -import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers' +import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers' import { ADMIN_ROLES } from '@/lib/permissions' import { stripeSettingsSchema } from '@/lib/validations' -import { isStripeConfigured } from '@/lib/stripe-logic' -import { getStripeConfig, storeStripeSecret, type StripeConfig } from '@/lib/stripe/config' +import { isStripeConfigured, priceIdForTier } from '@/lib/stripe-logic' +import { getStripeConfig, storeStripeSecret, readStripeSecret, type StripeConfig } from '@/lib/stripe/config' +import { getStripe } from '@/lib/stripe/client' + +async function appOrigin(): Promise { + const h = await headers() + const host = h.get('x-forwarded-host') ?? h.get('host') + const proto = h.get('x-forwarded-proto') ?? 'https' + return host ? `${proto}://${host}` : 'https://hackerspace.sh' +} // Admin-facing settings view. NEVER returns secret values, only whether they // are set, so the config screen can show status without exposing keys. @@ -92,3 +101,162 @@ export async function saveStripeSettings(input: unknown) { revalidatePath('/settings') return { data: { ok: true, configured: isStripeConfigured(config) } } } + +// ─── Member dues: checkout + portal + own status ───────────────────────────── + +// Start a hosted Checkout in subscription mode for the caller's membership +// tier. Self-only: the member + tier are resolved server-side; metadata is +// set on BOTH the session and the subscription (session metadata does not +// propagate to the subscription — verified gotcha). +export async function startDuesCheckout() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const admin = createAdminClient() + const cfg = await getStripeConfig(admin, member.space_id) + if (!cfg || !isStripeConfigured(cfg)) return { error: 'Online dues are not set up for this space yet.' } + const secret = await readStripeSecret(admin, member.space_id, cfg.secret_key_ref) + if (!secret) return { error: 'Stripe is misconfigured (no secret key). Ask an admin.' } + + const { data: m } = await admin + .from('space_members') + .select('tier, email, display_name') + .eq('id', member.id) + .eq('space_id', member.space_id) + .maybeSingle() + if (!m) return { error: 'Member not found' } + const price = priceIdForTier(cfg, m.tier as string | null) + if (!price) return { error: 'No dues plan is configured for your membership tier yet.' } + + try { + const stripe = getStripe(secret) + const { data: billing } = await admin + .from('member_billing') + .select('stripe_customer_id') + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .maybeSingle() + + let customerId = billing?.stripe_customer_id as string | undefined + if (!customerId) { + const customer = await stripe.customers.create({ + email: (m.email as string | null) ?? undefined, + name: (m.display_name as string | null) ?? undefined, + metadata: { space_id: member.space_id, member_id: member.id }, + }) + customerId = customer.id + await admin + .from('member_billing') + .upsert( + { space_id: member.space_id, member_id: member.id, stripe_customer_id: customerId }, + { onConflict: 'space_id,member_id' }, + ) + } + + const origin = await appOrigin() + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer: customerId, + line_items: [{ price, quantity: 1 }], + client_reference_id: member.id, + metadata: { space_id: member.space_id, member_id: member.id }, + subscription_data: { metadata: { space_id: member.space_id, member_id: member.id } }, + success_url: `${origin}/me?dues=success`, + cancel_url: `${origin}/me?dues=cancelled`, + }) + if (!session.url) return { error: 'Stripe did not return a checkout URL.' } + return { data: { url: session.url } } + } catch (e) { + return { error: e instanceof Error ? e.message : 'Could not start checkout.' } + } +} + +// Stripe-hosted Billing Portal so the member manages card/plan/cancel. +export async function startBillingPortal() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const admin = createAdminClient() + const cfg = await getStripeConfig(admin, member.space_id) + const secret = cfg ? await readStripeSecret(admin, member.space_id, cfg.secret_key_ref) : null + if (!secret) return { error: 'Billing is not set up for this space.' } + + const { data: billing } = await admin + .from('member_billing') + .select('stripe_customer_id') + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .maybeSingle() + const customerId = billing?.stripe_customer_id as string | undefined + if (!customerId) return { error: 'No billing on file yet — pay your dues first.' } + + try { + const stripe = getStripe(secret) + const origin = await appOrigin() + const portal = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${origin}/me`, + }) + return { data: { url: portal.url } } + } catch (e) { + return { error: e instanceof Error ? e.message : 'Could not open the billing portal.' } + } +} + +// The caller's own billing status (member_billing is admin-readable via RLS, +// so the self-view goes through the service client, like getMyCards). +export async function getMyBilling() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data } = await createAdminClient() + .from('member_billing') + .select('subscription_status, current_period_end, stripe_customer_id') + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .maybeSingle() + return { + data: data + ? { + status: (data.subscription_status as string | null) ?? null, + currentPeriodEnd: (data.current_period_end as string | null) ?? null, + hasCustomer: !!data.stripe_customer_id, + } + : null, + } +} + +// Admin dues view: every member's billing status. +export async function listMemberBilling() { + const supabase = await createClient() + const auth = await requireMemberWithRole(supabase, ['admin', 'board', 'treasurer'], 'Admin access required') + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data, error } = await createAdminClient() + .from('member_billing') + .select('member_id, subscription_status, current_period_end, space_members!member_billing_member_id_fkey(display_name, email, tier)') + .eq('space_id', member.space_id) + .order('updated_at', { ascending: false }) + if (error) return { error: error.message } + return { + data: (data ?? []).map(r => { + const sm = r.space_members as { display_name: string | null; email: string | null; tier: string | null } | { display_name: string | null; email: string | null; tier: string | null }[] | null + const mem = Array.isArray(sm) ? sm[0] : sm + return { + memberId: r.member_id as string, + name: mem?.display_name ?? 'Member', + email: mem?.email ?? null, + tier: mem?.tier ?? null, + status: (r.subscription_status as string | null) ?? null, + currentPeriodEnd: (r.current_period_end as string | null) ?? null, + } + }), + } +}