Logo VelaStack

Enable payments

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.

$ vela enable payments
data/hooks/stripe.pb.js
/// <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");
});
data/hooks/utils.js
/// <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")}`,
      },
    });
  },
};
src/lib/components/payments/confirm-dialog.svelte
<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>
src/lib/components/payments/payment-button.svelte
<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}
src/lib/components/payments/payment-dialog.svelte
<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>
src/lib/components/payments/setup-button.svelte
<script lang="ts">
  import { Button } from "$lib/components/ui/button";
  import SetupDialog from "$lib/components/payments/setup-dialog.svelte";
  import { useStripe } from "$lib/components/payments/use-stripe.svelte";
  import { usePaymentIntent } from "$lib/components/payments/use-payment-intent.svelte";

  interface Props {
    disabled?: boolean;
    isProcessing?: boolean;
    label?: string;
    processingLabel?: string;
    onsuccess?: () => void;
  }

  let {
    disabled = false,
    isProcessing = false,
    label,
    processingLabel,
    onsuccess,
  }: Props = $props();

  const stripeManager = useStripe();
  const paymentIntentManager = usePaymentIntent();

  let showDialog = $state(false);
  let clientSecret = $state<string>();

  async function handleSetupClick() {
    const result = await paymentIntentManager.fetchSetupIntent();

    if (!result) return;

    clientSecret = result.clientSecret;
    showDialog = true;
  }

  function handleDialogOpenChange(open: boolean) {
    showDialog = open;
  }

  function handlePaymentSuccess() {
    onsuccess?.();
  }
</script>

<Button disabled={disabled || isProcessing} onclick={handleSetupClick}>
  {#if isProcessing}
    {processingLabel}
  {:else}
    {label}
  {/if}
</Button>

<SetupDialog
  bind:open={showDialog}
  stripe={stripeManager.stripe}
  {clientSecret}
  onopenchange={handleDialogOpenChange}
  onsuccess={handlePaymentSuccess}
/>
src/lib/components/payments/setup-dialog.svelte
<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 { 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;
    clientSecret?: string;
    onopenchange: (open: boolean) => void;
    onsuccess: () => void;
  }

  let {
    stripe,
    open = $bindable(),
    clientSecret,
    onopenchange,
    onsuccess,
  }: Props = $props();

  let isProcessing = $state(false);
  let cardComplete = $state(false);
  let formRef = $state<{
    getElements: () => StripeElements | null;
  }>();

  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 handleSetupPaymentMethod() {
    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 add payment method");
        return;
      }

      const { error: confirmError } = await stripe.confirmSetup({
        elements,
        clientSecret,
        confirmParams: {
          return_url: page.url.toString(),
          payment_method_data: {
            allow_redisplay: "always",
          },
        },
        redirect: "if_required",
      });

      if (confirmError) {
        toast.error(confirmError.message || "Failed to add payment method");
        return;
      }

      await invalidate("app:billing");
      toast.success("Payment method added successfully");
      onsuccess();
      resetDialog();
    } catch (err) {
      console.error("Error adding payment method:", err);
      toast.error("Failed to add payment method");
    } finally {
      isProcessing = false;
    }
  }
</script>

<Dialog.Root bind:open onOpenChangeComplete={handleOpenChangeComplete}>
  <Dialog.Content class="sm:max-w-[500px]">
    <Dialog.Header>
      <Dialog.Title>Add payment method</Dialog.Title>
      <Dialog.Description
        >Enter your card details to add a new payment method.</Dialog.Description
      >
    </Dialog.Header>
    <div>
      {#if stripe && open}
        <StripePaymentForm
          bind:this={formRef}
          mode="setup"
          {stripe}
          onchange={handleCardChange}
          savePaymentMethod={true}
        />
      {/if}
    </div>
    <Dialog.Footer>
      <Button variant="outline" onclick={resetDialog} disabled={isProcessing}
        >Cancel</Button
      >
      <Button
        onclick={handleSetupPaymentMethod}
        disabled={!cardComplete || isProcessing}
      >
        {isProcessing ? "Processing..." : "Add Card"}
      </Button>
    </Dialog.Footer>
  </Dialog.Content>
</Dialog.Root>
src/lib/components/payments/stripe-constants.ts
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,
};
src/lib/components/payments/stripe-payment-form.svelte
<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>
src/lib/components/payments/use-payment-intent.svelte.ts
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,
  };
}
src/lib/components/payments/use-stripe.svelte.ts
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { toast } from "svelte-sonner";
import { env } from "$env/dynamic/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(env.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;
    },
  };
}
src/lib/stripe.ts
import { env } from "$env/dynamic/private";
import Stripe from "stripe";

