# Enable payments
Adds payments with Stripe (product/price sync, a checkout dialog, and saved payment methods when auth is enabled), Shopify (a Storefront client and a Buy button that hands off to Shopify's checkout), or WhatsApp (an Order button that opens a prefilled chat with the business and records the order to a hosted VelaStack form).
Tags: sveltekit, payments, stripe, shopify, whatsapp, pocketbase, velastack, checkout

$ vela enable payments

## src/lib/components/currency-select.svelte

```svelte
<script lang="ts">
  import { onMount } from "svelte";
  import * as Select from "#lib/components/ui/select/index.js";
  import { currency, offered, pageLocale } from "#lib/currency.svelte.js";

  // Nothing to pick from until the business offers a second currency.
  let { class: className }: { class?: string } = $props();

  onMount(() => currency.load());

  function label(code: string): string {
    const names = new Intl.DisplayNames(pageLocale(), { type: "currency" });
    return `${code} · ${names.of(code) ?? code}`;
  }
</script>

{#if offered.length > 1}
  <Select.Root
    type="single"
    value={currency.current}
    onValueChange={(code) => currency.set(code)}
  >
    <Select.Trigger class={className} aria-label="Currency"
      >{currency.current}</Select.Trigger
    >
    <Select.Content>
      {#each offered as code (code)}
        <Select.Item value={code}>{label(code)}</Select.Item>
      {/each}
    </Select.Content>
  </Select.Root>
{/if}
```

## src/lib/components/payments/confirm-dialog.svelte

```svelte
<script lang="ts">
  import type { Stripe } from "@stripe/stripe-js";
  import { Button } from "#lib/components/ui/button/index.js";
  import * as Dialog from "#lib/components/ui/dialog/index.js";
  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}>
  <!-- Stripe's card form is tall: scroll within the screen so the buttons stay reachable -->
  <Dialog.Content
    class="max-h-[calc(100dvh-2rem)] overflow-y-auto 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

```svelte
<script lang="ts">
  import { Button } from "#lib/components/ui/button/index.js";
  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.js";
  import { usePaymentIntent } from "#lib/components/payments/use-payment-intent.svelte.js";
  import type { ButtonProps } from "#lib/components/ui/button/index.js";

  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>();
  let setupFutureUsage = $state<"off_session" | null>(null);

  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;
    setupFutureUsage = result.setupFutureUsage ?? null;
    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}
    {setupFutureUsage}
    {user}
    onopenchange={handleDialogOpenChange}
    onsuccess={handlePaymentSuccess}
  />
{/if}
```

## src/lib/components/payments/payment-dialog.svelte

```svelte
<script lang="ts">
  import type { Stripe, StripeElements } from "@stripe/stripe-js";
  import { Button } from "#lib/components/ui/button/index.js";
  import * as Dialog from "#lib/components/ui/dialog/index.js";
  import { Input } from "#lib/components/ui/input/index.js";
  import { Label } from "#lib/components/ui/label/index.js";
  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;
    /** From the intent: `off_session` when the card is kept for the customer. */
    setupFutureUsage?: "off_session" | null;
    user?: { email?: string; name?: string } | null;
    onopenchange: (open: boolean) => void;
    onsuccess: () => void;
  }

  let {
    stripe,
    open = $bindable(),
    amount,
    currency,
    clientSecret,
    setupFutureUsage = null,
    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: {
              // Stripe rejects an empty string.
              email: email || user?.email || undefined,
            },
            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}>
  <!-- Stripe's card form is tall: scroll within the screen so the buttons stay reachable -->
  <Dialog.Content
    class="max-h-[calc(100dvh-2rem)] overflow-y-auto 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>
    <!-- For the receipt: guests, and accounts made with a phone number -->
    {#if !user?.email}
      <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}
          {setupFutureUsage}
          {user}
          onchange={handleCardChange}
        />
      {/if}
    </div>
    <!-- Only a signed-in customer's card is kept, and so can be shown again -->
    {#if setupFutureUsage}
      <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

```svelte
<script lang="ts">
  import { Button } from "#lib/components/ui/button/index.js";
  import SetupDialog from "#lib/components/payments/setup-dialog.svelte";
  import { useStripe } from "#lib/components/payments/use-stripe.svelte.js";
  import { usePaymentIntent } from "#lib/components/payments/use-payment-intent.svelte.js";

  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

```svelte
<script lang="ts">
  import type { Stripe, StripeElements } from "@stripe/stripe-js";
  import { Button } from "#lib/components/ui/button/index.js";
  import * as Dialog from "#lib/components/ui/dialog/index.js";
  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}>
  <!-- Stripe's card form is tall: scroll within the screen so the buttons stay reachable -->
  <Dialog.Content
    class="max-h-[calc(100dvh-2rem)] overflow-y-auto 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

