feat(billing): P1c Stripe dep + server client + vault-backed config
Pin [email protected] (exact). lib/stripe/client.ts: per-request getStripe with the pinned apiVersion (server-only, never global/client). Helpers in lib/stripe/config.ts (plain server module — they take the admin client so they must NOT be 'use server' exports): store/read the secret key + webhook signing secret in the AES-256-GCM secrets vault (door pattern), getStripeConfig. lib/actions/stripe.ts (actions only): getStripeSettings (status only, never returns secrets) + saveStripeSettings (write-only secrets, integrations.config upsert). Zod stripeSettingsSchema.
This commit is contained in:
@@ -65,3 +65,6 @@ export * from './door'
|
||||
|
||||
// Presence & attendance (check-in / check-out / hosting)
|
||||
export * from './presence'
|
||||
|
||||
// Stripe recurring dues (product spine Phase 1)
|
||||
export * from './stripe'
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { 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'
|
||||
|
||||
// Admin-facing settings view. NEVER returns secret values, only whether they
|
||||
// are set, so the config screen can show status without exposing keys.
|
||||
export async function getStripeSettings() {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const cfg = (await getStripeConfig(createAdminClient(), member.space_id)) ?? {}
|
||||
return {
|
||||
data: {
|
||||
mode: cfg.mode ?? 'test',
|
||||
publishable_key: cfg.publishable_key ?? '',
|
||||
grace_days: cfg.grace_days ?? 7,
|
||||
prices: cfg.prices ?? {},
|
||||
hasSecretKey: !!cfg.secret_key_ref,
|
||||
hasWebhookSecret: !!cfg.webhook_secret_ref,
|
||||
configured: isStripeConfigured(cfg),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveStripeSettings(input: unknown) {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
const v = parseInput(stripeSettingsSchema, input)
|
||||
if (!v.ok) return { error: v.error }
|
||||
const s = v.data
|
||||
|
||||
const admin = createAdminClient()
|
||||
const existing = (await getStripeConfig(admin, member.space_id)) ?? {}
|
||||
|
||||
// Secret fields are write-only: a provided non-blank value rotates the
|
||||
// stored secret; blank/omitted keeps the existing ref.
|
||||
let secretKeyRef = existing.secret_key_ref
|
||||
if (s.secret_key && s.secret_key.trim()) {
|
||||
const id = await storeStripeSecret(admin, member.space_id, 'Stripe secret key', s.secret_key.trim(), user?.id ?? null)
|
||||
if (!id) return { error: 'Could not store the Stripe secret key.' }
|
||||
secretKeyRef = id
|
||||
}
|
||||
let webhookRef = existing.webhook_secret_ref
|
||||
if (s.webhook_secret && s.webhook_secret.trim()) {
|
||||
const id = await storeStripeSecret(admin, member.space_id, 'Stripe webhook signing secret', s.webhook_secret.trim(), user?.id ?? null)
|
||||
if (!id) return { error: 'Could not store the Stripe webhook secret.' }
|
||||
webhookRef = id
|
||||
}
|
||||
|
||||
const prices: Record<string, string> = {}
|
||||
for (const [k, val] of Object.entries(s.prices ?? {})) {
|
||||
if (typeof val === 'string' && val.trim()) prices[k] = val.trim()
|
||||
}
|
||||
|
||||
const config: StripeConfig = {
|
||||
mode: s.mode,
|
||||
publishable_key: s.publishable_key?.trim() || undefined,
|
||||
secret_key_ref: secretKeyRef,
|
||||
webhook_secret_ref: webhookRef,
|
||||
grace_days: s.grace_days ?? 7,
|
||||
prices,
|
||||
}
|
||||
|
||||
const { error } = await admin.from('integrations').upsert(
|
||||
{
|
||||
space_id: member.space_id,
|
||||
platform: 'stripe',
|
||||
name: 'Stripe',
|
||||
is_connected: isStripeConfigured(config),
|
||||
config,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'space_id,platform' },
|
||||
)
|
||||
if (error) return { error: error.message }
|
||||
|
||||
revalidatePath('/settings')
|
||||
return { data: { ok: true, configured: isStripeConfigured(config) } }
|
||||
}
|
||||
Reference in New Issue
Block a user