const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
  apiVersion: "2026-04-22.dahlia",
});

export default stripe;
src/routes/(app)/billing/+layout.server.ts
export const load = async ({ parent }) => {
  const { breadcrumbs } = await parent();

  return {
    breadcrumbs: [...breadcrumbs, { title: "Billing", url: "/billing" }],
  };
};
src/routes/(app)/billing/+page.server.ts
import { error } from "@sveltejs/kit";
import stripe from "$lib/stripe";

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",
    },
  );

  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,
  };
};

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,
        });
      }
    }
  },
};
src/routes/(app)/billing/+page.svelte
<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 } from "@lucide/svelte";
  import { toast } from "svelte-sonner";
  import { invalidate } from "$app/navigation";

  let { data } = $props();
  let paymentMethods = $derived(data.paymentMethods);
  let showDeleteDialog = $state(false);
  let selectedPaymentMethodId = $state<string | null>(null);
  let isProcessing = $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);
  }
</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>

<!-- 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>
src/routes/(app)/billing/README.md
# Billing Page - Stripe Payment Methods

This billing page provides a modern UI for managing payment methods with Stripe.

## Features

**Display Payment Methods** - Shows all saved cards with brand, last 4 digits, and expiration
**Add Payment Methods** - Secure card input using Stripe Elements
**Remove Payment Methods** - Delete cards with confirmation dialog
**Default Payment Method** - Set and display the default payment method
**Responsive Design** - Mobile-friendly layout
**Error Handling** - Toast notifications for user feedback

## Current Status

The frontend is **fully functional** with mock data. To connect to your backend, follow the integration steps below.

## Backend Integration

### 1. Update Stripe Publishable Key

In `+page.svelte`, replace the placeholder with your actual Stripe publishable key:

```typescript
const STRIPE_PUBLISHABLE_KEY = "pk_test_YOUR_KEY_HERE";
```

Or better yet, load it from environment variables:

```typescript
import { env } from "$env/dynamic/public";
const STRIPE_PUBLISHABLE_KEY = env.PUBLIC_STRIPE_PUBLISHABLE_KEY;
```

### 2. Fetch Payment Methods on Load

Update `+page.server.ts` to fetch payment methods:

```typescript
export const load: PageServerLoad = async ({ locals }) => {
  const user = locals.user;

  if (!user?.stripeCustomerId) {
    return { paymentMethods: [] };
  }

  // Fetch payment methods from Stripe
  const stripe = await import("$lib/stripe").then((m) => m.default);
  const paymentMethods = await stripe.paymentMethods.list({
    customer: user.stripeCustomerId,
    type: "card",
  });

  return {
    paymentMethods: paymentMethods.data.map((pm) => ({
      id: pm.id,
      brand: pm.card?.brand || "unknown",
      last4: pm.card?.last4 || "0000",
      expMonth: pm.card?.exp_month || 1,
      expYear: pm.card?.exp_year || 2025,
      isDefault: false, // You'll need to track this separately
    })),
  };
};
```

### 3. Create API Endpoints

Create the following server endpoints:

#### Add Payment Method: `+page.server.ts`

```typescript
export const actions = {
  addPaymentMethod: async ({ request, locals }) => {
    const formData = await request.formData();
    const paymentMethodId = formData.get("paymentMethodId");

    // Attach payment method to customer
    const stripe = await import("$lib/stripe").then((m) => m.default);
    await stripe.paymentMethods.attach(paymentMethodId, {
      customer: locals.user.stripeCustomerId,
    });

    return { success: true };
  },

  removePaymentMethod: async ({ request, locals }) => {
    const formData = await request.formData();
    const paymentMethodId = formData.get("paymentMethodId");

    // Detach payment method from customer
    const stripe = await import("$lib/stripe").then((m) => m.default);
    await stripe.paymentMethods.detach(paymentMethodId);

    return { success: true };
  },

  setDefaultPaymentMethod: async ({ request, locals }) => {
    const formData = await request.formData();
    const paymentMethodId = formData.get("paymentMethodId");

    // Update customer's default payment method
    const stripe = await import("$lib/stripe").then((m) => m.default);
    await stripe.customers.update(locals.user.stripeCustomerId, {
      invoice_settings: {
        default_payment_method: paymentMethodId,
      },
    });

    return { success: true };
  },
};
```

### 4. Update Frontend to Use Actions

Replace the mock functions in `+page.svelte`:

```typescript
async function handleAddPaymentMethod() {
  if (!stripe || !cardElement || !cardComplete) return;

  isProcessing = true;

  try {
    const { error, paymentMethod } = await stripe.createPaymentMethod({
      type: "card",
      card: cardElement,
    });

    if (error) {
      toast.error(error.message);
      return;
    }

    // Submit to your backend
    const formData = new FormData();
    formData.append("paymentMethodId", paymentMethod.id);

    const response = await fetch("?/addPaymentMethod", {
      method: "POST",
      body: formData,
    });

    if (response.ok) {
      toast.success("Payment method added");
      // Reload page to fetch updated payment methods
      window.location.reload();
    }
  } finally {
    isProcessing = false;
  }
}
```

## Security Considerations

⚠️ **Important**: Never expose your Stripe secret key on the frontend
✅ All sensitive operations should happen on your backend
✅ Use Stripe's publishable key for frontend operations
✅ Validate user permissions before modifying payment methods
✅ Use HTTPS in production

## Testing

Use [Stripe's test cards](https://stripe.com/docs/testing) for testing:

- **Success**: 4242 4242 4242 4242
- **Decline**: 4000 0000 0000 0002
- **Requires authentication**: 4000 0025 0000 3155

Use any future expiration date and any 3-digit CVC.

## Styling

The page uses Tailwind CSS and shadcn/ui components. All components are styled to match your existing design system with support for light/dark modes.

### Stripe Elements Styling

Since Stripe Elements render in an iframe, they cannot access CSS variables directly. Additionally, Stripe only accepts HEX, RGB, or HSL colors (not modern formats like OKLCH).

The implementation:

1. **Creates temporary DOM elements** with CSS variables applied
2. **Lets the browser compute colors** - converts OKLCH/HSL/any format to RGB
3. **Extracts RGB values** that Stripe Elements can understand
4. **Passes computed colors** to Stripe Elements on initialization
5. **Matches the current theme** automatically (light/dark mode)

This approach works with any CSS color format (oklch, hsl, rgb, hex) and ensures Stripe Elements always match your theme when the "Add Payment Method" dialog opens.

## Dependencies

- `@stripe/stripe-js` - Stripe.js for frontend
- `stripe` - Stripe Node.js SDK for backend
- `svelte-sonner` - Toast notifications
- `@lucide/svelte` - Icons
- shadcn/ui components
src/routes/api/stripe/payment-intent/+server.ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import type { Stripe } from "stripe";

const createPaymentIntent = async (
  price: Stripe.Price,
  customer?: { id: string; default_payment_method?: string },
) => {
  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");
  }

  // Guest
  if (!customer) {
    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,
    };
  }

  // Customer with saved payment method
  if (customer.default_payment_method) {
    const paymentMethod = await stripe.customers.retrievePaymentMethod(
      customer.id,
      customer.default_payment_method,
    );

    const intent = await stripe.paymentIntents.create({
      amount: price.unit_amount,
      currency: price.currency,
      customer: customer.id,
      payment_method: customer.default_payment_method,
      automatic_payment_methods: {
        enabled: true,
        allow_redirects: "never",
      },
    });

    return {
      type: "confirm",
      clientSecret: intent.client_secret,
      amount: price.unit_amount,
      currency: price.currency,
      brand: paymentMethod.card?.brand,
      last4: paymentMethod.card?.last4,
    };
  }

  // Customer without saved payment method
  const intent = await stripe.paymentIntents.create({
    amount: price.unit_amount,
    currency: price.currency,
    customer: customer.id,
    setup_future_usage: "off_session",
    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 }) => {
  const user = locals.pb.authStore.record?.id;

  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 },
    );
  }

  let customer;

  if (user) {
    try {
      customer = await locals.admin
        .collection("stripe_customers")
        .getFirstListItem(
          locals.admin.filter("user = {:user}", { user: user }),
        );
    } catch (error) {
      // Customer not found is expected for new users, log for debugging only
      console.log("No Stripe customer found for user:", user);
    }
  }

  try {
    const res = await createPaymentIntent(price, customer);
    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 });
  }
};
src/routes/api/stripe/payment-intent/server.test.ts
import { describe, it, expect, beforeAll, beforeEach, afterEach } 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;
  let stripeCustomer: Stripe.Customer;
  let paymentMethod: Stripe.PaymentMethod;

  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",
        },
      });
    }
  });

  beforeEach(async (context) => {
    // Create a Stripe customer for authenticated user tests
    stripeCustomer = await stripe.customers.create({
      email: context.user.email,
      metadata: {
        test: "true",
      },
    });

    // Create a payment method for the customer
    paymentMethod = await stripe.paymentMethods.create({
      type: "card",
      card: {
        token: "tok_visa",
      },
    });

    await stripe.paymentMethods.attach(paymentMethod.id, {
      customer: stripeCustomer.id,
    });
  });

  afterEach(async () => {
    if (paymentMethod?.id) {
      await stripe.paymentMethods.detach(paymentMethod.id);
    }

    if (stripeCustomer?.id) {
      await stripe.customers.del(stripeCustomer.id);
    }
  });

  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);
    });
  });

  describe("Authenticated user without Stripe customer", () => {
    it("should create payment intent for user without Stripe customer", async (context) => {
      await context.agent.authenticateUser();

      const response = await context.agent
        .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");

      // Verify the payment intent
      const intentId = response.body.clientSecret.split("_secret_")[0];
      const intent = await stripe.paymentIntents.retrieve(intentId);
      expect(intent.amount).toBe(1000);
      expect(intent.setup_future_usage).toBeNull();

      // Clean up
      await stripe.paymentIntents.cancel(intent.id);
    });
  });

  describe("Authenticated user with Stripe customer", () => {
    let customerRecord: { id: string };

    beforeEach(async (context) => {
      // Link the Stripe customer to the PocketBase user
      customerRecord = await context.admin
        .collection("stripe_customers")
        .create({
          user: context.user.id,
          id: stripeCustomer.id,
          email: stripeCustomer.email,
        });
    });

    afterEach(async (context) => {
      // Clean up the PocketBase customer record
      await context.admin
        .collection("stripe_customers")
        .delete(customerRecord.id);
    });

    it("should create payment intent with setup_future_usage for user without default payment method", async (context) => {
      await context.agent.authenticateUser();

      const response = await context.agent
        .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 has setup_future_usage
      const intentId = response.body.clientSecret.split("_secret_")[0];
      const intent = await stripe.paymentIntents.retrieve(intentId);
      expect(intent.customer).toBe(stripeCustomer.id);
      expect(intent.setup_future_usage).toBe("off_session");

      // Clean up
      await stripe.paymentIntents.cancel(intent.id);
    });

    it("should create payment intent with default payment method for customer with saved payment method", async (context) => {
      // Set default payment method
      await stripe.customers.update(stripeCustomer.id, {
        invoice_settings: {
          default_payment_method: paymentMethod.id,
        },
      });

      const customerRecord = await context.admin
        .collection("stripe_customers")
        .getFirstListItem(
          context.admin.filter("user = {:user}", { user: context.user.id }),
        );

      await context.admin.collection("stripe_payment_methods").create({
        id: paymentMethod.id,
        customer: customerRecord.id,
        brand: paymentMethod.card?.brand,
        last4: paymentMethod.card?.last4,
        exp_month: paymentMethod.card?.exp_month,
        exp_year: paymentMethod.card?.exp_year,
      });

      await context.admin
        .collection("stripe_customers")
        .update(customerRecord.id, {
          default_payment_method: paymentMethod.id,
        });

      await context.agent.authenticateUser();

      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: activePrice.id });

      expect(response.status).toBe(200);
      expect(response.body.type).toBe("confirm");
      expect(response.body.clientSecret).toBeDefined();
      expect(response.body.amount).toBe(1000);
      expect(response.body.currency).toBe("usd");
      expect(response.body.brand).toBe("visa");
      expect(response.body.last4).toBeDefined();
      expect(response.body.last4).toMatch(/^\d{4}$/);

      // Verify the payment intent has the payment method attached
      const intentId = response.body.clientSecret.split("_secret_")[0];
      const intent = await stripe.paymentIntents.retrieve(intentId);
      expect(intent.customer).toBe(stripeCustomer.id);
      expect(intent.payment_method).toBe(paymentMethod.id);

      // Clean up
      await stripe.paymentIntents.cancel(intent.id);
    });
  });
});
src/routes/api/stripe/setup-intent/+server.ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";