```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

```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;
    /**
     * The intent's `setup_future_usage`, which Elements must match. A setup
     * (adding a card) always keeps the card.
     */
    setupFutureUsage?: "off_session" | null;
    user?: { email?: string; name?: string } | null;
    onchange: (complete: boolean) => void;
  }

  let {
    stripe,
    mode,
    amount = 0,
    currency = "usd",
    savePaymentMethod = false,
    setupFutureUsage = null,
    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,
      }),
      ...(mode === "setup" || setupFutureUsage
        ? {
            setupFutureUsage: "off_session" as const,
          }
        : {}),
    } as StripeElementsOptionsMode);

    paymentElement = elements.create("payment", {
      layout: "tabs",
      defaultValues: {
        billingDetails: {
          email: user?.email || undefined,
          name: user?.name || undefined,
        },
      },
      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

```ts
import { toast } from "svelte-sonner";

export interface PaymentIntentResult {
  type: "payment" | "confirm";
  amount?: number;
  currency?: string;
  clientSecret?: string;
  brand?: string;
  last4?: string;
  /**
   * What the intent was created with: `off_session` when the card is kept
   * for the signed-in customer. The card form has to match it.
   */
  setupFutureUsage?: "off_session" | null;
}

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

```ts
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { toast } from "svelte-sonner";
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from "$app/env/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;
    },
  };
}
```

## src/lib/currency.svelte.ts

```ts
import { browser } from "$app/env";
import { invalidateAll } from "$app/navigation";
import { site } from "#lib/site.js";

/**
 * Prices and the currency they show in.
 *
 * Every price on the site is in the business's currency, `site.currency`.
 * When `site.currencies` offers more, a visitor can pick one of them and
 * sees each price converted at the day's rate, marked `≈`: an estimate, not
 * what they are charged. The choice is kept in a cookie for a year.
 *
 * Rates come from velastack.dev, which publishes central bank reference
 * rates once for every site (see https://velastack.dev/v1/rates). They are
 * fetched in the browser once a day and kept in localStorage.
 */

const RATES_URL = "https://velastack.dev/v1/rates";
const COOKIE = "currency";
const RATES_KEY = "rates";
const DAY = 24 * 60 * 60 * 1000;

interface Rates {
  base: string;
  date: string;
  rates: Record<string, number>;
  fetchedAt: number;
}

/** The day's rates as velastack.dev sends them, before the store stamps when it got them. */
export type RatesPayload = Pick<Rates, "base" | "date" | "rates">;

// `site.ts` declares these once payments asked for them; a project that
// enabled payments before that has none, and formats in each price's own
// currency with nothing to convert to.
const business = site as unknown as {
  currency?: string;
  country?: string;
  currencies?: string[];
};
const home = business.currency ?? "";

/** The currencies the business shows prices in: its own, then the others it offers. */
export const offered: string[] = home
  ? [home, ...(business.currencies ?? []).filter((code) => code !== home)]
  : [];

/**
 * The page's language in the business's country (`es-MX`, not `es`), so a
 * price reads the way the business's customers read it: an English page on
 * a Mexican site shows MXN as `MX$5.50`, a Spanish one as `$5.50`. A bare
 * language would print `360,00 MXN`. On the server the language is the
 * site's default; the server and the browser then agree on the first render.
 */
export function pageLocale(): string {
  // app.html ships `lang="en"`; with i18n the server hook fills the page's own.
  const language = (browser && document.documentElement.lang) || "en";
  if (!business.country || language.includes("-")) return language;
  return `${language}-${business.country}`;
}

class Currency {
  /** The currency prices show in: the business's own until the visitor picks another. */
  current = $state(home);
  rates = $state<Rates | null>(null);

  #loaded = false;

