# Enable payments
Adds Stripe payments with product/price sync, checkout flow, and optional saved payment methods when auth is enabled.
Tags: sveltekit, payments, stripe, pocketbase, velastack, checkout

$ vela enable payments

## 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";
  import * as Dialog from "$lib/components/ui/dialog";
  import { toast } from "svelte-sonner";
  import { page } from "$app/state";
  import { invalidate } from "$app/navigation";

  interface Props {
    stripe: Stripe | null;
    open: boolean;
    amount: number;
    currency: string;
    brand?: string;
    last4?: string;
    clientSecret?: string;
    onopenchange: (open: boolean) => void;
    onsuccess: () => void;
  }

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

  const brandNames = {
    amex: "American Express",
    cartes_bancaires: "Cartes Bancaires",
    diners: "Diners",
    discover: "Discover",
    eftpos_au: "Eftpos Au",
    jcb: "Jcb",
    link: "Link",
    mastercard: "Mastercard",
    unionpay: "Unionpay",
    visa: "Visa",
    unknown: "Unknown",
  };

  let isProcessing = $state(false);

  let amountFormatted = $derived.by(() => {
    let formatter = new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: currency,
    });
    return formatter.format(amount / 100);
  });

  const handleOpenChangeComplete = (isOpen: boolean) => {
    if (!isOpen) {
      resetDialog();
    }
    onopenchange(isOpen);
  };

  function resetDialog() {
    open = false;
    isProcessing = false;
  }

  async function handlePaymentMethod() {
    if (!stripe || !clientSecret) return;

    isProcessing = true;

    try {
      const { error: confirmError } = await stripe.confirmPayment({
        clientSecret,
        confirmParams: {
          return_url: page.url.toString(),
        },
        redirect: "if_required",
      });

      if (confirmError) {
        toast.error(confirmError.message || "Failed to pay for your purchase");
        return;
      }

      await invalidate("app:billing");
      toast.success("Payment successful");
      onsuccess();
      resetDialog();
    } catch (err) {
      console.error("Error paying for your purchase:", err);
      toast.error("Failed to pay for your purchase");
    } finally {
      isProcessing = false;
    }
  }
</script>

<Dialog.Root bind:open onOpenChangeComplete={handleOpenChangeComplete}>
  <Dialog.Content class="sm:max-w-[500px]">
    <Dialog.Header>
      <Dialog.Title>Confirm payment</Dialog.Title>
      <Dialog.Description>
        Are you sure you want to pay {amountFormatted} with your
        {brandNames[brand as keyof typeof brandNames]}
        credit card ending in **** {last4}?
      </Dialog.Description>
    </Dialog.Header>
    <Dialog.Footer>
      <Button variant="outline" onclick={resetDialog} disabled={isProcessing}
        >Cancel</Button
      >
      <Button onclick={handlePaymentMethod} disabled={isProcessing}>
        {isProcessing ? "Processing..." : "Pay Now"}
      </Button>
    </Dialog.Footer>
  </Dialog.Content>
</Dialog.Root>
```

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

```svelte
<script lang="ts">
  import { Button } from "$lib/components/ui/button";
  import PaymentDialog from "$lib/components/payments/payment-dialog.svelte";
  import ConfirmDialog from "$lib/components/payments/confirm-dialog.svelte";
  import { useStripe } from "$lib/components/payments/use-stripe.svelte";
  import { usePaymentIntent } from "$lib/components/payments/use-payment-intent.svelte";
  import type { ButtonProps } from "$lib/components/ui/button";

  interface Props {
    priceId: string;
    user?: { email: string; name: string } | null;
    disabled?: boolean;
    isProcessing?: boolean;
    label?: string;
    processingLabel?: string;
    onsuccess?: () => void;
  }

  let {
    priceId,
    user,
    disabled = false,
    isProcessing = false,
    label = "Purchase",
    processingLabel = "Processing...",
    onsuccess,
    ...rest
  }: Props & ButtonProps = $props();

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

  let mode = $state<"payment" | "confirm">("payment");
  let showDialog = $state(false);
  let amount = $state<number>(0);
  let currency = $state<string>("usd");
  let clientSecret = $state<string>();
  let brand = $state<string>();
  let last4 = $state<string>();

  async function handleBuyClick() {
    const result = await paymentIntentManager.fetchPaymentIntent({
      priceId,
    });

    if (!result) return;

    if (result.type === "confirm") {
      mode = "confirm";
      brand = result.brand;
      last4 = result.last4;
    } else if (result.type === "payment") {
      mode = "payment";
    }

    amount = result.amount || 0;
    currency = result.currency || "usd";
    clientSecret = result.clientSecret;
    showDialog = true;
  }

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

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

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