export const POST = async ({ locals }) => {
  const user = locals.pb.authStore.record?.id;

  if (!user) {
    return json({ error: "Unauthorized" }, { status: 401 });
  }

  const customer = await locals.admin
    .collection("stripe_customers")
    .getFirstListItem(locals.admin.filter("user = {:user}", { user }));

  if (!customer) {
    return json({ error: "Customer not found" }, { status: 404 });
  }

  const setupIntent = await stripe.setupIntents.create({
    customer: customer.id,
    automatic_payment_methods: {
      enabled: true,
      allow_redirects: "never",
    },
  });

  return json({ clientSecret: setupIntent.client_secret });
};
src/routes/api/stripe/setup-intent/server.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { Match } from "@velastack/pocketbase";
import type Stripe from "stripe";
import stripe from "$lib/stripe";
import type { RouteId } from "./$types";

describe("POST /api/stripe/setup-intent", () => {
  let stripeCustomer: Stripe.Customer;

  beforeEach(async (context) => {
    await context.agent.authenticateUser();

    stripeCustomer = await stripe.customers.create({
      email: context.user.email,
      metadata: {
        test: "true",
      },
    });

    await context.admin.collection("stripe_customers").create({
      id: stripeCustomer.id,
      user: context.user.id,
    });
  });

  afterEach(async (context) => {
    await context.admin
      .collection("stripe_customers")
      .delete(stripeCustomer.id);
  });

  it("creates a setup intent", async (context) => {
    const response = await context.agent.post(
      "/api/stripe/setup-intent" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
    expect(response.body.clientSecret).toBeDefined();
  });
});
src/routes/api/stripe/sync/customers/+server.ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import { env } from "$env/dynamic/private";

// Sync users to Stripe customers
export const POST = async ({ request, locals }) => {
  const authHeader = request.headers.get("Authorization");
  if (authHeader !== `Bearer ${env.INTERNAL_JOB_SECRET}`) {
    return json({ error: "Unauthorized" }, { status: 401 });
  }

  // Ensure all users have a Stripe customer
  const users = await locals.admin
    .collection("users")
    .getFullList({ filter: "stripe_customers_via_user.id = null" });

  for (const user of users) {
    const customer = await stripe.customers.create({
      email: user.email,
      name: user.name,
    });

    await locals.admin.collection("stripe_customers").create({
      id: customer.id,
      user: user.id,
    });
  }

  console.log("Customers synced");
  return json({ message: "Customers synced" });
};
src/routes/api/stripe/sync/prices/+server.ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import { env } from "$env/dynamic/private";

export const POST = async ({ request, locals }) => {
  const authHeader = request.headers.get("Authorization");
  if (authHeader !== `Bearer ${env.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" });
};
src/routes/api/stripe/sync/products/+server.ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import { env } from "$env/dynamic/private";

export const POST = async ({ request, locals }) => {
  const authHeader = request.headers.get("Authorization");
  if (authHeader !== `Bearer ${env.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" });
};
src/routes/webhooks/stripe/+server.ts
import Stripe from "stripe";
import { json } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";
import stripe from "$lib/stripe";
import { handlePaymentIntentSucceeded } from "./handlers/payment-intent/succeeded";
import { handlePaymentMethodAttached } from "./handlers/payment-method/attached";

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,
      env.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;

      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 },
    );
  }
};
src/routes/webhooks/stripe/handlers/payment-intent/server.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Stripe from "stripe";
import stripe from "$lib/stripe";
import { handlePaymentIntentSucceeded } from "./succeeded";