  /**
   * Starts from what the server resolved (`handleCurrency`, through the root
   * layout's data), so the first render shows the visitor's currency on the
   * server and in the browser alike. Called from the root layout, before any
   * child renders; a static site has no server to call it. On the server this
   * sets module state per request, which is sound because Svelte renders a
   * request synchronously: nothing else runs between this call and the end
   * of the page.
   */
  hydrate(current: string, rates: RatesPayload | null) {
    if (offered.includes(current)) this.current = current;
    if (rates) this.rates = { ...rates, fetchedAt: Date.now() };
  }

  /** Reads the cookie and the day's rates, once, in the browser. */
  load() {
    if (!browser || this.#loaded || offered.length < 2) return;
    this.#loaded = true;
    const saved = document.cookie.match(/(?:^|;\s*)currency=([A-Z]{3})/)?.[1];
    if (saved && offered.includes(saved)) this.current = saved;
    this.#loadRates();
  }

  /** Shows prices in `code`, one of `offered`, and remembers it. */
  set(code: string) {
    if (!offered.includes(code)) return;
    this.current = code;
    document.cookie = `${COOKIE}=${code}; path=/; max-age=${365 * 24 * 60 * 60}; samesite=lax`;
    if (code !== home) this.#loadRates();
    // A load that prices by the currency (a Shopify market) runs again.
    void invalidateAll();
  }

  /** `amount` in `from` as the current currency, or `null` without a rate for either. */
  convert(amount: number, from = home): number | null {
    if (from === this.current || !this.current) return amount;
    const rates = this.rates?.rates;
    const fromRate = rates?.[from];
    const toRate = rates?.[this.current];
    if (!fromRate || !toRate) return null;
    return (amount / fromRate) * toRate;
  }

  /**
   * `amount` in `from`, as the visitor should read it: in the current
   * currency with `≈` when it was converted, else as it is. Formatted for
   * the page's language in the business's country.
   */
  format(amount: number, from = home): string {
    const converted = this.convert(amount, from);
    if (converted === null || from === this.current || !this.current)
      return money(amount, from);
    return `≈ ${money(converted, this.current)}`;
  }

  async #loadRates() {
    if (this.rates && Date.now() - this.rates.fetchedAt < DAY) return;
    try {
      const cached = JSON.parse(
        localStorage.getItem(RATES_KEY) ?? "null",
      ) as Rates | null;
      if (cached && Date.now() - cached.fetchedAt < DAY) {
        this.rates = cached;
        return;
      }
    } catch {
      // Unreadable storage: fetch instead.
    }
    try {
      const response = await fetch(RATES_URL);
      if (!response.ok) return;
      const body = (await response.json()) as RatesPayload;
      const rates: Rates = {
        base: body.base,
        date: body.date,
        rates: body.rates,
        fetchedAt: Date.now(),
      };
      this.rates = rates;
      localStorage.setItem(RATES_KEY, JSON.stringify(rates));
    } catch {
      // No rates: prices stay in the business's currency.
    }
  }
}

export const currency = new Currency();

/** `amount` in `code`, formatted for the page's language in the business's country. */
export function money(amount: number, code: string): string {
  return new Intl.NumberFormat(pageLocale(), {
    style: "currency",
    currency: code,
  }).format(amount);
}

/**
 * The currency a `load` should price by: what the server resolved into the
 * root layout's data (`currency`, with a server), else what the store holds.
 */
export function currencyFrom(data: object): string {
  const resolved = "currency" in data ? data.currency : undefined;
  return typeof resolved === "string" && offered.includes(resolved)
    ? resolved
    : currency.current;
}

/** A price in the business's currency, shown the way the visitor reads prices. */
export function formatPrice(amount: number, from = home): string {
  return currency.format(amount, from);
}
```

## src/lib/server/stripe-customer.ts

```ts
import { linkStripeCustomer } from "#lib/workflows/link-stripe-customer.js";

/**
 * The user's Stripe customer id. Signup starts link-stripe-customer in the
 * background, so a user can get here before it finishes, or without it ever
 * starting (a one-time-code signup, an import). Either way this starts or
 * joins that same run (the user's id is its idempotency key, so there is still
 * one customer) and waits a little for it.
 *
 * Throws when the customer is not there in time; the caller decides whether to
 * say "try again shortly" or fail.
 */
