add embedded checkout
This commit is contained in:
@@ -30,8 +30,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hiseb/confetti": "^2.0.2",
|
||||
"@stripe/stripe-js": "^9.13.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"pocketbase": "^0.27.0",
|
||||
"qrcode": "^1.5.4"
|
||||
"qrcode": "^1.5.4",
|
||||
"stripe": "^22.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -16,5 +16,17 @@ export const variables = defineEnvVars({
|
||||
PB_PASSWORD: { public: false, schema: withDefault('debug123') },
|
||||
// Server-only secret used to derive a child member's PB password from
|
||||
// (famSlug + username). Never expose client-side. OTP is the access gate.
|
||||
MEMBER_SECRET: { public: false, schema: withDefault('famchamp-member-secret') }
|
||||
MEMBER_SECRET: { public: false, schema: withDefault('famchamp-member-secret') },
|
||||
// Stripe (server-only). Keys left blank until a real account is connected.
|
||||
STRIPE_SECRET_KEY: { public: false, schema: withDefault('') },
|
||||
// Stripe webhook signing secret (prod/dashboard endpoint).
|
||||
STRIPE_WEBHOOK_SECRET: { public: false, schema: withDefault('') },
|
||||
// Stripe CLI webhook signing secret (local dev only — overrides the
|
||||
// dashboard secret when set, so `stripe listen` works without touching prod).
|
||||
STRIPE_CLI_WEBHOOK_SECRET: { public: false, schema: withDefault('') },
|
||||
STRIPE_PRICE_TRIAL: { public: false, schema: withDefault('') },
|
||||
STRIPE_PRICE_MONTHLY: { public: false, schema: withDefault('') },
|
||||
STRIPE_PRICE_YEARLY: { public: false, schema: withDefault('') },
|
||||
// Stripe publishable key (client-side for Checkout redirect).
|
||||
PUBLIC_STRIPE_PUBLISHABLE_KEY: { public: true, schema: withDefault('') }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { pbAdmin } from '$lib/server/pocketbase';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
// Shared Stripe event handling. Both the real webhook (/account/webhook) and
|
||||
// the dev-only simulator (/account/webhook/simulate) route through here so the
|
||||
// DB effects are identical.
|
||||
export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const famId = session.metadata?.famId;
|
||||
if (famId && session.customer) {
|
||||
await pbAdmin.update('fams', famId, {
|
||||
stripeCustomerId: String(session.customer),
|
||||
active: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.deleted':
|
||||
case 'customer.subscription.paused': {
|
||||
const sub = event.data.object as Stripe.Subscription;
|
||||
await setActiveFromSubscription(sub);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveFromSubscription(sub: Stripe.Subscription) {
|
||||
// The fam that owns this subscription — look up by the customer id stored
|
||||
// on the fam (set at checkout). Stripe customer id is unique per fam.
|
||||
const fams = await pbAdmin.getList('fams', `stripeCustomerId = '${sub.customer}'`);
|
||||
const fam = fams[0];
|
||||
if (!fam) return;
|
||||
// Active only while the sub is trialing/active (not past_due/canceled/paused).
|
||||
const active =
|
||||
sub.status === 'trialing' || sub.status === 'active' || sub.status === 'past_due';
|
||||
await pbAdmin.update('fams', fam.id, { active });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -324,6 +324,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Card title="Billing & Subscription" cols={1}>
|
||||
<p class="hint">
|
||||
{data.fam?.active === false
|
||||
? 'Your subscription is paused — interactions and payments are disabled.'
|
||||
: 'Manage your plan, billing details, or pause your subscription.'}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<Button href="/account" variant="primary" size="sm">Account & Billing</Button>
|
||||
<Button href="/subscriptions" variant="secondary" size="sm">Plans</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Data" cols={1}>
|
||||
<div class="actions">
|
||||
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
|
||||
|
||||
Reference in New Issue
Block a user