describe("payment_intent.succeeded", () => {
  let stripeCustomer: Stripe.Customer;
  let paymentIntent: Stripe.PaymentIntent;
  let paymentMethod: Stripe.PaymentMethod;

  beforeEach(async (context) => {
    // Create a Stripe customer
    stripeCustomer = await stripe.customers.create({
      email: context.user.email,
      metadata: {
        test: "true",
      },
    });

    // Link the Stripe customer to the test user
    await context.admin.collection("stripe_customers").create({
      id: stripeCustomer.id,
      user: context.user.id,
    });

    // Create a payment method for testing
    paymentMethod = await stripe.paymentMethods.create({
      type: "card",
      card: {
        token: "tok_visa",
      },
    });

    // Attach payment method to customer
    await stripe.paymentMethods.attach(paymentMethod.id, {
      customer: stripeCustomer.id,
    });

    // Create a payment intent
    paymentIntent = await stripe.paymentIntents.create({
      amount: 2000, // $20.00
      currency: "usd",
      customer: stripeCustomer.id,
      payment_method: paymentMethod.id,
      confirm: true,
      return_url: "https://example.com/return",
    });
  });

  afterEach(async (context) => {
    // Clean up transactions
    const transactions = await context.admin
      .collection("transactions")
      .getFullList({
        filter: context.admin.filter("stripe_customer = {:stripeCustomer}", {
          stripeCustomer: stripeCustomer.id,
        }),
      });
    for (const transaction of transactions) {
      await context.admin.collection("transactions").delete(transaction.id);
    }

    // Clean up Stripe resources
    if (paymentMethod?.id) {
      try {
        await stripe.paymentMethods.detach(paymentMethod.id);
      } catch (e) {
        // Payment method may already be detached
      }
    }

    // Delete customer (this will also delete associated payment methods)
    if (stripeCustomer?.id) {
      await context.admin
        .collection("stripe_customers")
        .delete(stripeCustomer.id);
      await stripe.customers.del(stripeCustomer.id);
    }
  });

  it("creates a transaction record when payment intent succeeds", async (context) => {
    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentIntentSucceeded(paymentIntent, locals);

    // Verify transaction was created
    const transactions = await context.admin
      .collection("transactions")
      .getFullList({
        filter: context.admin.filter(
          "stripe_payment_intent = {:paymentIntent}",
          {
            paymentIntent: paymentIntent.id,
          },
        ),
      });

    expect(transactions).toHaveLength(1);
    const transaction = transactions[0];

    expect(transaction.stripe_payment_intent).toBe(paymentIntent.id);
    expect(transaction.stripe_customer).toBe(stripeCustomer.id);
    expect(transaction.amount).toBe(2000);
    expect(transaction.currency).toBe("usd");
    expect(transaction.user).toBe(context.user.id);
    expect(transaction.stripe_charge).toBeDefined();
    expect(transaction.last4).toBeDefined();
    expect(transaction.brand).toBeDefined();
    expect(transaction.created).toBeDefined();
    expect(transaction.receipt_url).toBeDefined();
  });

  it("handles payment intent with charge object instead of string", async (context) => {
    // Retrieve the payment intent with expanded charge
    const expandedPaymentIntent = await stripe.paymentIntents.retrieve(
      paymentIntent.id,
      {
        expand: ["latest_charge"],
      },
    );

    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentIntentSucceeded(expandedPaymentIntent, locals);

    // Verify transaction was created
    const transactions = await context.admin
      .collection("transactions")
      .getFullList({
        filter: context.admin.filter(
          "stripe_payment_intent = {:paymentIntent}",
          {
            paymentIntent: paymentIntent.id,
          },
        ),
      });

    expect(transactions).toHaveLength(1);
    const transaction = transactions[0];

    expect(transaction.stripe_payment_intent).toBe(paymentIntent.id);
    expect(transaction.stripe_charge).toBeDefined();
  });

  it("handles payment intent with customer object instead of string", async (context) => {
    // Retrieve the payment intent with expanded customer
    const expandedPaymentIntent = await stripe.paymentIntents.retrieve(
      paymentIntent.id,
      {
        expand: ["customer"],
      },
    );

    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentIntentSucceeded(expandedPaymentIntent, locals);

    // Verify transaction was created
    const transactions = await context.admin
      .collection("transactions")
      .getFullList({
        filter: context.admin.filter(
          "stripe_payment_intent = {:paymentIntent}",
          {
            paymentIntent: paymentIntent.id,
          },
        ),
      });

    expect(transactions).toHaveLength(1);
    expect(transactions[0].stripe_customer).toBe(stripeCustomer.id);
  });

  it("handles missing charge ID gracefully", async (context) => {
    const paymentIntentWithoutCharge = {
      ...paymentIntent,
      latest_charge: null,
    } as Stripe.PaymentIntent;

    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentIntentSucceeded(paymentIntentWithoutCharge, locals);

    // Verify no transaction was created
    const transactions = await context.admin
      .collection("transactions")
      .getFullList({
        filter: context.admin.filter(
          "stripe_payment_intent = {:paymentIntent}",
          {
            paymentIntent: paymentIntent.id,
          },
        ),
      });

    expect(transactions).toHaveLength(0);
  });

  it("handles missing customer ID gracefully", async (context) => {
    const paymentIntentWithoutCustomer = {
      ...paymentIntent,
      customer: null,
    } as Stripe.PaymentIntent;

    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentIntentSucceeded(paymentIntentWithoutCustomer, locals);

    // Verify no transaction was created
    const transactions = await context.admin
      .collection("transactions")
      .getFullList({
        filter: context.admin.filter(
          "stripe_payment_intent = {:paymentIntent}",
          {
            paymentIntent: paymentIntent.id,
          },
        ),
      });

    expect(transactions).toHaveLength(0);
  });
});
src/routes/webhooks/stripe/handlers/payment-intent/succeeded.ts
import Stripe from "stripe";
import stripe from "$lib/stripe";
/**
 * Handles payment_intent.succeeded event
 */