export async function getStripeCustomerId(
  admin: App.Locals["admin"],
  user: { id: string; email?: string },
  timeoutMs = 10_000,
): Promise<string> {
  try {
    const customer = await admin
      .collection("stripe_customers")
      .getFirstListItem(admin.filter("user = {:user}", { user: user.id }));
    return customer.id;
  } catch {
    // Not linked yet.
  }

  const handle = await linkStripeCustomer.run(
    { userId: user.id, email: user.email || undefined },
    { idempotencyKey: user.id },
  );
  const { customerId } = await handle.result({ timeoutMs });
  return customerId;
}
```

## src/lib/stripe.ts

```ts
import { STRIPE_SECRET_KEY } from "$app/env/private";
import Stripe from "stripe";

let client: Stripe | undefined;

function getClient(): Stripe {
  if (!client) {
    if (!STRIPE_SECRET_KEY) {
      throw new Error(
        "STRIPE_SECRET_KEY is not set, so Stripe cannot be called.",
      );
    }
    // No apiVersion: the SDK pins the version it was built for, and naming one
    // here fell behind it in every fresh project.
    client = new Stripe(STRIPE_SECRET_KEY);
  }
  return client;
}

// Built on first use, not on import. The workflow worker imports every workflow
// at startup, and some import this module, so a client built at module scope
// takes the whole server down wherever the key is unset - a pull request
// preview, say - instead of failing only the requests that need Stripe.
const stripe = new Proxy({} as Stripe, {
  get(_target, property) {
    const value = Reflect.get(getClient(), property);
    return typeof value === "function" ? value.bind(getClient()) : value;
  },
});

export default stripe;
```

## src/lib/workflows/link-stripe-customer.ts

```ts
import { z } from "zod";
import stripe from "#lib/stripe.js";
import { getAdmin, ow } from "#lib/server/workflows.js";

/**
 * Gives a user a Stripe customer, reusing one that already carries the email
 * and copying over its saved cards. A user without an email (one made with a
 * phone number) gets a new customer under their name and number. Signup starts
 * it with the user's id as the idempotency key, so however many times it is
 * started or retried there is one run and one customer per user.
 */
export const linkStripeCustomer = ow.defineWorkflow(
  {
    name: "link-stripe-customer",
    schema: z.object({ userId: z.string(), email: z.string().optional() }),
    retryPolicy: { maximumAttempts: 5 },
  },
  async ({ input, step }) => {
    const customer = await step.run(
      { name: "find-or-create-customer" },
      async () => {
        if (!input.email) {
          const admin = await getAdmin();
          // `phone` is there once enable-whatsapp has added it.
          const user: { name?: string; phone?: string } = await admin
            .collection("users")
            .getOne(input.userId);
          const created = await stripe.customers.create({
            name: user.name || undefined,
            phone: user.phone || undefined,
          });
          return {
            id: created.id,
            existing: false,
            defaultPaymentMethod: null,
          };
        }

        const matching = await stripe.customers.list({
          email: input.email,
          limit: 1,
          expand: ["data.invoice_settings"],
        });
        const existing = matching.data[0];
        if (existing) {
          const method = existing.invoice_settings?.default_payment_method;
          return {
            id: existing.id,
            existing: true,
            defaultPaymentMethod:
              typeof method === "string" ? method : (method?.id ?? null),
          };
        }
        const created = await stripe.customers.create({ email: input.email });
        return { id: created.id, existing: false, defaultPaymentMethod: null };
      },
    );

    await step.run({ name: "record-customer" }, async () => {
      const admin = await getAdmin();
      try {
        await admin.collection("stripe_customers").getOne(customer.id);
      } catch {
        await admin
          .collection("stripe_customers")
          .create({ id: customer.id, user: input.userId });
      }
    });

    if (!customer.existing) return { customerId: customer.id, cards: 0 };

    const cards = await step.run({ name: "list-cards" }, async () => {
      const methods = await stripe.customers.listPaymentMethods(customer.id, {
        type: "card",
        allow_redisplay: "always",
      });
      return methods.data
        .filter((method) => method.allow_redisplay !== "unspecified")
        .map((method) => ({
          id: method.id,
          brand: method.card?.brand,
          last4: method.card?.last4,
          exp_month: method.card?.exp_month,
          exp_year: method.card?.exp_year,
        }));
    });

    await step.run({ name: "record-cards" }, async () => {
      const admin = await getAdmin();
      for (const card of cards) {
        try {
          await admin
            .collection("stripe_payment_methods")
            .create({ ...card, customer: customer.id });
        } catch {
          // Already recorded by an earlier attempt.
        }
      }
      if (customer.defaultPaymentMethod) {
        try {
          await admin.collection("stripe_customers").update(customer.id, {
            default_payment_method: customer.defaultPaymentMethod,
          });
        } catch {
          // The default card was not one that can be shown again.
        }
      }
    });

    return { customerId: customer.id, cards: cards.length };
  },
);
```

## src/lib/workflows/stripe-event.ts

```ts
import type Stripe from "stripe";
import { getAdmin, ow } from "#lib/server/workflows.js";
import { dispatchStripeEvent } from "../../routes/webhooks/stripe/handlers/dispatch";

