add embedded checkout

This commit is contained in:
JCEEE
2026-08-20 07:35:31 +01:00
parent c6e987a852
commit 397543c880
45 changed files with 5731 additions and 334 deletions
+144
View File
@@ -0,0 +1,144 @@
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';
// True until a real key is wired in .env. When dummy, we don't hit the Stripe
// API — checkout/billing actions return simulated session URLs so the flow can
// be established end-to-end before the account is connected.
export const isDummyStripe = !STRIPE_SECRET_KEY;
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 (app-side). In practice these should live in a PB collection;
// for the dummy flow a static map is enough. Maps code -> trial days.
export const TRIAL_CODES: Record<string, number> = {
FAM3MONTHS: 90,
FAMTRIAL: 30
};
export function resolveTrialDays(code?: string): number | null {
if (!code) return null;
const days = TRIAL_CODES[code.trim().toUpperCase()];
return typeof days === 'number' ? 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}/subscriptions?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);
}
// Stripe Billing portal session for managing/cancelling the subscription.
export async function createBillingPortalSession(customerId: string, origin: string) {
return stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${origin}/account`
});
}
// 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;
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}/account?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 };
}