{#if mode === "confirm"}
  <ConfirmDialog
    bind:open={showDialog}
    stripe={stripeManager.stripe}
    {amount}
    {currency}
    {brand}
    {last4}
    {clientSecret}
    onopenchange={handleDialogOpenChange}
    onsuccess={handlePaymentSuccess}
  />
{:else if mode === "payment"}
  <PaymentDialog
    bind:open={showDialog}
    stripe={stripeManager.stripe}
    {amount}
    {currency}
    {clientSecret}
    {user}
    onopenchange={handleDialogOpenChange}
    onsuccess={handlePaymentSuccess}
  />
{/if}
```

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

```svelte
<script lang="ts">
  import type { Stripe, StripeElements } from "@stripe/stripe-js";
  import { Button } from "$lib/components/ui/button";
  import * as Dialog from "$lib/components/ui/dialog";
  import { Input } from "$lib/components/ui/input";
  import { Label } from "$lib/components/ui/label";
  import { toast } from "svelte-sonner";
  import { page } from "$app/state";
  import { invalidate } from "$app/navigation";
  import StripePaymentForm from "./stripe-payment-form.svelte";

  interface Props {
    stripe: Stripe | null;
    open: boolean;
    amount: number;
    currency: string;
    clientSecret?: string;
    user?: { email: string; name: string } | null;
    onopenchange: (open: boolean) => void;
    onsuccess: () => void;
  }

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

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

  let amountFormatted = $derived.by(() => {
    let formatter = new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: currency,
    });
    return formatter.format(amount / 100);
  });

  const handleOpenChangeComplete = (isOpen: boolean) => {
    if (!isOpen) {
      resetDialog();
    }
    onopenchange(isOpen);
  };

  function resetDialog() {
    open = false;
    isProcessing = false;
    cardComplete = false;
  }

  function handleCardChange(complete: boolean) {
    cardComplete = complete;
  }

  async function handlePaymentMethod() {
    if (!stripe || !formRef || !cardComplete || !clientSecret) return;

    const elements = formRef.getElements();
    if (!elements) return;

    isProcessing = true;

    try {
      const { error: submitError } = await elements.submit();
      if (submitError) {
        toast.error(submitError.message || "Failed to pay for your purchase");
        return;
      }

      const { error: confirmError } = await stripe.confirmPayment({
        elements,
        clientSecret,
        confirmParams: {
          return_url: page.url.toString(),
          payment_method_data: {
            billing_details: {
              email: email || user?.email,
            },
            allow_redisplay: savePaymentMethod ? "always" : "unspecified",
          },
        },
        redirect: "if_required",
      });

      if (confirmError) {
        toast.error(confirmError.message || "Failed to pay for your purchase");
        return;
      }

      await invalidate("app:billing");
      toast.success("Payment successful");
      onsuccess();
      resetDialog();
    } catch (err) {
      console.error("Error paying for your purchase:", err);
      toast.error("Failed to pay for your purchase");
    } finally {
      isProcessing = false;
    }
  }
</script>