export async function handlePaymentIntentSucceeded(
  paymentIntent: Stripe.PaymentIntent,
  locals: App.Locals,
) {
  const chargeId =
    typeof paymentIntent.latest_charge === "string"
      ? paymentIntent.latest_charge
      : paymentIntent.latest_charge?.id;

  if (!chargeId) {
    console.error("No charge ID found for payment intent");
    return;
  }

  const charge = await stripe.charges.retrieve(chargeId);

  const customerId =
    typeof paymentIntent.customer === "string"
      ? paymentIntent.customer
      : paymentIntent.customer?.id;

  if (!customerId) {
    console.error("No customer ID found for payment intent");
    return;
  }

  const customer = await locals.admin
    .collection("stripe_customers")
    .getOne(customerId);
  const created = new Date(charge.created * 1000);

  await locals.admin.collection("transactions").create({
    stripe_payment_intent: paymentIntent.id,
    stripe_charge: chargeId,
    stripe_customer: customerId,
    amount: paymentIntent.amount,
    currency: paymentIntent.currency,
    created: created.toISOString(),
    last4: charge.payment_method_details?.card?.last4,
    brand: charge.payment_method_details?.card?.brand,
    receipt_url: charge.receipt_url,
    user: customer.user,
  });
}
src/routes/webhooks/stripe/handlers/payment-method/attached.ts
import Stripe from "stripe";
import stripe from "$lib/stripe";

