Add Stripe payments 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. When auth is also enabled, customers can save payment methods to their account for faster future purchases.
/// <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");
});
/// <reference path="../types.d.ts" />
module.exports = {
triggerJob: (jobUrl) => {
$http.send({
method: "POST",
url: `${$app.settings().meta.appURL}${jobUrl}`,
headers: {
Authorization: `Bearer ${$os.getenv("INTERNAL_JOB_SECRET")}`,
},
});
},
};
<script lang="ts">
import type { Stripe } from "@stripe/stripe-js";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { toast } from "svelte-sonner";
import { page } from "$app/state";
import { invalidate } from "$app/navigation";
interface Props {
stripe: Stripe | null;
open: boolean;
amount: number;
currency: string;
brand?: string;
last4?: string;
clientSecret?: string;
onopenchange: (open: boolean) => void;
onsuccess: () => void;
}
let {
stripe,
open = $bindable(),
amount,
currency,
brand,
last4,
clientSecret,
onopenchange,
onsuccess,
}: Props = $props();
const brandNames = {
amex: "American Express",
cartes_bancaires: "Cartes Bancaires",
diners: "Diners",
discover: "Discover",
eftpos_au: "Eftpos Au",
jcb: "Jcb",
link: "Link",
mastercard: "Mastercard",
unionpay: "Unionpay",
visa: "Visa",
unknown: "Unknown",
};
let isProcessing = $state(false);
let amountFormatted = $derived.by(() => {
let formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
});
return formatter.format(amount / 100);
});
const handleOpenChangeComplete = (isOpen: boolean) => {
if (!isOpen) {
resetDialog();
}
onopenchange(isOpen);
};
function resetDialog() {
open = false;
isProcessing = false;
}
async function handlePaymentMethod() {
if (!stripe || !clientSecret) return;
isProcessing = true;
try {
const { error: confirmError } = await stripe.confirmPayment({
clientSecret,
confirmParams: {
return_url: page.url.toString(),
},
redirect: "if_required",
});
if (confirmError) {
toast.error(confirmError.message || "Failed to pay for your purchase");
return;
}
await invalidate("app:billing");
toast.success("Payment successful");
onsuccess();
resetDialog();
} catch (err) {
console.error("Error paying for your purchase:", err);
toast.error("Failed to pay for your purchase");
} finally {
isProcessing = false;
}
}
</script>
<Dialog.Root bind:open onOpenChangeComplete={handleOpenChangeComplete}>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>Confirm payment</Dialog.Title>
<Dialog.Description>
Are you sure you want to pay {amountFormatted} with your
{brandNames[brand as keyof typeof brandNames]}
credit card ending in **** {last4}?
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={resetDialog} disabled={isProcessing}
>Cancel</Button
>
<Button onclick={handlePaymentMethod} disabled={isProcessing}>
{isProcessing ? "Processing..." : "Pay Now"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import PaymentDialog from "$lib/components/payments/payment-dialog.svelte";
import ConfirmDialog from "$lib/components/payments/confirm-dialog.svelte";
import { useStripe } from "$lib/components/payments/use-stripe.svelte";
import { usePaymentIntent } from "$lib/components/payments/use-payment-intent.svelte";
import type { ButtonProps } from "$lib/components/ui/button";
interface Props {
priceId: string;
user?: { email: string; name: string } | null;
disabled?: boolean;
isProcessing?: boolean;
label?: string;
processingLabel?: string;
onsuccess?: () => void;
}
let {
priceId,
user,
disabled = false,
isProcessing = false,
label = "Purchase",
processingLabel = "Processing...",
onsuccess,
...rest
}: Props & ButtonProps = $props();
const stripeManager = useStripe();
const paymentIntentManager = usePaymentIntent();
let mode = $state<"payment" | "confirm">("payment");
let showDialog = $state(false);
let amount = $state<number>(0);
let currency = $state<string>("usd");
let clientSecret = $state<string>();
let brand = $state<string>();
let last4 = $state<string>();
async function handleBuyClick() {
const result = await paymentIntentManager.fetchPaymentIntent({
priceId,
});
if (!result) return;
if (result.type === "confirm") {
mode = "confirm";
brand = result.brand;
last4 = result.last4;
} else if (result.type === "payment") {
mode = "payment";
}
amount = result.amount || 0;
currency = result.currency || "usd";
clientSecret = result.clientSecret;
showDialog = true;
}
function handleDialogOpenChange(open: boolean) {
showDialog = open;
}
function handlePaymentSuccess() {
onsuccess?.();
}
</script>
<Button disabled={disabled || isProcessing} onclick={handleBuyClick} {...rest}>
{#if isProcessing}
{processingLabel}
{:else}
{label}
{/if}
</Button>
{#if mode === "confirm"}
<ConfirmDialog
bind:open={showDialog}
stripe={stripeManager.stripe}
{amount}
{currency}
{brand}
{last4}
{clientSecret}
onopenchange={handleDialogOpenChange}
onsuccess={handlePaymentSuccess}
/>
{:else if mode === "payment"}
<PaymentDialog
bind:open={showDialog}
stripe={stripeManager.stripe}
{amount}
{currency}
{clientSecret}
{user}
onopenchange={handleDialogOpenChange}
onsuccess={handlePaymentSuccess}
/>
{/if}
<script lang="ts">
import type { Stripe, StripeElements } from "@stripe/stripe-js";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { toast } from "svelte-sonner";
import { page } from "$app/state";
import { invalidate } from "$app/navigation";
import StripePaymentForm from "./stripe-payment-form.svelte";
interface Props {
stripe: Stripe | null;
open: boolean;
amount: number;
currency: string;
clientSecret?: string;
user?: { email: string; name: string } | null;
onopenchange: (open: boolean) => void;
onsuccess: () => void;
}
let {
stripe,
open = $bindable(),
amount,
currency,
clientSecret,
user = null,
onopenchange,
onsuccess,
}: Props = $props();
let isProcessing = $state(false);
let cardComplete = $state(false);
let savePaymentMethod = $state(false);
let email = $state<string>();
let formRef = $state<{
getElements: () => StripeElements | null;
}>();
let amountFormatted = $derived.by(() => {
let formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
});
return formatter.format(amount / 100);
});
const handleOpenChangeComplete = (isOpen: boolean) => {
if (!isOpen) {
resetDialog();
}
onopenchange(isOpen);
};
function resetDialog() {
open = false;
isProcessing = false;
cardComplete = false;
}
function handleCardChange(complete: boolean) {
cardComplete = complete;
}
async function handlePaymentMethod() {
if (!stripe || !formRef || !cardComplete || !clientSecret) return;
const elements = formRef.getElements();
if (!elements) return;
isProcessing = true;
try {
const { error: submitError } = await elements.submit();
if (submitError) {
toast.error(submitError.message || "Failed to pay for your purchase");
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: page.url.toString(),
payment_method_data: {
billing_details: {
email: email || user?.email,
},
allow_redisplay: savePaymentMethod ? "always" : "unspecified",
},
},
redirect: "if_required",
});
if (confirmError) {
toast.error(confirmError.message || "Failed to pay for your purchase");
return;
}
await invalidate("app:billing");
toast.success("Payment successful");
onsuccess();
resetDialog();
} catch (err) {
console.error("Error paying for your purchase:", err);
toast.error("Failed to pay for your purchase");
} finally {
isProcessing = false;
}
}
</script>
<Dialog.Root bind:open onOpenChangeComplete={handleOpenChangeComplete}>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title>{amountFormatted}</Dialog.Title>
<Dialog.Description
>Enter your card details to pay for your purchase.</Dialog.Description
>
</Dialog.Header>
{#if !user}
<div class="space-y-2">
<Label for="email">Email</Label>
<Input type="email" bind:value={email} />
</div>
{/if}
<div>
{#if stripe && open}
<StripePaymentForm
bind:this={formRef}
mode="payment"
{stripe}
{amount}
{currency}
{savePaymentMethod}
{user}
onchange={handleCardChange}
/>
{/if}
</div>
{#if user}
<div class="flex items-center gap-2">
<input
type="checkbox"
id="savePaymentMethod"
bind:checked={savePaymentMethod}
/>
<label for="savePaymentMethod" class="text-sm text-muted-foreground">
Save payment method for future purchases
</label>
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={resetDialog} disabled={isProcessing}
>Cancel</Button
>
<Button
onclick={handlePaymentMethod}
disabled={!cardComplete || isProcessing}
>
{isProcessing ? "Processing..." : "Pay Now"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
export const TERMS_AUTO = {
applePay: "auto" as const,
auBecsDebit: "auto" as const,
bancontact: "auto" as const,
card: "auto" as const,
cashapp: "auto" as const,
googlePay: "auto" as const,
ideal: "auto" as const,
paypal: "auto" as const,
sepaDebit: "auto" as const,
sofort: "auto" as const,
usBankAccount: "auto" as const,
};
export const TERMS_NEVER = {
applePay: "never" as const,
auBecsDebit: "never" as const,
bancontact: "never" as const,
card: "never" as const,
cashapp: "never" as const,
googlePay: "never" as const,
ideal: "never" as const,
paypal: "never" as const,
sepaDebit: "never" as const,
sofort: "never" as const,
usBankAccount: "never" as const,
};
<script lang="ts">
import type {
Stripe,
StripeElements,
StripePaymentElement,
StripeElementsOptionsMode,
} from "@stripe/stripe-js";
import { mode as modeWatcher } from "mode-watcher";
import { TERMS_AUTO, TERMS_NEVER } from "./stripe-constants";
import { onMount } from "svelte";
interface Props {
stripe: Stripe;
mode: "payment" | "setup";
amount?: number;
currency?: string;
savePaymentMethod?: boolean;
user?: { email: string; name: string } | null;
onchange: (complete: boolean) => void;
}
let {
stripe,
mode,
amount = 0,
currency = "usd",
savePaymentMethod = false,
user,
onchange,
}: Props = $props();
let cardContainer = $state<HTMLDivElement | null>(null);
let elements = $state<StripeElements | null>(null);
let paymentElement = $state<StripePaymentElement | null>(null);
function initializeElements() {
if (!stripe) return;
// Clean up previous elements
if (paymentElement) {
paymentElement.destroy();
paymentElement = null;
}
const appearance = {
theme:
modeWatcher.current === "dark"
? ("night" as const)
: ("stripe" as const),
inputs: "condensed" as const,
labels: "floating" as const,
};
elements = stripe.elements({
mode,
appearance,
currency,
...(mode === "payment" && {
amount: amount,
}),
...(user || mode === "setup"
? {
setupFutureUsage: "off_session" as const,
}
: {}),
} as StripeElementsOptionsMode);
paymentElement = elements.create("payment", {
layout: "tabs",
defaultValues: {
billingDetails: {
email: user?.email,
name: user?.name,
},
},
terms: savePaymentMethod ? TERMS_AUTO : TERMS_NEVER,
});
if (cardContainer && paymentElement) {
paymentElement.mount(cardContainer);
paymentElement.on("change", (event: any) => {
onchange(event.complete);
});
}
}
// Initialize elements when component mounts
onMount(() => {
initializeElements();
return () => {
if (paymentElement) {
paymentElement.destroy();
}
};
});
// Update terms when savePaymentMethod changes
$effect(() => {
if (paymentElement) {
paymentElement.update({
terms: savePaymentMethod ? TERMS_AUTO : TERMS_NEVER,
});
}
});
export function getElements() {
return elements;
}
</script>
<div bind:this={cardContainer}></div>
import { toast } from "svelte-sonner";
export interface PaymentIntentResult {
type: "payment" | "confirm";
amount?: number;
currency?: string;
clientSecret?: string;
brand?: string;
last4?: string;
}
export function usePaymentIntent() {
let isLoading = $state(false);
async function fetchPaymentIntent({
priceId,
}: {
priceId: string;
}): Promise<PaymentIntentResult | null> {
isLoading = true;
const res = await fetch("/api/stripe/payment-intent", {
method: "POST",
body: JSON.stringify({
priceId: priceId,
}),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
toast.error("Failed to initialize payment");
return null;
}
return res.json();
}
async function fetchSetupIntent(): Promise<PaymentIntentResult | null> {
isLoading = true;
const res = await fetch("/api/stripe/setup-intent", {
method: "POST",
});
if (!res.ok) {
toast.error("Failed to initialize setup");
return null;
}
return res.json();
}
return {
get isLoading() {
return isLoading;
},
fetchPaymentIntent,
fetchSetupIntent,
};
}
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { toast } from "svelte-sonner";
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from "$env/static/public";
export function useStripe() {
let stripe = $state<Stripe | null>(null);
let isLoading = $state(true);
let error = $state<string | null>(null);
async function initialize() {
try {
stripe = await loadStripe(PUBLIC_STRIPE_PUBLISHABLE_KEY);
isLoading = false;
} catch (err) {
console.error("Failed to load Stripe:", err);
error = "Failed to initialize payment system";
toast.error("Failed to initialize payment system");
isLoading = false;
}
}
// Auto-initialize
initialize();
return {
get stripe() {
return stripe;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
};
}
import { STRIPE_SECRET_KEY } from "$env/static/private";
import Stripe from "stripe";
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2025-09-30.clover",
});
export default stripe;
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import type { Stripe } from "stripe";
const createPaymentIntent = async (price: Stripe.Price) => {
if (price.unit_amount === null || price.unit_amount === undefined) {
console.error("Price does not have a per unit amount");
throw new Error("Price does not have a per unit amount");
}
const intent = await stripe.paymentIntents.create({
amount: price.unit_amount,
currency: price.currency,
automatic_payment_methods: {
enabled: true,
allow_redirects: "never",
},
});
if (!intent.client_secret) {
console.error("Payment intent created without client secret");
throw new Error("Failed to create payment intent");
}
return {
type: "payment",
clientSecret: intent.client_secret,
amount: price.unit_amount,
currency: price.currency,
};
};
export const POST = async ({ request, locals }) => {
let body;
try {
body = await request.json();
} catch (error) {
console.error("Invalid JSON in request body");
return json({ error: "Invalid request body" }, { status: 400 });
}
const { priceId } = body as { priceId?: string };
// Validate required fields
if (!priceId || typeof priceId !== "string" || priceId === "") {
console.error("Missing or invalid priceId");
return json({ error: "Price ID is required" }, { status: 400 });
}
// Retrieve price from Stripe
let price;
try {
price = await stripe.prices.retrieve(priceId);
} catch (error) {
console.error("Failed to retrieve price from Stripe:", priceId);
return json(
{ error: "Invalid price ID or price not found" },
{ status: 404 },
);
}
// Validate price is active
if (!price.active) {
console.error("Price is not active:", priceId);
return json(
{ error: "This price is no longer available" },
{ status: 400 },
);
}
try {
const res = await createPaymentIntent(price);
return json(res, { status: 200 });
} catch (error) {
console.error("Error creating payment intent for price:", priceId);
const errorMessage =
error instanceof Error
? error.message
: "Failed to create payment intent";
return json({ error: errorMessage }, { status: 500 });
}
};
import { describe, it, expect, beforeAll } from "vitest";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";
import stripe from "$lib/stripe";
import type Stripe from "stripe";
describe("POST /api/stripe/payment-intent", () => {
let product: Stripe.Product;
let activePrice: Stripe.Price;
let inactivePrice: Stripe.Price;
beforeAll(async () => {
const productName = "Test Product - Payment Intent";
const products = await stripe.products.search({
query: `name:"${productName}" AND metadata["test"]:"true"`,
limit: 1,
});
if (products.data.length > 0) {
product = products.data[0];
const prices = await stripe.prices.list({
product: product.id,
active: true,
limit: 1,
});
if (prices.data.length > 0) {
activePrice = prices.data[0];
}
const inactivePrices = await stripe.prices.list({
product: product.id,
active: false,
limit: 1,
});
if (inactivePrices.data.length > 0) {
inactivePrice = inactivePrices.data[0];
}
}
// Create if not found or no active price
if (!product) {
product = await stripe.products.create({
name: productName,
metadata: {
test: "true",
test_type: "payment-intent",
},
});
}
if (!activePrice) {
activePrice = await stripe.prices.create({
product: product.id,
unit_amount: 1000,
currency: "usd",
metadata: {
test: "true",
test_type: "payment-intent",
},
});
}
if (!inactivePrice) {
inactivePrice = await stripe.prices.create({
product: product.id,
unit_amount: 2000,
currency: "usd",
active: false,
metadata: {
test: "true",
test_type: "payment-intent-inactive",
},
});
}
});
describe("Request validation", () => {
it("should return 400 if request body is not valid JSON", async (context) => {
const response = await context.agent
.post("/api/stripe/payment-intent" satisfies Match<RouteId>)
.set("Content-Type", "application/json")
.send("invalid json");
expect(response.status).toBe(400);
expect(response.body.error).toBe("Invalid request body");
});
it("should return 400 if priceId is missing", async (context) => {
const response = await context.agent
.post("/api/stripe/payment-intent" satisfies Match<RouteId>)
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe("Price ID is required");
});
it("should return 400 if priceId is not a string", async (context) => {
const response = await context.agent
.post("/api/stripe/payment-intent" satisfies Match<RouteId>)
.send({ priceId: 12345 });
expect(response.status).toBe(400);
expect(response.body.error).toBe("Price ID is required");
});
it("should return 400 if priceId is an empty string", async (context) => {
const response = await context.agent
.post("/api/stripe/payment-intent" satisfies Match<RouteId>)
.send({ priceId: "" });
expect(response.status).toBe(400);
expect(response.body.error).toBe("Price ID is required");
});
it("should return 404 if price does not exist", async (context) => {
const response = await context.agent
.post("/api/stripe/payment-intent" satisfies Match<RouteId>)
.send({ priceId: "price_nonexistent123" });
expect(response.status).toBe(404);
expect(response.body.error).toBe("Invalid price ID or price not found");
});
it("should return 400 if price is not active", async (context) => {
const response = await context.agent
.post("/api/stripe/payment-intent" satisfies Match<RouteId>)
.send({ priceId: inactivePrice.id });
expect(response.status).toBe(400);
expect(response.body.error).toBe("This price is no longer available");
});
});
describe("Guest user (unauthenticated)", () => {
it("should create payment intent for guest user", async (context) => {
const response = await context.request
.post("/api/stripe/payment-intent" satisfies Match<RouteId>)
.send({ priceId: activePrice.id });
expect(response.status).toBe(200);
expect(response.body.type).toBe("payment");
expect(response.body.clientSecret).toBeDefined();
expect(response.body.amount).toBe(1000);
expect(response.body.currency).toBe("usd");
expect(response.body.brand).toBeUndefined();
expect(response.body.last4).toBeUndefined();
// Verify the payment intent was created in Stripe
const intentId = response.body.clientSecret.split("_secret_")[0];
const intent = await stripe.paymentIntents.retrieve(intentId);
expect(intent.amount).toBe(1000);
expect(intent.currency).toBe("usd");
expect(intent.customer).toBeNull();
// Clean up
await stripe.paymentIntents.cancel(intent.id);
});
});
});
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import { INTERNAL_JOB_SECRET } from "$env/static/private";
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 prices = await stripe.prices.list();
for (const price of prices.data) {
try {
await locals.admin
.collection("stripe_prices")
.getFirstListItem(`id = "${price.id}"`);
continue;
} catch {}
await locals.admin.collection("stripe_prices").create({
id: price.id,
billing_scheme: price.billing_scheme,
currency: price.currency,
product: price.product,
recurring: price.recurring,
type: price.type,
unit_amount: price.unit_amount,
active: price.active,
});
}
console.log("Prices synced");
return json({ message: "Prices synced" });
};
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import { INTERNAL_JOB_SECRET } from "$env/static/private";
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 products = await stripe.products.list();
for (const product of products.data) {
try {
await locals.admin
.collection("stripe_products")
.getFirstListItem(`id = "${product.id}"`);
continue;
} catch {}
await locals.admin.collection("stripe_products").create({
id: product.id,
name: product.name,
active: product.active,
default_price: product.default_price,
});
}
console.log("Products synced");
return json({ message: "Products synced" });
};
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";
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;
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 },
);
}
};
import { describe, it, expect } from "vitest";
import stripe from "$lib/stripe";
import { handlePaymentIntentSucceeded } from "./succeeded";
describe("payment_intent.succeeded", () => {
it("handles a payment intent successfully", async (context) => {
const paymentIntent = await stripe.paymentIntents.create({
amount: 2000,
currency: "usd",
});
const locals = { admin: context.admin } as App.Locals;
const result = await handlePaymentIntentSucceeded(paymentIntent, locals);
expect(result).toEqual({});
});
});
import Stripe from "stripe";
/**
* Handles payment_intent.succeeded event
*/
export async function handlePaymentIntentSucceeded(
paymentIntent: Stripe.PaymentIntent,
locals: App.Locals,
) {
return {};
}
import { describe, it, expect } from "vitest";
import type { RouteId } from "./$types";
import type { Match } from "@velastack/pocketbase";
import type Stripe from "stripe";
import stripe from "$lib/stripe";
describe("POST /webhooks/stripe", () => {
it("accepts valid webhook signature", async (context) => {
const event = {
id: `evt_test_${Date.now()}`,
object: "event",
api_version: "2024-11-20.acacia",
created: Math.floor(Date.now() / 1000),
type: "test.event",
data: {
object: {},
},
livemode: false,
pending_webhooks: 0,
request: {
id: null,
idempotency_key: null,
},
} as unknown as Stripe.Event;
const payload = JSON.stringify(event);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const signature = stripe.webhooks.generateTestHeaderString({
payload,
secret: webhookSecret,
});
const response = await context.request
.post("/webhooks/stripe" satisfies Match<RouteId>)
.set("stripe-signature", signature)
.send(payload)
.set("Content-Type", "application/json");
expect(response.status).toBe(200);
expect(response.body.received).toBe(true);
expect(response.body.eventId).toBe(event.id);
expect(response.body.eventType).toBe("test.event");
});
it("rejects invalid webhook signature", async (context) => {
const event = {
id: `evt_test_${Date.now()}`,
object: "event",
api_version: "2024-11-20.acacia",
created: Math.floor(Date.now() / 1000),
type: "test.event",
data: {
object: {},
},
livemode: false,
pending_webhooks: 0,
request: {
id: null,
idempotency_key: null,
},
} as unknown as Stripe.Event;
const payload = JSON.stringify(event);
const response = await context.request
.post("/webhooks/stripe" satisfies Match<RouteId>)
.set("stripe-signature", "invalid_signature")
.send(payload)
.set("Content-Type", "application/json");
expect(response.status).toBe(400);
expect(response.body.error).toBe("Webhook processing failed");
});
it("rejects request without signature header", async (context) => {
const event = {
id: `evt_test_${Date.now()}`,
object: "event",
api_version: "2024-11-20.acacia",
created: Math.floor(Date.now() / 1000),
type: "test.event",
data: {
object: {},
},
livemode: false,
pending_webhooks: 0,
request: {
id: null,
idempotency_key: null,
},
} as unknown as Stripe.Event;
const payload = JSON.stringify(event);
const response = await context.request
.post("/webhooks/stripe" satisfies Match<RouteId>)
.send(payload)
.set("Content-Type", "application/json");
expect(response.status).toBe(400);
expect(response.body.error).toBe("Webhook processing failed");
expect(response.body.message).toBe("Missing stripe-signature header");
});
it("handles duplicate webhook events", async (context) => {
const event = {
id: `evt_test_${Date.now()}`,
object: "event",
api_version: "2024-11-20.acacia",
created: Math.floor(Date.now() / 1000),
type: "test.event",
data: {
object: {},
},
livemode: false,
pending_webhooks: 0,
request: {
id: null,
idempotency_key: null,
},
} as unknown as Stripe.Event;
const payload = JSON.stringify(event);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const signature = stripe.webhooks.generateTestHeaderString({
payload,
secret: webhookSecret,
});
// Send first webhook
const response1 = await context.request
.post("/webhooks/stripe" satisfies Match<RouteId>)
.set("stripe-signature", signature)
.send(payload)
.set("Content-Type", "application/json");
expect(response1.status).toBe(200);
expect(response1.body.received).toBe(true);
// Send duplicate webhook
const response2 = await context.request
.post("/webhooks/stripe" satisfies Match<RouteId>)
.set("stripe-signature", signature)
.send(payload)
.set("Content-Type", "application/json");
expect(response2.status).toBe(200);
expect(response2.body.received).toBe(true);
expect(response2.body.message).toBe("Event already processed");
});
});
<script lang="ts">
import PaymentButton from "$lib/components/payments/payment-button.svelte";
</script>
<section data-role="content">
<PaymentButton priceId="price_preview_placeholder" />
</section>
<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,
}: {
user: {
id: string;
name: string;
email: string;
avatar: string;
};
} = $props();
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
>
</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
>
</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>
import { redirect } from "@sveltejs/kit";
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setFlash } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase";
import { dev } from "$app/environment";
import { signupSchema } from "$lib/schemas/signup";
import stripe from "$lib/stripe";
export const load = async ({ locals }) => {
const authMethods = await locals.admin.collection("users").listAuthMethods();
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
return { form: await superValidate(zod4(signupSchema)), authMethods };
};
export const actions = {
default: async ({ locals, request, cookies, url }) => {
const form = await superValidate(request, zod4(signupSchema));
if (!form.valid) {
return fail(400, { form });
}
let user;
try {
user = await locals.admin.collection("users").create({
email: form.data.email,
password: form.data.password,
passwordConfirm: form.data.passwordConfirm,
});
} catch (error) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
await linkStripeCustomer(form.data.email, user, locals);
await locals.pb.collection("users").requestVerification(user.email);
await locals.pb
.collection("users")
.authWithPassword(form.data.email, form.data.password);
const redirectUrl = url.searchParams.get("redirect") ?? "/dashboard";
const cookie = locals.pb.authStore.getCookie();
cookies.set("pb_auth", cookie, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !dev,
maxAge: 60 * 60 * 24 * 30,
});
setFlash(
{ type: "toast", message: "We sent a confirmation link to your email." },
cookies,
);
return redirect(303, redirectUrl);
},
};
const linkStripeCustomer = async (
email: string,
user: { id: string },
locals: App.Locals,
) => {
const matchingCustomer = await stripe.customers.list({
email: email,
limit: 1,
expand: ["data.invoice_settings"],
});
let customer;
let isExistingCustomer = false;
if (matchingCustomer.data.length > 0) {
isExistingCustomer = true;
customer = matchingCustomer.data[0];
} else {
customer = await stripe.customers.create({
email: email,
});
}
await locals.admin.collection("stripe_customers").create({
id: customer.id,
user: user.id,
});
if (!isExistingCustomer) {
return;
}
const paymentMethods = await stripe.customers.listPaymentMethods(
customer.id,
{
type: "card",
allow_redisplay: "always",
},
);
for (const paymentMethod of paymentMethods.data) {
if (paymentMethod.allow_redisplay === "unspecified") {
continue;
}
await locals.admin.collection("stripe_payment_methods").create({
id: paymentMethod.id,
customer: customer.id,
brand: paymentMethod.card?.brand,
last4: paymentMethod.card?.last4,
exp_month: paymentMethod.card?.exp_month,
exp_year: paymentMethod.card?.exp_year,
});
}
const defaultPaymentMethod =
customer.invoice_settings?.default_payment_method;
if (defaultPaymentMethod) {
try {
await locals.admin.collection("stripe_customers").update(customer.id, {
default_payment_method: defaultPaymentMethod,
});
} catch {}
}
};
stripe_productsstripe_prices