/**
 * One run per Stripe event, keyed on the event id by the webhook route, so
 * Stripe's retries and a replay from its dashboard land on the run that
 * already exists. A handler that throws is retried with backoff; the run and
 * its error are listed under Workflows in the PocketBase dashboard.
 */
export const handleStripeEvent = ow.defineWorkflow<Stripe.Event, void>(
  { name: "stripe-event", retryPolicy: { maximumAttempts: 5 } },
  async ({ input: event, step }) => {
    await step.run({ name: event.type }, async () => {
      const admin = await getAdmin();
      await dispatchStripeEvent(event, { admin } as App.Locals);
    });
  },
);
```

## src/lib/workflows/sync-stripe-catalog.ts

```ts
import stripe from "#lib/stripe.js";
import { getAdmin, ow } from "#lib/server/workflows.js";

/** Copies every Stripe product into stripe_products, adding new ones and updating the rest. */
export const syncStripeProducts = ow.defineWorkflow(
  { name: "sync-stripe-products", retryPolicy: { maximumAttempts: 3 } },
  async ({ step }) => {
    const products = await step.run({ name: "list" }, async () => {
      const rows = [];
      for await (const product of stripe.products.list({ limit: 100 })) {
        rows.push({
          id: product.id,
          name: product.name,
          active: product.active,
          default_price:
            typeof product.default_price === "string"
              ? product.default_price
              : (product.default_price?.id ?? null),
          description: product.description,
          images: product.images,
          metadata: product.metadata,
          url: product.url,
        });
      }
      return rows;
    });

    await step.run({ name: "upsert" }, async () => {
      const admin = await getAdmin();
      for (const product of products) {
        try {
          await admin.collection("stripe_products").update(product.id, product);
        } catch {
          await admin.collection("stripe_products").create(product);
        }
      }
    });

    return { synced: products.length };
  },
);

/** The same for prices. */
export const syncStripePrices = ow.defineWorkflow(
  { name: "sync-stripe-prices", retryPolicy: { maximumAttempts: 3 } },
  async ({ step }) => {
    const prices = await step.run({ name: "list" }, async () => {
      const rows = [];
      // `currency_options` (fixed amounts in other currencies, set in the
      // dashboard) comes only when asked for.
      for await (const price of stripe.prices.list({
        limit: 100,
        expand: ["data.currency_options"],
      })) {
        rows.push({
          id: price.id,
          billing_scheme: price.billing_scheme,
          currency: price.currency,
          product:
            typeof price.product === "string"
              ? price.product
              : price.product.id,
          recurring: price.recurring,
          type: price.type,
          unit_amount: price.unit_amount,
          active: price.active,
          nickname: price.nickname,
          lookup_key: price.lookup_key,
          currency_options: price.currency_options ?? null,
        });
      }
      return rows;
    });

    await step.run({ name: "upsert" }, async () => {
      const admin = await getAdmin();
      for (const price of prices) {
        try {
          await admin.collection("stripe_prices").update(price.id, price);
        } catch {
          await admin.collection("stripe_prices").create(price);
        }
      }
    });

    return { synced: prices.length };
  },
);

/** Both run on this schedule; a product or price changed in Stripe shows up within ten minutes. */
export const cron = "*/10 * * * *";
```

## src/lib/workflows/sync-stripe-customers.ts

```ts
import { getAdmin, ow } from "#lib/server/workflows.js";
import { linkStripeCustomer } from "./link-stripe-customer";

/**
 * The safety net for users that did not come through signup (the dashboard,
 * an import): starts link-stripe-customer for each one without a customer.
 * The idempotency key means a user whose run already exists, finished or
 * still retrying, is left alone.
 */