/**
 * Handles payment_method.attached event
 */
export async function handlePaymentMethodAttached(
  paymentMethod: Stripe.PaymentMethod,
  locals: App.Locals,
) {
  if (
    paymentMethod.allow_redisplay === "unspecified" ||
    !paymentMethod.customer
  ) {
    return;
  }

  const customer =
    typeof paymentMethod.customer === "string"
      ? paymentMethod.customer
      : paymentMethod.customer?.id;

  await locals.admin.collection("stripe_payment_methods").create({
    id: paymentMethod.id,
    customer: customer,
    brand: paymentMethod.card?.brand,
    last4: paymentMethod.card?.last4,
    exp_month: paymentMethod.card?.exp_month,
    exp_year: paymentMethod.card?.exp_year,
  });

  const paymentMethodRecords = await locals.admin
    .collection("stripe_payment_methods")
    .getFullList({
      filter: locals.admin.filter("customer = {:customer}", {
        customer: customer,
      }),
    });

  if (paymentMethodRecords.length === 1) {
    const paymentMethod = paymentMethodRecords[0];

    await stripe.customers.update(customer, {
      invoice_settings: {
        default_payment_method: paymentMethod.id,
      },
    });

    await locals.admin.collection("stripe_customers").update(customer, {
      default_payment_method: paymentMethod.id,
    });
  }
}
src/routes/webhooks/stripe/handlers/payment-method/server.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Stripe from "stripe";
import stripe from "$lib/stripe";
import { handlePaymentMethodAttached } from "./attached";

