183 lines
6.8 KiB
TypeScript
183 lines
6.8 KiB
TypeScript
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<number | null> {
|
|
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 };
|
|
}
|