export const syncStripeCustomers = ow.defineWorkflow(
  { name: "sync-stripe-customers", retryPolicy: { maximumAttempts: 3 } },
  async ({ step }) => {
    const users = await step.run({ name: "find-unlinked" }, async () => {
      const admin = await getAdmin();
      const rows = await admin
        .collection("users")
        .getFullList({ filter: "stripe_customers_via_user.id = null" });
      return rows.map((user) => ({
        id: user.id,
        email: user.email || undefined,
      }));
    });

    await step.run({ name: "start-links" }, async () => {
      for (const user of users) {
        await linkStripeCustomer.run(
          { userId: user.id, email: user.email },
          { idempotencyKey: user.id },
        );
      }
    });

    return { queued: users.length };
  },
);

export const cron = "*/10 * * * *";
```

## src/routes/(app)/billing/+layout.server.ts

```ts
export const load = async ({ parent }) => {
  const { breadcrumbs } = await parent();

  return {
    breadcrumbs: [...breadcrumbs, { title: "Billing", url: "/billing" }],
  };
};
```

## src/routes/(app)/billing/+page.server.ts

```ts
import { error } from "@sveltejs/kit";
import stripe from "#lib/stripe.js";
import { getStripeCustomerId } from "#lib/server/stripe-customer.js";

export const load = async ({ locals, depends, parent }) => {
  const { user } = await parent();
  depends("app:billing");

  let customer;
  try {
    const customerId = await getStripeCustomerId(locals.admin, user);
    customer = await locals.admin
      .collection("stripe_customers")
      .getOne(customerId);
  } catch {
    // The link-stripe-customer run has not finished yet.
    return { paymentMethods: [], user, billingReady: false };
  }

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

  return {
    billingReady: true,
    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

```svelte
<script lang="ts">
  import { Button } from "#lib/components/ui/button/index.js";
  import * as Card from "#lib/components/ui/card/index.js";
  import { Badge } from "#lib/components/ui/badge/index.js";
  import * as AlertDialog from "#lib/components/ui/alert-dialog/index.js";
  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 !data.billingReady}
            <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">Setting up billing</p>
              <p class="text-sm">
                This takes a moment after signing up. Refresh to check again.
              </p>
            </div>
          {:else 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>
        {#if data.billingReady}
          <Card.Footer class="border-t justify-end">
            <SetupButton
              label="Add Payment Method"
              processingLabel="Adding..."
              onsuccess={() => invalidate("app:billing")}
            />
          </Card.Footer>
        {/if}
      </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

````text
# 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 { PUBLIC_STRIPE_PUBLISHABLE_KEY } from "$app/env/public";
const STRIPE_PUBLISHABLE_KEY = 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.js").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.js").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.js").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.js").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

```ts
import stripe from "#lib/stripe.js";
import type { Stripe } from "stripe";
import { getStripeCustomerId } from "#lib/server/stripe-customer.js";

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,
      setupFutureUsage: null,
    };
  }

  // 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,
      setupFutureUsage: null,
      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,
    // The card form must set the same `setupFutureUsage` as the intent, or
    // Stripe refuses to confirm it.
    setupFutureUsage: "off_session",
  };
};

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

  let body;
  try {
    body = await request.json();
  } catch (error) {
    console.error("Invalid JSON in request body");
    return Response.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 Response.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 Response.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 Response.json(
      { error: "This price is no longer available" },
      { status: 400 },
    );
  }

  let customer;

  if (user) {
    try {
      // Links the user to a Stripe customer first if they have none yet (a
      // signup whose link run has not finished, a one-time-code signup).
      const customerId = await getStripeCustomerId(locals.admin, user);
      customer = await locals.admin
        .collection("stripe_customers")
        .getOne(customerId);
    } catch (error) {
      // Not linked in time: pay as a guest rather than not at all.
      console.log("No Stripe customer for user, paying as a guest:", user.id);
    }
  }

  try {
    const res = await createPaymentIntent(price, customer);
    return Response.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 Response.json({ error: errorMessage }, { status: 500 });
  }
};
```

## src/routes/api/stripe/payment-intent/server.test.ts

```ts
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";
import stripe from "#lib/stripe.js";
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();
      expect(response.body.setupFutureUsage).toBeNull();

      // 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 link a Stripe customer and keep the card for them", 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.setupFutureUsage).toBe("off_session");

      // The link-stripe-customer run linked one on the spot, reusing the
      // Stripe customer that already has the user's email.
      const linked = await context.admin
        .collection("stripe_customers")
        .getFirstListItem(
          context.admin.filter("user = {:user}", { user: context.user.id }),
        );
      expect(linked.id).toBe(stripeCustomer.id);
      const intentId = response.body.clientSecret.split("_secret_")[0];
      const intent = await stripe.paymentIntents.retrieve(intentId);
      expect(intent.amount).toBe(1000);
      expect(intent.customer).toBe(linked.id);
      expect(intent.setup_future_usage).toBe("off_session");

      // Clean up
      await stripe.paymentIntents.cancel(intent.id);
      await context.admin.collection("stripe_customers").delete(linked.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();
      expect(response.body.setupFutureUsage).toBe("off_session");

      // 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}$/);
      expect(response.body.setupFutureUsage).toBeNull();

      // 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

```ts
import stripe from "#lib/stripe.js";
import { getStripeCustomerId } from "#lib/server/stripe-customer.js";

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

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

  let customer: string;
  try {
    customer = await getStripeCustomerId(locals.admin, user);
  } catch {
    return Response.json(
      { error: "Billing is still being set up. Try again shortly." },
      { status: 503 },
    );
  }

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

  return Response.json({ clientSecret: setupIntent.client_secret });
};
```

## src/routes/api/stripe/setup-intent/server.test.ts

```ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { Match } from "@velastack/kit";
import type Stripe from "stripe";
import stripe from "#lib/stripe.js";
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/webhooks/stripe/+server.ts

```ts
import Stripe from "stripe";
import { STRIPE_WEBHOOK_SECRET } from "$app/env/private";
import stripe from "#lib/stripe.js";
import { handleStripeEvent } from "#lib/workflows/stripe-event.js";

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 }) => {
  try {
    const event = await verifyWebhookSignature(request, STRIPE_WEBHOOK_SECRET);

    // Queued, not handled here: the workflow runs the handler with retries,
    // and its idempotency key means a redelivery of the same event returns
    // the existing run instead of handling it twice.
    const handle = await handleStripeEvent.run(event, {
      idempotencyKey: event.id,
    });
    console.log(
      `Queued webhook event ${event.type} (${event.id}) as run ${handle.workflowRun.id}`,
    );

    return Response.json({
      received: true,
      eventId: event.id,
      eventType: event.type,
      runId: handle.workflowRun.id,
    });
  } catch (error) {
    console.error("Webhook processing error:", error);

    return Response.json(
      {
        error: "Webhook processing failed",
        message: error instanceof Error ? error.message : "Unknown error",
      },
      { status: 400 },
    );
  }
};
```

## src/routes/webhooks/stripe/handlers/dispatch.ts

```ts
import type Stripe from "stripe";
import { handlePaymentIntentSucceeded } from "./payment-intent/succeeded";
import { handlePaymentMethodAttached } from "./payment-method/attached";