describe("payment_method.attached", () => {
  let stripeCustomer: Stripe.Customer;
  let paymentMethod: Stripe.PaymentMethod;

  beforeEach(async (context) => {
    stripeCustomer = await stripe.customers.create({
      email: context.user.email,
      metadata: {
        test: "true",
      },
    });

    await context.admin.collection("stripe_customers").create({
      id: stripeCustomer.id,
      user: context.user.id,
    });

    paymentMethod = await stripe.paymentMethods.create({
      type: "card",
      card: {
        token: "tok_visa",
      },
    });

    await stripe.paymentMethods.attach(paymentMethod.id, {
      customer: stripeCustomer.id,
    });

    paymentMethod = await stripe.paymentMethods.update(paymentMethod.id, {
      allow_redisplay: "always",
    });
  });

  afterEach(async (context) => {
    const paymentMethods = await context.admin
      .collection("stripe_payment_methods")
      .getFullList({
        filter: context.admin.filter("customer = {:stripeCustomer}", {
          stripeCustomer: stripeCustomer.id,
        }),
      });

    for (const pm of paymentMethods) {
      await context.admin.collection("stripe_payment_methods").delete(pm.id);
    }

    if (paymentMethod?.id) {
      try {
        await stripe.paymentMethods.detach(paymentMethod.id);
      } catch {}
    }

    if (stripeCustomer?.id) {
      await context.admin
        .collection("stripe_customers")
        .delete(stripeCustomer.id);
      await stripe.customers.del(stripeCustomer.id);
    }
  });

  it("creates a payment method record when payment method is attached", async (context) => {
    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentMethodAttached(paymentMethod, locals);

    // Verify payment method was created
    const paymentMethods = await context.admin
      .collection("stripe_payment_methods")
      .getFullList({
        filter: context.admin.filter("id = {:paymentMethod}", {
          paymentMethod: paymentMethod.id,
        }),
      });

    expect(paymentMethods).toHaveLength(1);
    const pm = paymentMethods[0];

    expect(pm.id).toBe(paymentMethod.id);
    expect(pm.customer).toBe(stripeCustomer.id);
    expect(pm.brand).toBe(paymentMethod.card?.brand);
    expect(pm.last4).toBe(paymentMethod.card?.last4);
    expect(pm.exp_month).toBe(paymentMethod.card?.exp_month);
    expect(pm.exp_year).toBe(paymentMethod.card?.exp_year);
  });

  it("sets payment method as default when it is the first one", async (context) => {
    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentMethodAttached(paymentMethod, locals);

    // Verify it was set as default in database
    const customer = await context.admin
      .collection("stripe_customers")
      .getOne(stripeCustomer.id);
    expect(customer.default_payment_method).toBe(paymentMethod.id);

    // Verify it was set as default in Stripe
    const stripeCustomerUpdated = await stripe.customers.retrieve(
      stripeCustomer.id,
    );
    if ("deleted" in stripeCustomerUpdated) {
      throw new Error("Customer should not be deleted");
    }
    expect(stripeCustomerUpdated.invoice_settings.default_payment_method).toBe(
      paymentMethod.id,
    );
  });

  it("does not set as default when there are already payment methods", async (context) => {
    // Handle the first payment method
    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentMethodAttached(paymentMethod, locals);

    // Verify it was set as default
    let customer = await context.admin
      .collection("stripe_customers")
      .getOne(stripeCustomer.id);
    expect(customer.default_payment_method).toBe(paymentMethod.id);

    // Create and attach a first payment method
    let secondPaymentMethod = await stripe.paymentMethods.create({
      type: "card",
      card: {
        token: "tok_mastercard",
      },
    });

    await stripe.paymentMethods.attach(secondPaymentMethod.id, {
      customer: stripeCustomer.id,
    });

    secondPaymentMethod = await stripe.paymentMethods.update(
      secondPaymentMethod.id,
      {
        allow_redisplay: "always",
      },
    );

    // Now handle the second payment method
    await handlePaymentMethodAttached(secondPaymentMethod, locals);

    // Verify the default did NOT change
    customer = await context.admin
      .collection("stripe_customers")
      .getOne(stripeCustomer.id);
    expect(customer.default_payment_method).toBe(paymentMethod.id);

    // Clean up the second payment method
    await context.admin
      .collection("stripe_payment_methods")
      .delete(secondPaymentMethod.id);
    await stripe.paymentMethods.detach(secondPaymentMethod.id);
  });

  it("handles payment method with customer object instead of string", async (context) => {
    // Retrieve the payment method with expanded customer
    const expandedPaymentMethod = await stripe.paymentMethods.retrieve(
      paymentMethod.id,
      {
        expand: ["customer"],
      },
    );

    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentMethodAttached(expandedPaymentMethod, locals);

    // Verify payment method was created
    const paymentMethods = await context.admin
      .collection("stripe_payment_methods")
      .getFullList({
        filter: context.admin.filter("id = {:paymentMethod}", {
          paymentMethod: paymentMethod.id,
        }),
      });

    expect(paymentMethods).toHaveLength(1);
    expect(paymentMethods[0].customer).toBe(stripeCustomer.id);
  });

  it("does not create record when allow_redisplay is unspecified", async (context) => {
    // Update payment method to have unspecified allow_redisplay
    const unspecifiedPaymentMethod = await stripe.paymentMethods.update(
      paymentMethod.id,
      {
        allow_redisplay: "unspecified",
      },
    );

    const locals = { admin: context.admin } as App.Locals;
    await handlePaymentMethodAttached(unspecifiedPaymentMethod, locals);

    // Verify no payment method was created
    const paymentMethods = await context.admin
      .collection("stripe_payment_methods")
      .getFullList({
        filter: context.admin.filter("id = {:paymentMethod}", {
          paymentMethod: paymentMethod.id,
        }),
      });

    expect(paymentMethods).toHaveLength(0);
  });
});
src/routes/webhooks/stripe/server.test.ts
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");
  });
});
src/routes/(public)/payment/+page.svelte
<script lang="ts">
  import PaymentButton from "$lib/components/payments/payment-button.svelte";
</script>

<section data-role="content">
  <PaymentButton priceId="price_preview_placeholder" />
</section>