<Dialog.Root bind:open onOpenChangeComplete={handleOpenChangeComplete}>
  <Dialog.Content class="sm:max-w-[500px]">
    <Dialog.Header>
      <Dialog.Title>{amountFormatted}</Dialog.Title>
      <Dialog.Description
        >Enter your card details to pay for your purchase.</Dialog.Description
      >
    </Dialog.Header>
    {#if !user}
      <div class="space-y-2">
        <Label for="email">Email</Label>
        <Input type="email" bind:value={email} />
      </div>
    {/if}
    <div>
      {#if stripe && open}
        <StripePaymentForm
          bind:this={formRef}
          mode="payment"
          {stripe}
          {amount}
          {currency}
          {savePaymentMethod}
          {user}
          onchange={handleCardChange}
        />
      {/if}
    </div>
    {#if user}
      <div class="flex items-center gap-2">
        <input
          type="checkbox"
          id="savePaymentMethod"
          bind:checked={savePaymentMethod}
        />
        <label for="savePaymentMethod" class="text-sm text-muted-foreground">
          Save payment method for future purchases
        </label>
      </div>
    {/if}
    <Dialog.Footer>
      <Button variant="outline" onclick={resetDialog} disabled={isProcessing}
        >Cancel</Button
      >
      <Button
        onclick={handlePaymentMethod}
        disabled={!cardComplete || isProcessing}
      >
        {isProcessing ? "Processing..." : "Pay Now"}
      </Button>
    </Dialog.Footer>
  </Dialog.Content>
</Dialog.Root>
```

## src/lib/components/payments/setup-button.svelte

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

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

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

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

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

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

    if (!result) return;

    clientSecret = result.clientSecret;
    showDialog = true;
  }

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

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

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

<SetupDialog
  bind:open={showDialog}
  stripe={stripeManager.stripe}
  {clientSecret}
  onopenchange={handleDialogOpenChange}
  onsuccess={handlePaymentSuccess}
/>
```

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

```svelte
<script lang="ts">
  import type { Stripe, StripeElements } from "@stripe/stripe-js";
  import { Button } from "$lib/components/ui/button";
  import * as Dialog from "$lib/components/ui/dialog";
  import { toast } from "svelte-sonner";
  import { page } from "$app/state";
  import { invalidate } from "$app/navigation";
  import StripePaymentForm from "./stripe-payment-form.svelte";

  interface Props {
    stripe: Stripe | null;
    open: boolean;
    clientSecret?: string;
    onopenchange: (open: boolean) => void;
    onsuccess: () => void;
  }

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

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

  const handleOpenChangeComplete = (isOpen: boolean) => {
    if (!isOpen) {
      resetDialog();
    }
    onopenchange(isOpen);
  };

  function resetDialog() {
    open = false;
    isProcessing = false;
    cardComplete = false;
  }

  function handleCardChange(complete: boolean) {
    cardComplete = complete;
  }

  async function handleSetupPaymentMethod() {
    if (!stripe || !formRef || !cardComplete || !clientSecret) return;

    const elements = formRef.getElements();
    if (!elements) return;

    isProcessing = true;

    try {
      const { error: submitError } = await elements.submit();
      if (submitError) {
        toast.error(submitError.message || "Failed to add payment method");
        return;
      }

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

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

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

<Dialog.Root bind:open onOpenChangeComplete={handleOpenChangeComplete}>
  <Dialog.Content class="sm:max-w-[500px]">
    <Dialog.Header>
      <Dialog.Title>Add payment method</Dialog.Title>
      <Dialog.Description
        >Enter your card details to add a new payment method.</Dialog.Description
      >
    </Dialog.Header>
    <div>
      {#if stripe && open}
        <StripePaymentForm
          bind:this={formRef}
          mode="setup"
          {stripe}
          onchange={handleCardChange}
          savePaymentMethod={true}
        />
      {/if}
    </div>
    <Dialog.Footer>
      <Button variant="outline" onclick={resetDialog} disabled={isProcessing}
        >Cancel</Button
      >
      <Button
        onclick={handleSetupPaymentMethod}
        disabled={!cardComplete || isProcessing}
      >
        {isProcessing ? "Processing..." : "Add Card"}
      </Button>
    </Dialog.Footer>
  </Dialog.Content>
</Dialog.Root>
```

## src/lib/components/payments/stripe-constants.ts

```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;
    user?: { email: string; name: string } | null;
    onchange: (complete: boolean) => void;
  }

  let {
    stripe,
    mode,
    amount = 0,
    currency = "usd",
    savePaymentMethod = false,
    user,
    onchange,
  }: Props = $props();

  let cardContainer = $state<HTMLDivElement | null>(null);
  let elements = $state<StripeElements | null>(null);
  let paymentElement = $state<StripePaymentElement | null>(null);

  function initializeElements() {
    if (!stripe) return;

    // Clean up previous elements
    if (paymentElement) {
      paymentElement.destroy();
      paymentElement = null;
    }

    const appearance = {
      theme:
        modeWatcher.current === "dark"
          ? ("night" as const)
          : ("stripe" as const),
      inputs: "condensed" as const,
      labels: "floating" as const,
    };

    elements = stripe.elements({
      mode,
      appearance,
      currency,
      ...(mode === "payment" && {
        amount: amount,
      }),
      ...(user || mode === "setup"
        ? {
            setupFutureUsage: "off_session" as const,
          }
        : {}),
    } as StripeElementsOptionsMode);

    paymentElement = elements.create("payment", {
      layout: "tabs",
      defaultValues: {
        billingDetails: {
          email: user?.email,
          name: user?.name,
        },
      },
      terms: savePaymentMethod ? TERMS_AUTO : TERMS_NEVER,
    });

    if (cardContainer && paymentElement) {
      paymentElement.mount(cardContainer);
      paymentElement.on("change", (event: any) => {
        onchange(event.complete);
      });
    }
  }

  // Initialize elements when component mounts
  onMount(() => {
    initializeElements();

    return () => {
      if (paymentElement) {
        paymentElement.destroy();
      }
    };
  });

  // Update terms when savePaymentMethod changes
  $effect(() => {
    if (paymentElement) {
      paymentElement.update({
        terms: savePaymentMethod ? TERMS_AUTO : TERMS_NEVER,
      });
    }
  });

  export function getElements() {
    return elements;
  }
</script>

<div bind:this={cardContainer}></div>
```

## src/lib/components/payments/use-payment-intent.svelte.ts

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

export interface PaymentIntentResult {
  type: "payment" | "confirm";
  amount?: number;
  currency?: string;
  clientSecret?: string;
  brand?: string;
  last4?: string;
}

export function usePaymentIntent() {
  let isLoading = $state(false);

  async function fetchPaymentIntent({
    priceId,
  }: {
    priceId: string;
  }): Promise<PaymentIntentResult | null> {
    isLoading = true;

    const res = await fetch("/api/stripe/payment-intent", {
      method: "POST",
      body: JSON.stringify({
        priceId: priceId,
      }),
      headers: {
        "Content-Type": "application/json",
      },
    });

    if (!res.ok) {
      toast.error("Failed to initialize payment");
      return null;
    }

    return res.json();
  }

  async function fetchSetupIntent(): Promise<PaymentIntentResult | null> {
    isLoading = true;

    const res = await fetch("/api/stripe/setup-intent", {
      method: "POST",
    });

    if (!res.ok) {
      toast.error("Failed to initialize setup");
      return null;
    }

    return res.json();
  }

  return {
    get isLoading() {
      return isLoading;
    },
    fetchPaymentIntent,
    fetchSetupIntent,
  };
}
```

## src/lib/components/payments/use-stripe.svelte.ts

```ts
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { toast } from "svelte-sonner";
import { env } from "$env/dynamic/public";

export function useStripe() {
  let stripe = $state<Stripe | null>(null);
  let isLoading = $state(true);
  let error = $state<string | null>(null);

  async function initialize() {
    try {
      stripe = await loadStripe(env.PUBLIC_STRIPE_PUBLISHABLE_KEY);
      isLoading = false;
    } catch (err) {
      console.error("Failed to load Stripe:", err);
      error = "Failed to initialize payment system";
      toast.error("Failed to initialize payment system");
      isLoading = false;
    }
  }

  // Auto-initialize
  initialize();

  return {
    get stripe() {
      return stripe;
    },
    get isLoading() {
      return isLoading;
    },
    get error() {
      return error;
    },
  };
}
```

## src/lib/stripe.ts

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

// No apiVersion: the SDK pins the version it was built for, and naming one
// here fell behind it in every fresh project.
const stripe = new Stripe(env.STRIPE_SECRET_KEY);

export default stripe;
```

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

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

/**
 * Gives a user a Stripe customer, reusing one that already carries the email
 * and copying over its saved cards. 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() }),
    retryPolicy: { maximumAttempts: 5 },
  },
  async ({ input, step }) => {
    const customer = await step.run(
      { name: "find-or-create-customer" },
      async () => {
        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";
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";
import { getAdmin, ow } from "$lib/server/workflows";

/** 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),
        });
      }
      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 = [];
      for await (const price of stripe.prices.list({ limit: 100 })) {
        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,
        });
      }
      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";
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
        .filter((user) => user.email)
        .map((user) => ({ id: user.id, email: user.email }));
    });

    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";

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

  let customer;
  try {
    customer = await locals.admin
      .collection("stripe_customers")
      .getFirstListItem(
        locals.admin.filter("user = {:user}", { user: user.id }),
      );
  } catch {
    // The link-stripe-customer workflow started at signup has not finished.
    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";
  import * as Card from "$lib/components/ui/card";
  import { Badge } from "$lib/components/ui/badge";
  import * as AlertDialog from "$lib/components/ui/alert-dialog";
  import SetupButton from "$lib/components/payments/setup-button.svelte";
  import { CreditCard, Trash2, Check } from "@lucide/svelte";
  import { toast } from "svelte-sonner";
  import { invalidate } from "$app/navigation";

  let { data } = $props();
  let paymentMethods = $derived(data.paymentMethods);
  let showDeleteDialog = $state(false);
  let selectedPaymentMethodId = $state<string | null>(null);
  let isProcessing = $state(false);

  function openDeleteDialog(paymentMethodId: string) {
    selectedPaymentMethodId = paymentMethodId;
    showDeleteDialog = true;
  }

  function closeDeleteDialog() {
    showDeleteDialog = false;
    selectedPaymentMethodId = null;
  }

  async function handleDeletePaymentMethod() {
    if (!selectedPaymentMethodId) return;
    isProcessing = true;

    try {
      const formData = new FormData();
      formData.append("paymentMethodId", selectedPaymentMethodId);

      await fetch("?/deletePaymentMethod", {
        method: "POST",
        body: formData,
      });

      await invalidate("app:billing");
      toast.success("Payment method removed successfully");
      closeDeleteDialog();
    } catch (err) {
      console.error("Error deleting payment method:", err);
      toast.error("Failed to remove payment method");
    } finally {
      isProcessing = false;
    }
  }

  async function setDefaultPaymentMethod(paymentMethodId: string) {
    try {
      const formData = new FormData();
      formData.append("paymentMethodId", paymentMethodId);

      await fetch("?/setDefaultPaymentMethod", {
        method: "POST",
        body: formData,
      });

      await invalidate("app:billing");
      toast.success("Default payment method updated");
    } catch (err) {
      console.error("Error setting default payment method:", err);
      toast.error("Failed to update default payment method");
    }
  }

  function getCardBrandIcon(brand: string) {
    // You can customize these icons or use actual card brand icons
    const brands: Record<string, string> = {
      visa: "💳",
      mastercard: "💳",
      amex: "💳",
      discover: "💳",
      unknown: "💳",
    };
    return brands[brand.toLowerCase()] || brands.unknown;
  }

  function formatCardBrand(brand: string) {
    return brand.charAt(0).toUpperCase() + brand.slice(1);
  }
</script>

<div class="divide-y divide-gray-900/10 dark:divide-white/10">
  <div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
    <div class="px-4 sm:px-0">
      <h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
        Payment Methods
      </h2>
      <p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
        Manage your payment methods for subscriptions and purchases.
      </p>
    </div>

    <div class="md:col-span-2">
      <Card.Root>
        <Card.Content>
          {#if !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 { env } from "$env/dynamic/public";
const STRIPE_PUBLISHABLE_KEY = env.PUBLIC_STRIPE_PUBLISHABLE_KEY;
```

### 2. Fetch Payment Methods on Load

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

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

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

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

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

### 3. Create API Endpoints

Create the following server endpoints:

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

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

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

    return { success: true };
  },

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

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

    return { success: true };
  },

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

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

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

### 4. Update Frontend to Use Actions

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

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

  isProcessing = true;

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

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

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

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

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

## Security Considerations

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

## Testing

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

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

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

## Styling

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

### Stripe Elements Styling

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

The implementation:

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

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

## Dependencies

- `@stripe/stripe-js` - Stripe.js for frontend
- `stripe` - Stripe Node.js SDK for backend
- `svelte-sonner` - Toast notifications
- `@lucide/svelte` - Icons
- shadcn/ui components
````

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

```ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import type { Stripe } from "stripe";

const createPaymentIntent = async (
  price: Stripe.Price,
  customer?: { id: string; default_payment_method?: string },
) => {
  if (price.unit_amount === null || price.unit_amount === undefined) {
    console.error("Price does not have a per unit amount");
    throw new Error("Price does not have a per unit amount");
  }

  // Guest
  if (!customer) {
    const intent = await stripe.paymentIntents.create({
      amount: price.unit_amount,
      currency: price.currency,
      automatic_payment_methods: {
        enabled: true,
        allow_redirects: "never",
      },
    });

    if (!intent.client_secret) {
      console.error("Payment intent created without client secret");
      throw new Error("Failed to create payment intent");
    }

    return {
      type: "payment",
      clientSecret: intent.client_secret,
      amount: price.unit_amount,
      currency: price.currency,
    };
  }

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

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

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

  // Customer without saved payment method
  const intent = await stripe.paymentIntents.create({
    amount: price.unit_amount,
    currency: price.currency,
    customer: customer.id,
    setup_future_usage: "off_session",
    automatic_payment_methods: {
      enabled: true,
      allow_redirects: "never",
    },
  });

  if (!intent.client_secret) {
    console.error("Payment intent created without client secret");
    throw new Error("Failed to create payment intent");
  }

  return {
    type: "payment",
    clientSecret: intent.client_secret,
    amount: price.unit_amount,
    currency: price.currency,
  };
};

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

  let body;
  try {
    body = await request.json();
  } catch (error) {
    console.error("Invalid JSON in request body");
    return json({ error: "Invalid request body" }, { status: 400 });
  }

  const { priceId } = body as { priceId?: string };

  // Validate required fields
  if (!priceId || typeof priceId !== "string" || priceId === "") {
    console.error("Missing or invalid priceId");
    return json({ error: "Price ID is required" }, { status: 400 });
  }

  // Retrieve price from Stripe
  let price;
  try {
    price = await stripe.prices.retrieve(priceId);
  } catch (error) {
    console.error("Failed to retrieve price from Stripe:", priceId);
    return json(
      { error: "Invalid price ID or price not found" },
      { status: 404 },
    );
  }

  // Validate price is active
  if (!price.active) {
    console.error("Price is not active:", priceId);
    return json(
      { error: "This price is no longer available" },
      { status: 400 },
    );
  }

  let customer;

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

  try {
    const res = await createPaymentIntent(price, customer);
    return json(res, { status: 200 });
  } catch (error) {
    console.error("Error creating payment intent for price:", priceId);
    const errorMessage =
      error instanceof Error
        ? error.message
        : "Failed to create payment intent";
    return json({ error: errorMessage }, { status: 500 });
  }
};
```

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

```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";
import type Stripe from "stripe";

describe("POST /api/stripe/payment-intent", () => {
  let product: Stripe.Product;
  let activePrice: Stripe.Price;
  let inactivePrice: Stripe.Price;
  let stripeCustomer: Stripe.Customer;
  let paymentMethod: Stripe.PaymentMethod;

  beforeAll(async () => {
    const productName = "Test Product - Payment Intent";
    const products = await stripe.products.search({
      query: `name:"${productName}" AND metadata["test"]:"true"`,
      limit: 1,
    });

    if (products.data.length > 0) {
      product = products.data[0];

      const prices = await stripe.prices.list({
        product: product.id,
        active: true,
        limit: 1,
      });

      if (prices.data.length > 0) {
        activePrice = prices.data[0];
      }

      const inactivePrices = await stripe.prices.list({
        product: product.id,
        active: false,
        limit: 1,
      });

      if (inactivePrices.data.length > 0) {
        inactivePrice = inactivePrices.data[0];
      }
    }

    // Create if not found or no active price
    if (!product) {
      product = await stripe.products.create({
        name: productName,
        metadata: {
          test: "true",
          test_type: "payment-intent",
        },
      });
    }

    if (!activePrice) {
      activePrice = await stripe.prices.create({
        product: product.id,
        unit_amount: 1000,
        currency: "usd",
        metadata: {
          test: "true",
          test_type: "payment-intent",
        },
      });
    }

    if (!inactivePrice) {
      inactivePrice = await stripe.prices.create({
        product: product.id,
        unit_amount: 2000,
        currency: "usd",
        active: false,
        metadata: {
          test: "true",
          test_type: "payment-intent-inactive",
        },
      });
    }
  });

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

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

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

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

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

  describe("Request validation", () => {
    it("should return 400 if request body is not valid JSON", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .set("Content-Type", "application/json")
        .send("invalid json");

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("Invalid request body");
    });

    it("should return 400 if priceId is missing", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({});

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("Price ID is required");
    });

    it("should return 400 if priceId is not a string", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: 12345 });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("Price ID is required");
    });

    it("should return 400 if priceId is an empty string", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: "" });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("Price ID is required");
    });

    it("should return 404 if price does not exist", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: "price_nonexistent123" });

      expect(response.status).toBe(404);
      expect(response.body.error).toBe("Invalid price ID or price not found");
    });

    it("should return 400 if price is not active", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: inactivePrice.id });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("This price is no longer available");
    });
  });

  describe("Guest user (unauthenticated)", () => {
    it("should create payment intent for guest user", async (context) => {
      const response = await context.request
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: activePrice.id });

      expect(response.status).toBe(200);
      expect(response.body.type).toBe("payment");
      expect(response.body.clientSecret).toBeDefined();
      expect(response.body.amount).toBe(1000);
      expect(response.body.currency).toBe("usd");
      expect(response.body.brand).toBeUndefined();
      expect(response.body.last4).toBeUndefined();

      // Verify the payment intent was created in Stripe
      const intentId = response.body.clientSecret.split("_secret_")[0];
      const intent = await stripe.paymentIntents.retrieve(intentId);
      expect(intent.amount).toBe(1000);
      expect(intent.currency).toBe("usd");
      expect(intent.customer).toBeNull();

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

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

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

      expect(response.status).toBe(200);
      expect(response.body.type).toBe("payment");
      expect(response.body.clientSecret).toBeDefined();
      expect(response.body.amount).toBe(1000);
      expect(response.body.currency).toBe("usd");

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

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

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

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

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

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

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

      expect(response.status).toBe(200);
      expect(response.body.type).toBe("payment");
      expect(response.body.clientSecret).toBeDefined();
      expect(response.body.amount).toBe(1000);
      expect(response.body.currency).toBe("usd");
      expect(response.body.brand).toBeUndefined();
      expect(response.body.last4).toBeUndefined();

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

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

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

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

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

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

      await context.agent.authenticateUser();

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

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

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

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

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

```ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";

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

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

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

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

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

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

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

```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";
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 { json } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";
import stripe from "$lib/stripe";
import { handleStripeEvent } from "$lib/workflows/stripe-event";

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,
      env.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 json({
      received: true,
      eventId: event.id,
      eventType: event.type,
      runId: handle.workflowRun.id,
    });
  } catch (error) {
    console.error("Webhook processing error:", error);

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

## src/routes/webhooks/stripe/handlers/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";
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";
/**
 * 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";

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

describe("POST /webhooks/stripe", () => {
  it("accepts valid webhook signature", async (context) => {
    const event = {
      id: `evt_test_${Date.now()}`,
      object: "event",
      api_version: "2024-11-20.acacia",
      created: Math.floor(Date.now() / 1000),
      type: "test.event",
      data: {
        object: {},
      },
      livemode: false,
      pending_webhooks: 0,
      request: {
        id: null,
        idempotency_key: null,
      },
    } as unknown as Stripe.Event;

    const payload = JSON.stringify(event);
    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

    const signature = stripe.webhooks.generateTestHeaderString({
      payload,
      secret: webhookSecret,
    });

    const response = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .set("stripe-signature", signature)
      .send(payload)
      .set("Content-Type", "application/json");

    expect(response.status).toBe(200);
    expect(response.body.received).toBe(true);
    expect(response.body.eventId).toBe(event.id);
    expect(response.body.eventType).toBe("test.event");
  });

  it("rejects invalid webhook signature", async (context) => {
    const event = {
      id: `evt_test_${Date.now()}`,
      object: "event",
      api_version: "2024-11-20.acacia",
      created: Math.floor(Date.now() / 1000),
      type: "test.event",
      data: {
        object: {},
      },
      livemode: false,
      pending_webhooks: 0,
      request: {
        id: null,
        idempotency_key: null,
      },
    } as unknown as Stripe.Event;

    const payload = JSON.stringify(event);

    const response = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .set("stripe-signature", "invalid_signature")
      .send(payload)
      .set("Content-Type", "application/json");

    expect(response.status).toBe(400);
    expect(response.body.error).toBe("Webhook processing failed");
  });

  it("rejects request without signature header", async (context) => {
    const event = {
      id: `evt_test_${Date.now()}`,
      object: "event",
      api_version: "2024-11-20.acacia",
      created: Math.floor(Date.now() / 1000),
      type: "test.event",
      data: {
        object: {},
      },
      livemode: false,
      pending_webhooks: 0,
      request: {
        id: null,
        idempotency_key: null,
      },
    } as unknown as Stripe.Event;

    const payload = JSON.stringify(event);

    const response = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .send(payload)
      .set("Content-Type", "application/json");

    expect(response.status).toBe(400);
    expect(response.body.error).toBe("Webhook processing failed");
    expect(response.body.message).toBe("Missing stripe-signature header");
  });

  it("handles duplicate webhook events", async (context) => {
    const event = {
      id: `evt_test_${Date.now()}`,
      object: "event",
      api_version: "2024-11-20.acacia",
      created: Math.floor(Date.now() / 1000),
      type: "test.event",
      data: {
        object: {},
      },
      livemode: false,
      pending_webhooks: 0,
      request: {
        id: null,
        idempotency_key: null,
      },
    } as unknown as Stripe.Event;

    const payload = JSON.stringify(event);
    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
    const signature = stripe.webhooks.generateTestHeaderString({
      payload,
      secret: webhookSecret,
    });

    // Send first webhook
    const response1 = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .set("stripe-signature", signature)
      .send(payload)
      .set("Content-Type", "application/json");

    expect(response1.status).toBe(200);
    expect(response1.body.received).toBe(true);

    // Send duplicate webhook
    const response2 = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .set("stripe-signature", signature)
      .send(payload)
      .set("Content-Type", "application/json");

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