/**
 * Routes a verified Stripe event to its handler. Runs inside the stripe-event
 * workflow, so a handler that throws is retried with backoff rather than lost.
 */
export async function dispatchStripeEvent(
  event: Stripe.Event,
  locals: App.Locals,
) {
  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}`);
  }
}
```

## src/routes/webhooks/stripe/handlers/payment-intent/server.test.ts

```ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Stripe from "stripe";
import stripe from "#lib/stripe.js";
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

```ts
import Stripe from "stripe";
import stripe from "#lib/stripe.js";
/**
 * 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

```ts
import Stripe from "stripe";
import stripe from "#lib/stripe.js";

/**
 * 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

```ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Stripe from "stripe";
import stripe from "#lib/stripe.js";
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

```ts
import { describe, it, expect } from "vitest";
import type { RouteId } from "./$types";
import type { Match } from "@velastack/kit";
import type Stripe from "stripe";
import stripe from "#lib/stripe.js";

describe("POST /webhooks/stripe", () => {
  it("accepts valid webhook signature", async (context) => {
    const event = {
      id: `evt_test_${Date.now()}`,
      object: "event",
      api_version: "2026-08-26.dahlia",
      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: "2026-08-26.dahlia",
      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: "2026-08-26.dahlia",
      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: "2026-08-26.dahlia",
      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");

    // The same event id lands on the same workflow run.
    expect(response2.status).toBe(200);
    expect(response2.body.received).toBe(true);
    expect(response2.body.runId).toBe(response1.body.runId);
  });
});
```

## src/routes/(public)/payment/+page.svelte

```svelte
<script lang="ts">
  import PaymentButton from "#lib/components/payments/payment-button.svelte";
</script>

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