import Stripe from 'stripe'; import { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_CLI_WEBHOOK_SECRET, STRIPE_PRICE_TRIAL, STRIPE_PRICE_MONTHLY, STRIPE_PRICE_YEARLY } from '$app/env/private'; // Dummy key placeholder — swap for a real test/live secret when the account is connected. const KEY: string = String(STRIPE_SECRET_KEY) || 'sk_test_dummy_famchamp_not_connected'; export const stripe = new Stripe(KEY); // 3-tier plans: `trial` is not a paid Stripe price — it's app-side validated // (a code) and realised as a subscription with trial_period_days. export type PlanId = 'trial' | 'monthly' | 'yearly'; export const PLAN_IDS: Record<'trial' | 'monthly' | 'yearly', string> = { trial: String(STRIPE_PRICE_TRIAL) || 'price_dummy_trial', monthly: String(STRIPE_PRICE_MONTHLY) || 'price_dummy_monthly', yearly: String(STRIPE_PRICE_YEARLY) || 'price_dummy_yearly' }; // Trial codes live in PB (`accesscodes` with trialDays > 0, active) so the // platform admin can manage them. A matching active code maps to Stripe // trial_period_days at checkout; anything else is not a trial code. export async function resolveTrialDays(code?: string): Promise { if (!code) return null; const { pbAdmin } = await import('$lib/server/pocketbase'); const recs = (await pbAdmin.getList( 'accesscodes', `value = '${code.trim().toUpperCase()}' && active = true` )) as any[]; const days = Number(recs?.[0]?.trialDays) || 0; return days > 0 ? days : null; } export function verifyStripeEvent(rawBody: string, signature: string): Stripe.Event { // Prefer the CLI secret (local dev) when set; else the dashboard secret. const secret = String(STRIPE_CLI_WEBHOOK_SECRET) || String(STRIPE_WEBHOOK_SECRET); return stripe.webhooks.constructEvent(rawBody, signature, secret); } // Redirect-mode Checkout session. `priceId` resolves from a PlanId (or direct // Stripe price id). Metadata carries the famId so the webhook can attribute it. export async function createCheckoutSession(opts: { plan: PlanId | 'price' | 'trial'; priceId?: string; famId: string; email?: string | null; customerId?: string | null; trialDays?: number | null; origin: string; }) { const isTrial = opts.plan === 'trial'; let priceId: string | undefined; if (opts.plan === 'trial' || opts.plan === 'monthly' || opts.plan === 'yearly') priceId = PLAN_IDS[opts.plan]; else priceId = opts.priceId; const params: Stripe.Checkout.SessionCreateParams = { mode: 'subscription', metadata: { famId: opts.famId, plan: opts.plan }, success_url: `${opts.origin}/account?checkout=success`, cancel_url: `${opts.origin}/pricing?checkout=cancelled` }; // Attach customer if we already have a Stripe customer id for this fam. if (opts.customerId) { params.customer = opts.customerId; } else if (opts.email) { params.customer_email = opts.email; } if (isTrial && priceId) { // Trial is an otherwise-free subscription whose price is a $0 plan; the // trial period is set explicitly so no card charge happens for X days. if (opts.trialDays) params.subscription_data = { trial_period_days: opts.trialDays }; params.line_items = [{ price: priceId, quantity: 1 }]; } else if (priceId) { params.line_items = [{ price: priceId, quantity: 1 }]; } else { throw new Error(`No Stripe price configured for plan "${opts.plan}"`); } return stripe.checkout.sessions.create(params); } // Live subscription state for the settings UI (source of truth = Stripe). export async function getSubscriptionStatus( customerId: string ): Promise<{ active: boolean; cancelAtPeriodEnd: boolean; endsAt?: string }> { const subs = await stripe.subscriptions.list({ customer: customerId, status: 'all', limit: 10 }); const active = subs.data.find((s) => ['active', 'trialing', 'past_due'].includes(s.status)); if (!active) return { active: false, cancelAtPeriodEnd: false }; const itemEnd = active.items.data[0]?.current_period_end; return { active: true, cancelAtPeriodEnd: !!active.cancel_at_period_end, endsAt: new Date((itemEnd ?? Math.floor(Date.now() / 1000)) * 1000).toISOString() }; } // In-app cancellation: flags the customer's active subscription to end at the // close of the current paid period (no refund, access continues until then). // The existing webhook flips paymentMode='canceled' when it actually ends. export async function cancelSubscriptionAtPeriodEnd( customerId: string ): Promise<{ ended: boolean; endsAt?: string }> { const subs = await stripe.subscriptions.list({ customer: customerId, status: 'all', limit: 10 }); const active = subs.data.find((s) => ['active', 'trialing', 'past_due'].includes(s.status)); if (!active) return { ended: false }; if (!active.cancel_at_period_end) { await stripe.subscriptions.update(active.id, { cancel_at_period_end: true }); } // Newer Stripe API versions carry current_period_end on the sub item. const itemEnd = active.items.data[0]?.current_period_end; const periodEnd = itemEnd ?? Math.floor(Date.now() / 1000) + 30 * 86400; return { ended: true, endsAt: new Date(periodEnd * 1000).toISOString() }; } // Stripe Billing portal session for managing/cancelling the subscription. export async function createBillingPortalSession(customerId: string, origin: string, famSlug = '') { return stripe.billingPortal.sessions.create({ customer: customerId, return_url: `${origin}/${famSlug}?checkout=return` }); } // Embedded (in-page) Checkout session. Returns the client_secret the browser // passes to @stripe/stripe-js `createEmbeddedCheckoutPage`. Embedded mode uses // return_url (same-origin) instead of success/cancel_url, and subscriptions // need a customer attached. export async function createEmbeddedCheckoutSession(opts: { plan: PlanId | 'price' | 'trial'; priceId?: string; famId: string; famSlug: string; email?: string | null; customerId?: string | null; trialDays?: number | null; origin: string; }) { const isTrial = opts.plan === 'trial'; let priceId: string | undefined; if (opts.plan === 'trial' || opts.plan === 'monthly' || opts.plan === 'yearly') priceId = PLAN_IDS[opts.plan]; else priceId = opts.priceId; if (!priceId) throw new Error(`No Stripe price configured for plan "${opts.plan}"`); const params: Stripe.Checkout.SessionCreateParams = { mode: 'subscription', ui_mode: 'embedded_page', metadata: { famId: opts.famId, plan: opts.plan }, return_url: `${opts.origin}/${opts.famSlug}?checkout=return` }; if (opts.customerId) { params.customer = opts.customerId; } else { // No customer yet — in subscription mode Stripe creates one automatically; // just seed the email if we have it. if (opts.email) params.customer_email = opts.email; } if (isTrial && opts.trialDays) params.subscription_data = { trial_period_days: opts.trialDays }; params.line_items = [{ price: priceId, quantity: 1 }]; const session = await stripe.checkout.sessions.create(params); return { clientSecret: session.client_secret, sessionId: session.id }; }