Add subscriptions to your SvelteKit app. Scaffolds product and price sync, a checkout flow, and the server wiring required to go from zero to a working Stripe integration without hand-rolling the plumbing.
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import { INTERNAL_JOB_SECRET } from "$env/static/private";
import { subscriptionRecordFields } from "../../../../webhooks/stripe/handlers/subscription/shared";
export const POST = async ({ request, locals }) => {
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${INTERNAL_JOB_SECRET}`) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const subscriptions = await stripe.subscriptions.list({
status: "all",
limit: 100,
});
for (const subscription of subscriptions.data) {
const fields = subscriptionRecordFields(subscription);
if (!fields) continue;
let customer;
try {
customer = await locals.admin
.collection("stripe_customers")
.getOne(fields.customer);
} catch {
console.log(
`Skipping subscription ${subscription.id}: customer ${fields.customer} not found locally`,
);
continue;
}
try {
await locals.admin
.collection("stripe_subscriptions")
.update(subscription.id, fields);
} catch {
await locals.admin.collection("stripe_subscriptions").create({
...fields,
user: customer.user,
});
}
}
console.log("Subscriptions synced");
return json({ message: "Subscriptions synced" });
};
import Stripe from "stripe";
import { subscriptionRecordFields } from "./shared";
/**
* Handles customer.subscription.created event
*/
export async function handleSubscriptionCreated(
subscription: Stripe.Subscription,
locals: App.Locals,
) {
const fields = subscriptionRecordFields(subscription);
if (!fields) return;
const customer = await locals.admin
.collection("stripe_customers")
.getOne(fields.customer);
await locals.admin.collection("stripe_subscriptions").create({
...fields,
user: customer.user,
});
}
import Stripe from "stripe";
import { subscriptionRecordFields } from "./shared";
/**
* Handles customer.subscription.deleted event
*/
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription,
locals: App.Locals,
) {
const fields = subscriptionRecordFields(subscription);
if (!fields) return;
try {
await locals.admin
.collection("stripe_subscriptions")
.update(subscription.id, {
...fields,
status: "canceled",
canceled_at: fields.canceled_at ?? new Date().toISOString(),
});
} catch {
// Row may not exist; ignore.
console.log(`Subscription ${subscription.id} not found for deletion`);
}
}
import Stripe from "stripe";
function unixToIso(unix: number | null | undefined): string | null {
if (!unix) return null;
return new Date(unix * 1000).toISOString();
}
/**
* Extracts the subset of Stripe.Subscription fields that map to a
* stripe_subscriptions row. Returns null if the subscription has no
* price (shouldn't happen, but guard).
*
* `current_period_start`/`current_period_end` live on the subscription item
* in API versions >= 2025-03-31 — read from there with a fallback to the
* subscription-level field for older events.
*/
export function subscriptionRecordFields(subscription: Stripe.Subscription) {
const item = subscription.items.data[0];
const priceId = item?.price.id;
if (!priceId) {
console.error(`Subscription ${subscription.id} has no price`);
return null;
}
const customerId =
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
const itemPeriodStart = (item as unknown as { current_period_start?: number })
.current_period_start;
const itemPeriodEnd = (item as unknown as { current_period_end?: number })
.current_period_end;
const subPeriodStart = (
subscription as unknown as { current_period_start?: number }
).current_period_start;
const subPeriodEnd = (
subscription as unknown as { current_period_end?: number }
).current_period_end;
return {
id: subscription.id,
customer: customerId,
price: priceId,
status: subscription.status,
current_period_start: unixToIso(itemPeriodStart ?? subPeriodStart),
current_period_end: unixToIso(itemPeriodEnd ?? subPeriodEnd),
cancel_at_period_end: subscription.cancel_at_period_end,
canceled_at: unixToIso(subscription.canceled_at),
};
}
import Stripe from "stripe";
import { subscriptionRecordFields } from "./shared";
/**
* Handles customer.subscription.updated event
*/
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
locals: App.Locals,
) {
const fields = subscriptionRecordFields(subscription);
if (!fields) return;
try {
await locals.admin
.collection("stripe_subscriptions")
.update(subscription.id, fields);
} catch {
// Row may not exist if the created event was missed; upsert instead.
const customer = await locals.admin
.collection("stripe_customers")
.getOne(fields.customer);
await locals.admin.collection("stripe_subscriptions").create({
...fields,
user: customer.user,
});
}
}
/// <reference path="../types.d.ts" />
cronAdd("stripe-products", "*/2 * * * *", () => {
const utils = require(`${__hooks}/utils.js`);
utils.triggerJob("/api/stripe/sync/products");
});
cronAdd("stripe-prices", "*/2 * * * *", () => {
const utils = require(`${__hooks}/utils.js`);
utils.triggerJob("/api/stripe/sync/prices");
});
cronAdd("stripe-customers", "* * * * *", () => {
const utils = require(`${__hooks}/utils.js`);
utils.triggerJob("/api/stripe/sync/customers");
});
cronAdd("stripe-subscriptions", "*/2 * * * *", () => {
const utils = require(`${__hooks}/utils.js`);
utils.triggerJob("/api/stripe/sync/subscriptions");
});
<script lang="ts" module>
import HomeIcon from "@lucide/svelte/icons/home";
let data = {
navMain: [
{
title: "Dashboard",
url: "/dashboard",
icon: HomeIcon,
},
],
};
</script>
<script lang="ts">
import NavMain from "./nav-main.svelte";
import NavUser from "./nav-user.svelte";
import * as Sidebar from "$lib/components/ui/sidebar";
import type { ComponentProps } from "svelte";
let {
user,
meta,
subscription = null,
ref = $bindable(null),
...restProps
}: ComponentProps<typeof Sidebar.Root> & {
user: any;
meta: any;
subscription?: { id: string; productName: string | null } | null;
} = $props();
</script>
<Sidebar.Root bind:ref variant="inset" {...restProps}>
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton size="lg">
{#snippet child({ props })}
<a href="/dashboard" {...props}>
<div
class="bg-sidebar-accent text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
>
<img src="/favicon.svg" alt="logo" class="size-6" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{meta.appName}</span>
</div>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<NavMain items={data.navMain} />
</Sidebar.Content>
<Sidebar.Footer>
<NavUser {user} {subscription} />
</Sidebar.Footer>
</Sidebar.Root>
<script lang="ts">
import { toggleMode, mode } from "mode-watcher";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import Settings2Icon from "@lucide/svelte/icons/settings-2";
import CreditCardIcon from "@lucide/svelte/icons/credit-card";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import * as Avatar from "$lib/components/ui/avatar";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import * as Sidebar from "$lib/components/ui/sidebar";
let data = {
navUser: [
{
title: "Settings",
url: "/settings",
icon: Settings2Icon,
},
{
title: "Billing",
url: "/billing",
icon: CreditCardIcon,
},
],
};
let {
user,
subscription = null,
}: {
user: {
id: string;
name: string;
email: string;
avatar: string;
};
subscription?: { id: string; productName: string | null } | null;
} = $props();
const planLabel = $derived(subscription?.productName ?? "Free");
const sidebar = Sidebar.useSidebar();
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
{#if user.avatar}
<Avatar.Image
src="/api/files/users/{user.id}/{user.avatar}"
alt={user.name}
/>
{/if}
<Avatar.Fallback class="rounded-lg">
{user.name?.charAt(0).toUpperCase() ||
user.email?.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">
{user.name || user.email}
</span>
<span class="truncate text-xs text-muted-foreground">
{planLabel}
</span>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
{#if user.avatar}
<Avatar.Image
src="/api/files/users/{user.id}/{user.avatar}"
alt={user.name}
/>
{/if}
<Avatar.Fallback class="rounded-lg">
{user.name?.charAt(0).toUpperCase() ||
user.email?.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">
{user.name || user.email}
</span>
<span class="truncate text-xs">
{planLabel}
</span>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onclick={toggleMode}>
{#if mode.current === "dark"}
<SunIcon />
Light mode
{:else}
<MoonIcon />
Dark mode
{/if}
</DropdownMenu.Item>
{#each data.navUser as item}
<DropdownMenu.Item class="p-0">
<a
href={item.url}
class="flex cursor-default items-center gap-2 px-2 py-1.5 w-full"
>
<item.icon />
{item.title}
</a>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item class="p-0">
<form action="/logout" method="post" class="w-full">
<button
type="submit"
class="flex cursor-default items-center gap-2 px-2 py-1.5 w-full"
>
<LogOutIcon />
Log out
</button>
</form>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
async function loadActiveSubscription(locals: App.Locals, userId: string) {
const subscriptions = await locals.admin
.collection("stripe_subscriptions")
.getFullList({
filter: locals.admin.filter(
'user = {:user} && (status = "active" || status = "trialing")',
{ user: userId },
),
expand: "price",
sort: "-created",
})
.catch(() => []);
const sub = subscriptions[0];
if (!sub) return null;
const price = sub.expand?.price;
let productName: string | null = null;
if (price?.product) {
const product = await locals.admin
.collection("stripe_products")
.getOne(price.product)
.catch(() => null);
productName = product?.name ?? null;
}
return {
id: sub.id,
status: sub.status as string,
productName,
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end),
};
}
export const load = async ({ locals }) => {
const user = locals.pb.authStore.record!;
const breadcrumbs = [{ title: "Home", url: "/dashboard" }];
const subscription = await loadActiveSubscription(locals, user.id);
return { user, breadcrumbs, subscription };
};
<script lang="ts">
import * as Sidebar from "$lib/components/ui/sidebar";
import { Separator } from "$lib/components/ui/separator";
import AppSidebar from "$lib/components/app-sidebar.svelte";
import * as Breadcrumb from "$lib/components/ui/breadcrumb";
import { page } from "$app/state";
let breadcrumbs = $derived(page.data.breadcrumbs || []) as {
title: string;
url: string;
}[];
let { data, children } = $props();
</script>
<div class="min-h-dvh dashboard">
<Sidebar.Provider>
<AppSidebar
user={data.user}
meta={data.meta}
subscription={data.subscription}
/>
<Sidebar.Inset>
<header class="flex h-16 shrink-0 items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator
orientation="vertical"
class="mr-2 data-[orientation=vertical]:h-4"
/>
{#if breadcrumbs.length > 0}
<Breadcrumb.Root>
<Breadcrumb.List>
{#if breadcrumbs.length > 1}
{#each breadcrumbs.slice(0, -1) as breadcrumb}
<Breadcrumb.Item>
<Breadcrumb.Link href={breadcrumb.url}
>{breadcrumb.title}</Breadcrumb.Link
>
</Breadcrumb.Item>
<Breadcrumb.Separator />
{/each}
{/if}
<Breadcrumb.Page>
{breadcrumbs[breadcrumbs.length - 1].title}
</Breadcrumb.Page>
</Breadcrumb.List>
</Breadcrumb.Root>
{/if}
</div>
</header>
<div class="flex flex-1 flex-col p-4 pt-0">
{@render children?.()}
</div>
</Sidebar.Inset>
</Sidebar.Provider>
</div>
<style>
:global(body:has(div.dashboard)) {
background-color: var(--color-sidebar);
}
</style>
import { error } from "@sveltejs/kit";
import stripe from "$lib/stripe";
type RecurringInfo = {
interval: string;
interval_count: number;
trial_period_days?: number | null;
};
function parseRecurring(value: unknown): RecurringInfo | null {
if (!value) return null;
if (typeof value === "object") return value as RecurringInfo;
try {
return JSON.parse(value as string) as RecurringInfo;
} catch {
return null;
}
}
const ACTIVE_SUBSCRIPTION_STATUSES = [
"active",
"trialing",
"past_due",
"incomplete",
];
export const load = async ({ locals, depends, parent }) => {
const { user } = await parent();
const customer = await locals.admin
.collection("stripe_customers")
.getFirstListItem(locals.admin.filter("user = {:user}", { user: user.id }));
if (!customer || !customer.id) {
return error(400, "Customer not found");
}
const stripeCustomer = await stripe.customers.retrieve(customer.id);
if (stripeCustomer.deleted) {
return error(400, "Customer not found");
}
const paymentMethods = await stripe.customers.listPaymentMethods(
customer.id,
{
type: "card",
allow_redisplay: "always",
},
);
const subscriptionRecords = await locals.admin
.collection("stripe_subscriptions")
.getFullList({
filter: locals.admin.filter("user = {:user}", { user: user.id }),
expand: "price",
sort: "-created",
});
const recurringPrices = await locals.admin
.collection("stripe_prices")
.getFullList({
filter: locals.admin.filter("type = {:type} && active = true", {
type: "recurring",
}),
});
const productIds: string[] = Array.from(
new Set(
recurringPrices
.map((p) => p.product)
.filter((id): id is string => Boolean(id)),
),
);
const productMap = new Map<string, string>();
if (productIds.length > 0) {
const products = await locals.admin
.collection("stripe_products")
.getFullList({
filter: productIds.map((id) => `id = "${id}"`).join(" || "),
});
for (const product of products) {
productMap.set(product.id, product.name ?? product.id);
}
}
const availablePlans = recurringPrices.map((price) => ({
id: price.id,
productName:
(price.product && productMap.get(price.product)) ?? price.product ?? "",
unitAmount: (price.unit_amount ?? 0) as number,
currency: (price.currency ?? "") as string,
recurring: parseRecurring(price.recurring),
}));
const subscriptions = subscriptionRecords.map((sub) => {
const expanded = sub.expand?.price;
return {
id: sub.id,
status: sub.status as string,
currentPeriodStart: (sub.current_period_start as string | null) ?? null,
currentPeriodEnd: (sub.current_period_end as string | null) ?? null,
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end),
canceledAt: (sub.canceled_at as string | null) || null,
priceId: sub.price as string,
productName: expanded
? ((expanded.product && productMap.get(expanded.product)) ??
expanded.product ??
null)
: null,
unitAmount: expanded
? ((expanded.unit_amount ?? null) as number | null)
: null,
currency: expanded
? ((expanded.currency ?? null) as string | null)
: null,
recurring: expanded ? parseRecurring(expanded.recurring) : null,
};
});
const activeSubscription =
subscriptions.find((s) =>
ACTIVE_SUBSCRIPTION_STATUSES.includes(s.status),
) || null;
// If the active plan isn't in the list (e.g., price was deactivated), include
// it so it can still be shown as the current plan.
if (
activeSubscription &&
!availablePlans.some((p) => p.id === activeSubscription.priceId)
) {
availablePlans.push({
id: activeSubscription.priceId,
productName: activeSubscription.productName ?? "",
unitAmount: activeSubscription.unitAmount ?? 0,
currency: activeSubscription.currency ?? "",
recurring: activeSubscription.recurring,
});
}
availablePlans.sort((a, b) => a.unitAmount - b.unitAmount);
depends("app:billing");
return {
paymentMethods: paymentMethods.data.map((pm) => ({
id: pm.id,
brand: pm.card!.brand,
last4: pm.card!.last4,
expMonth: pm.card!.exp_month,
expYear: pm.card!.exp_year,
isDefault: pm.id === customer.default_payment_method,
})),
user,
hasDefaultPaymentMethod: Boolean(customer.default_payment_method),
activeSubscription,
availablePlans,
};
};
async function getUserCustomer(locals: App.Locals) {
const userId = locals.pb.authStore.record?.id;
if (!userId) {
return error(401, "Unauthorized");
}
const customer = await locals.admin
.collection("stripe_customers")
.getFirstListItem(`user.id = "${userId}"`);
if (!customer || !customer.id) {
return error(400, "Customer not found");
}
return customer;
}
// A subscription can be updated in-place unless it has fully terminated.
const UPDATABLE_STATUSES = [
"active",
"trialing",
"past_due",
"incomplete",
"paused",
];
async function findUpdatableSubscription(locals: App.Locals, userId: string) {
const filterClause = UPDATABLE_STATUSES.map((s) => `status = "${s}"`).join(
" || ",
);
const subs = await locals.admin
.collection("stripe_subscriptions")
.getFullList({
filter: locals.admin.filter(`user = {:user} && (${filterClause})`, {
user: userId,
}),
sort: "-created",
});
return subs[0] ?? null;
}
export const actions = {
setDefaultPaymentMethod: async ({ locals, request }) => {
const formData = await request.formData();
const paymentMethodId = formData.get("paymentMethodId") as string;
const customer = await locals.admin
.collection("stripe_customers")
.getFirstListItem(`user.id = "${locals.pb.authStore.record?.id}"`);
if (!customer || !customer.id) {
return error(400, "Customer not found");
}
await stripe.customers.update(customer.id, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
await locals.admin.collection("stripe_customers").update(customer.id, {
default_payment_method: paymentMethodId,
});
},
deletePaymentMethod: async ({ locals, request }) => {
const formData = await request.formData();
const paymentMethodId = formData.get("paymentMethodId") as string;
const customer = await locals.admin
.collection("stripe_customers")
.getFirstListItem(`user.id = "${locals.pb.authStore.record?.id}"`);
const defaultPaymentMethod = customer.default_payment_method;
await stripe.paymentMethods.detach(paymentMethodId);
// If default payment method was deleted, update default payment method
if (defaultPaymentMethod === paymentMethodId) {
const paymentMethods = await stripe.paymentMethods.list({
customer: customer.id,
});
if (paymentMethods.data.length > 0) {
const defaultPaymentMethod = paymentMethods.data[0];
await stripe.customers.update(customer.id, {
invoice_settings: {
default_payment_method: defaultPaymentMethod.id,
},
});
await locals.admin.collection("stripe_customers").update(customer.id, {
default_payment_method: defaultPaymentMethod.id,
});
} else {
await locals.admin.collection("stripe_customers").update(customer.id, {
default_payment_method: null,
});
}
}
},
selectPlan: async ({ locals, request }) => {
const formData = await request.formData();
const priceId = formData.get("priceId") as string;
if (!priceId) return error(400, "Price ID is required");
const userId = locals.pb.authStore.record?.id;
if (!userId) return error(401, "Unauthorized");
// If an updatable subscription already exists, change its price in place
// rather than creating a duplicate. Covers switch, resume-to-different-plan,
// and races where the webhook hasn't delivered the existing sub yet.
const existing = await findUpdatableSubscription(locals, userId);
if (existing) {
const stripeSub = await stripe.subscriptions.retrieve(existing.id);
const item = stripeSub.items.data[0];
if (!item) return error(500, "Subscription has no items");
await stripe.subscriptions.update(existing.id, {
items: [{ id: item.id, price: priceId }],
proration_behavior: "create_prorations",
cancel_at_period_end: false,
});
return;
}
const customer = await getUserCustomer(locals);
if (!customer.default_payment_method) {
return error(400, "Add a default payment method before subscribing");
}
await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: priceId }],
default_payment_method: customer.default_payment_method,
payment_behavior: "error_if_incomplete",
off_session: true,
});
},
cancelSubscription: async ({ locals, request }) => {
const formData = await request.formData();
const subscriptionId = formData.get("subscriptionId") as string;
if (!subscriptionId) return error(400, "Subscription ID is required");
const userId = locals.pb.authStore.record?.id;
if (!userId) return error(401, "Unauthorized");
// Verify the subscription belongs to this user before touching Stripe.
try {
const record = await locals.admin
.collection("stripe_subscriptions")
.getOne(subscriptionId);
if (record.user !== userId) {
return error(403, "Forbidden");
}
} catch {
return error(404, "Subscription not found");
}
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
},
resumeSubscription: async ({ locals, request }) => {
const formData = await request.formData();
const subscriptionId = formData.get("subscriptionId") as string;
if (!subscriptionId) return error(400, "Subscription ID is required");
const userId = locals.pb.authStore.record?.id;
if (!userId) return error(401, "Unauthorized");
try {
const record = await locals.admin
.collection("stripe_subscriptions")
.getOne(subscriptionId);
if (record.user !== userId) {
return error(403, "Forbidden");
}
} catch {
return error(404, "Subscription not found");
}
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
},
};
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Badge } from "$lib/components/ui/badge";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import SetupButton from "$lib/components/payments/setup-button.svelte";
import { CreditCard, Trash2, Check, Package } from "@lucide/svelte";
import { toast } from "svelte-sonner";
import { invalidate, invalidateAll } from "$app/navigation";
import { untrack } from "svelte";
let { data } = $props();
let paymentMethods = $derived(data.paymentMethods);
let availablePlans = $derived(data.availablePlans);
let hasDefaultPaymentMethod = $derived(data.hasDefaultPaymentMethod);
// Local, mutable mirror of the server-side subscription. We optimistically
// update it on user actions so the UI reflects the change immediately; the
// $effect re-syncs from the server after the webhook has had a chance to
// fire (invalidateAll runs on a short delay below).
let activeSubscription = $state(untrack(() => data.activeSubscription));
$effect(() => {
activeSubscription = data.activeSubscription;
});
let showDeleteDialog = $state(false);
let selectedPaymentMethodId = $state<string | null>(null);
let isProcessing = $state(false);
let showCancelDialog = $state(false);
let isSubscribing = $state<string | null>(null);
let isCanceling = $state(false);
function openDeleteDialog(paymentMethodId: string) {
selectedPaymentMethodId = paymentMethodId;
showDeleteDialog = true;
}
function closeDeleteDialog() {
showDeleteDialog = false;
selectedPaymentMethodId = null;
}
async function handleDeletePaymentMethod() {
if (!selectedPaymentMethodId) return;
isProcessing = true;
try {
const formData = new FormData();
formData.append("paymentMethodId", selectedPaymentMethodId);
await fetch("?/deletePaymentMethod", {
method: "POST",
body: formData,
});
await invalidate("app:billing");
toast.success("Payment method removed successfully");
closeDeleteDialog();
} catch (err) {
console.error("Error deleting payment method:", err);
toast.error("Failed to remove payment method");
} finally {
isProcessing = false;
}
}
async function setDefaultPaymentMethod(paymentMethodId: string) {
try {
const formData = new FormData();
formData.append("paymentMethodId", paymentMethodId);
await fetch("?/setDefaultPaymentMethod", {
method: "POST",
body: formData,
});
await invalidate("app:billing");
toast.success("Default payment method updated");
} catch (err) {
console.error("Error setting default payment method:", err);
toast.error("Failed to update default payment method");
}
}
function getCardBrandIcon(brand: string) {
// You can customize these icons or use actual card brand icons
const brands: Record<string, string> = {
visa: "💳",
mastercard: "💳",
amex: "💳",
discover: "💳",
unknown: "💳",
};
return brands[brand.toLowerCase()] || brands.unknown;
}
function formatCardBrand(brand: string) {
return brand.charAt(0).toUpperCase() + brand.slice(1);
}
function formatAmount(amount: number | null, currency: string | null) {
if (amount === null || !currency) return "—";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
}
function formatInterval(
recurring: { interval: string; interval_count: number } | null,
) {
if (!recurring) return "";
if (recurring.interval_count === 1) return `/ ${recurring.interval}`;
return `every ${recurring.interval_count} ${recurring.interval}s`;
}
function formatDate(iso: string | null) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
async function selectPlan(priceId: string, successMessage: string) {
if (!hasDefaultPaymentMethod) {
toast.error("Add a default payment method first");
return;
}
const previous = activeSubscription;
const plan = availablePlans.find((p) => p.id === priceId);
if (plan) {
activeSubscription = previous
? {
...previous,
priceId,
productName: plan.productName,
unitAmount: plan.unitAmount,
currency: plan.currency,
recurring: plan.recurring,
cancelAtPeriodEnd: false,
}
: {
id: "pending",
status: "active",
priceId,
productName: plan.productName,
unitAmount: plan.unitAmount,
currency: plan.currency,
recurring: plan.recurring,
cancelAtPeriodEnd: false,
currentPeriodStart: null,
currentPeriodEnd: null,
canceledAt: null,
};
}
isSubscribing = priceId;
try {
const formData = new FormData();
formData.append("priceId", priceId);
const res = await fetch("?/selectPlan", {
method: "POST",
body: formData,
});
if (!res.ok) {
throw new Error(`selectPlan failed: ${res.status}`);
}
toast.success(successMessage);
setTimeout(() => {
invalidateAll();
}, 2000);
} catch (err) {
console.error("Error selecting plan:", err);
toast.error("Failed to update plan");
activeSubscription = previous;
} finally {
isSubscribing = null;
}
}
async function subscribe(priceId: string) {
await selectPlan(priceId, "Subscription started");
}
async function switchPlan(priceId: string) {
await selectPlan(priceId, "Subscription updated");
}
async function resumeSubscription() {
if (!activeSubscription) return;
const previous = activeSubscription;
isSubscribing = activeSubscription.priceId;
activeSubscription = { ...activeSubscription, cancelAtPeriodEnd: false };
try {
const formData = new FormData();
formData.append("subscriptionId", previous.id);
const res = await fetch("?/resumeSubscription", {
method: "POST",
body: formData,
});
if (!res.ok) {
throw new Error(`Resume failed: ${res.status}`);
}
toast.success("Subscription resumed");
setTimeout(() => {
invalidateAll();
}, 2000);
} catch (err) {
console.error("Error resuming subscription:", err);
toast.error("Failed to resume subscription");
activeSubscription = previous;
} finally {
isSubscribing = null;
}
}
async function handleCancelSubscription() {
if (!activeSubscription) return;
const previous = activeSubscription;
isCanceling = true;
activeSubscription = { ...activeSubscription, cancelAtPeriodEnd: true };
try {
const formData = new FormData();
formData.append("subscriptionId", previous.id);
const res = await fetch("?/cancelSubscription", {
method: "POST",
body: formData,
});
if (!res.ok) {
throw new Error(`Cancel failed: ${res.status}`);
}
toast.success("Subscription will cancel at period end");
showCancelDialog = false;
setTimeout(() => {
invalidateAll();
}, 2000);
} catch (err) {
console.error("Error canceling subscription:", err);
toast.error("Failed to cancel subscription");
activeSubscription = previous;
} finally {
isCanceling = false;
}
}
</script>
<div class="divide-y divide-gray-900/10 dark:divide-white/10">
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Payment Methods
</h2>
<p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
Manage your payment methods for subscriptions and purchases.
</p>
</div>
<div class="md:col-span-2">
<Card.Root>
<Card.Content>
{#if paymentMethods.length === 0}
<div
class="text-muted-foreground flex flex-col items-center justify-center py-12 text-center"
>
<CreditCard class="mb-4 size-12 opacity-50" />
<p class="mb-2 text-lg font-medium">No payment methods</p>
<p class="text-sm">Add a payment method to get started</p>
</div>
{:else}
<div class="space-y-4">
{#each paymentMethods as method (method.id)}
<div
class="border-border hover:bg-accent/50 flex items-center justify-between rounded-lg border p-4 transition-colors"
>
<div class="flex items-center gap-4">
<div
class="bg-muted flex size-12 items-center justify-center rounded-lg text-2xl"
>
{getCardBrandIcon(method.brand)}
</div>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">
{formatCardBrand(method.brand)} •••• {method.last4}
</p>
{#if method.isDefault}
<Badge variant="secondary">
<Check class="mr-1 size-3" />
Default
</Badge>
{/if}
</div>
<p class="text-muted-foreground text-sm">
Expires {method.expMonth
.toString()
.padStart(2, "0")}/{method.expYear}
</p>
</div>
</div>
<div class="flex items-center gap-2">
{#if !method.isDefault}
<Button
variant="outline"
size="sm"
onclick={() => setDefaultPaymentMethod(method.id)}
>
Set as Default
</Button>
{/if}
<Button
variant="outline"
size="icon-sm"
onclick={() => openDeleteDialog(method.id)}
disabled={method.isDefault && paymentMethods.length === 1}
>
<Trash2 class="size-4" />
</Button>
</div>
</div>
{/each}
</div>
{/if}
</Card.Content>
<Card.Footer class="border-t justify-end">
<SetupButton
label="Add Payment Method"
processingLabel="Adding..."
onsuccess={() => invalidate("app:billing")}
/>
</Card.Footer>
</Card.Root>
</div>
</div>
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Plan
</h2>
<p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
Manage your subscription.
</p>
</div>
<div class="md:col-span-2">
<Card.Root>
<Card.Content>
{#if availablePlans.length === 0}
<div
class="text-muted-foreground flex flex-col items-center justify-center py-12 text-center"
>
<Package class="mb-4 size-12 opacity-50" />
<p class="mb-2 text-lg font-medium">No plans available</p>
<p class="text-sm">Check back soon.</p>
</div>
{:else}
{#if !hasDefaultPaymentMethod}
<p
class="text-muted-foreground mb-4 rounded-md border border-dashed p-3 text-sm"
>
Add a default payment method above to subscribe.
</p>
{/if}
<div class="space-y-4">
{#each availablePlans as plan (plan.id)}
{@const isCurrent = activeSubscription?.priceId === plan.id}
<div
class="flex items-center justify-between rounded-lg border p-4 transition-colors {isCurrent
? 'border-primary ring-primary ring-1'
: 'border-border hover:bg-accent/50'}"
>
<div class="flex items-center gap-4">
<div
class="bg-muted flex size-12 items-center justify-center rounded-lg"
>
<Package class="size-5" />
</div>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{plan.productName}</p>
{#if isCurrent && activeSubscription}
{#if activeSubscription.cancelAtPeriodEnd}
<Badge variant="outline">Pending cancellation</Badge
>
{:else if activeSubscription.status === "trialing"}
<Badge variant="secondary">Trial</Badge>
{:else if activeSubscription.status === "past_due"}
<Badge variant="destructive">Past due</Badge>
{:else}
<Badge variant="secondary">
<Check class="mr-1 size-3" />
Current
</Badge>
{/if}
{/if}
</div>
<p class="text-muted-foreground text-sm">
{formatAmount(plan.unitAmount, plan.currency)}
{formatInterval(plan.recurring)}
{#if isCurrent && activeSubscription?.currentPeriodEnd}
<span>
•
{activeSubscription.cancelAtPeriodEnd
? "Cancels on"
: "Renews on"}
{formatDate(activeSubscription.currentPeriodEnd)}
</span>
{/if}
</p>
</div>
</div>
<div class="flex items-center gap-2">
{#if isCurrent && activeSubscription}
{#if activeSubscription.cancelAtPeriodEnd}
<Button
variant="outline"
size="sm"
disabled={isSubscribing === plan.id}
onclick={resumeSubscription}
>
{isSubscribing === plan.id ? "Resuming..." : "Resume"}
</Button>
{:else}
<Button
variant="outline"
size="sm"
onclick={() => (showCancelDialog = true)}
>
Cancel subscription
</Button>
{/if}
{:else if activeSubscription}
<Button
variant="outline"
size="sm"
disabled={!hasDefaultPaymentMethod ||
isSubscribing === plan.id}
onclick={() => switchPlan(plan.id)}
>
{isSubscribing === plan.id ? "Switching..." : "Switch"}
</Button>
{:else}
<Button
variant="outline"
size="sm"
disabled={!hasDefaultPaymentMethod ||
isSubscribing === plan.id}
onclick={() => subscribe(plan.id)}
>
{isSubscribing === plan.id
? "Subscribing..."
: "Subscribe"}
</Button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
</div>
</div>
<!-- Cancel Subscription Dialog -->
<AlertDialog.Root bind:open={showCancelDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Cancel Subscription</AlertDialog.Title>
<AlertDialog.Description>
Your subscription will remain active until the end of the current
billing period, then cancel automatically.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (showCancelDialog = false)}>
Keep Subscription
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleCancelSubscription}
disabled={isCanceling}
>
{isCanceling ? "Canceling..." : "Cancel at Period End"}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={showDeleteDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Remove Payment Method</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to remove this payment method? This action cannot
be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={closeDeleteDialog}>Cancel</AlertDialog.Cancel
>
<AlertDialog.Action
onclick={handleDeletePaymentMethod}
disabled={isProcessing}
>
{isProcessing ? "Removing..." : "Remove"}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
import Stripe from "stripe";
import { json } from "@sveltejs/kit";
import { STRIPE_WEBHOOK_SECRET } from "$env/static/private";
import stripe from "$lib/stripe";
import { handlePaymentIntentSucceeded } from "./handlers/payment-intent/succeeded";
import { handlePaymentMethodAttached } from "./handlers/payment-method/attached";
import { handleSubscriptionCreated } from "./handlers/subscription/created";
import { handleSubscriptionUpdated } from "./handlers/subscription/updated";
import { handleSubscriptionDeleted } from "./handlers/subscription/deleted";
const processedEvents = new Set<string>();
async function verifyWebhookSignature(
request: Request,
webhookSecret: string,
): Promise<Stripe.Event> {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
throw new Error("Missing stripe-signature header");
}
if (!webhookSecret) {
throw new Error("Missing webhook secret");
}
try {
return stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
throw new Error("Invalid signature");
}
}
export const POST = async ({ request, locals }) => {
try {
const event = await verifyWebhookSignature(request, STRIPE_WEBHOOK_SECRET);
if (processedEvents.has(event.id)) {
console.log(`Event ${event.id} already processed, skipping`);
return json({ received: true, message: "Event already processed" });
}
processedEvents.add(event.id);
console.log(`Processing webhook event: ${event.type} (${event.id})`);
switch (event.type) {
case "payment_intent.succeeded":
await handlePaymentIntentSucceeded(event.data.object, locals);
break;
case "payment_method.attached":
await handlePaymentMethodAttached(event.data.object, locals);
break;
case "customer.subscription.created":
await handleSubscriptionCreated(event.data.object, locals);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object, locals);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object, locals);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return json({
received: true,
eventId: event.id,
eventType: event.type,
});
} catch (error) {
console.error("Webhook processing error:", error);
return json(
{
error: "Webhook processing failed",
message: error instanceof Error ? error.message : "Unknown error",
},
{ status: 400 },
);
}
};
stripe_subscriptions