# Enable orders
Records what was bought through Stripe Checkout: orders and their lines, written by the webhook so a redelivery updates rather than duplicates. The success page shows the order; with auth, users see their own orders, and a paid order notifies them when notifications are on.
Tags: sveltekit, ecommerce, orders, stripe, webhooks, pocketbase, velastack

$ vela enable orders

## src/routes/(app)/orders/[id]/+page.server.ts

```ts
import { error, redirect } from "@sveltejs/kit";

interface OrderRecord {
  id: string;
  status: string;
  amount_total: number;
  currency: string;
  email: string | null;
  user: string;
  created: string;
}

interface OrderLineRecord {
  id: string;
  name: string;
  quantity: number;
  unit_amount: number | null;
  amount_total: number;
  currency: string;
}

/** One of the signed-in user's orders, with its lines. Anyone else's is not found. */
export const load = async ({ locals, params, parent }) => {
  const { user } = await parent();
  if (!user) redirect(303, "/login");

  const order = await locals.admin
    .collection("orders")
    .getOne<OrderRecord>(params.id)
    .catch(() => null);
  if (!order || order.user !== user.id) error(404, "Not found");

  const lines = await locals.admin
    .collection("order_lines")
    .getFullList<OrderLineRecord>({
      filter: locals.admin.filter("order = {:order}", { order: order.id }),
      sort: "created",
    });

  return {
    order: {
      id: order.id,
      status: order.status,
      amountTotal: order.amount_total,
      currency: order.currency,
      email: order.email,
      created: order.created,
    },
    lines: lines.map((line) => ({
      id: line.id,
      name: line.name,
      quantity: line.quantity,
      unitAmount: line.unit_amount,
      amountTotal: line.amount_total,
      currency: line.currency,
    })),
  };
};
```

## src/routes/(app)/orders/[id]/+page.svelte

```svelte
<script lang="ts">
  import * as Card from "#lib/components/ui/card/index.js";
  import { Badge } from "#lib/components/ui/badge/index.js";
  import { Separator } from "#lib/components/ui/separator/index.js";
  import { formatAmount } from "#lib/cart.svelte.js";

  let { data } = $props();

  const when = new Date(data.order.created).toLocaleDateString(undefined, {
    dateStyle: "long",
  });
</script>

<div class="flex flex-col gap-6">
  <div>
    <p class="text-muted-foreground mb-2 text-sm">
      <a href="/orders">Orders</a>
    </p>
    <h1 class="text-2xl font-semibold">Order of {when}</h1>
    <p class="text-muted-foreground text-xs">{data.order.id}</p>
  </div>

  <Card.Root>
    <Card.Header class="flex flex-row items-center justify-between">
      <Card.Title>Items</Card.Title>
      <Badge variant={data.order.status === "paid" ? "default" : "secondary"}>
        {data.order.status === "paid" ? "Paid" : "Pending"}
      </Badge>
    </Card.Header>
    <Card.Content class="flex flex-col gap-4">
      <ul class="divide-y">
        {#each data.lines as line (line.id)}
          <li class="flex items-center justify-between gap-4 py-3">
            <div class="flex flex-col">
              <span class="font-medium">{line.name}</span>
              {#if line.unitAmount !== null}
                <span class="text-muted-foreground text-sm">
                  {line.quantity} × {formatAmount(
                    line.unitAmount,
                    line.currency,
                  )}
                </span>
              {/if}
            </div>
            <span>{formatAmount(line.amountTotal, line.currency)}</span>
          </li>
        {/each}
      </ul>
      <Separator />
      <div class="flex justify-between font-medium">
        <span>Total</span>
        <span>{formatAmount(data.order.amountTotal, data.order.currency)}</span>
      </div>
    </Card.Content>
  </Card.Root>
</div>
```

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

```ts
import { redirect } from "@sveltejs/kit";

/** The fields of an order this page reads. */
interface OrderRecord {
  id: string;
  status: string;
  amount_total: number;
  currency: string;
  created: string;
}

/** The signed-in user's orders, newest first. */
export const load = async ({ locals, parent }) => {
  const { user } = await parent();
  if (!user) redirect(303, "/login");

  const orders = await locals.admin
    .collection("orders")
    .getFullList<OrderRecord>({
      filter: locals.admin.filter("user = {:user}", { user: user.id }),
      sort: "-created",
    });

  return {
    orders: orders.map((order) => ({
      id: order.id,
      status: order.status,
      amountTotal: order.amount_total,
      currency: order.currency,
      created: order.created,
    })),
  };
};
```

## src/routes/(app)/orders/+page.svelte

```svelte
<script lang="ts">
  import * as Card from "#lib/components/ui/card/index.js";
  import { Badge } from "#lib/components/ui/badge/index.js";
  import { formatAmount } from "#lib/cart.svelte.js";

  let { data } = $props();

  const when = (iso: string) =>
    new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
</script>

<div class="flex flex-col gap-6">
  <div>
    <h1 class="text-2xl font-semibold">Orders</h1>
    <p class="text-muted-foreground text-sm">What you have bought here.</p>
  </div>

  {#if data.orders.length === 0}
    <p class="text-muted-foreground text-sm">No orders yet.</p>
  {:else}
    <Card.Root>
      <Card.Content class="p-0">
        <ul class="divide-y">
          {#each data.orders as order (order.id)}
            <li>
              <a
                href="/orders/{order.id}"
                class="hover:bg-muted/50 flex items-center justify-between gap-4 px-6 py-4"
              >
                <div class="flex flex-col">
                  <span class="font-medium">{when(order.created)}</span>
                  <span class="text-muted-foreground text-xs">{order.id}</span>
                </div>
                <div class="flex items-center gap-3">
                  <Badge
                    variant={order.status === "paid" ? "default" : "secondary"}
                  >
                    {order.status === "paid" ? "Paid" : "Pending"}
                  </Badge>
                  <span class="font-medium"
                    >{formatAmount(order.amountTotal, order.currency)}</span
                  >
                </div>
              </a>
            </li>
          {/each}
        </ul>
      </Card.Content>
    </Card.Root>
  {/if}
</div>
```

## src/routes/webhooks/stripe/handlers/checkout/async-payment.ts

```ts
import type Stripe from "stripe";
import { syncOrder } from "./shared";

/**
 * `checkout.session.async_payment_succeeded` and `_failed`: a payment that
 * settles after Checkout (a bank debit, a voucher) is now final either way,
 * and the order's status follows the session's.
 */
export async function handleCheckoutAsyncPayment(
  session: Stripe.Checkout.Session,
  locals: App.Locals,
) {
  await syncOrder(session, locals);
}
```

## src/routes/webhooks/stripe/handlers/checkout/completed.ts

```ts
import type Stripe from "stripe";
import { syncOrder } from "./shared";

/** `checkout.session.completed`: the buyer finished Checkout; the payment may still be settling. */
export async function handleCheckoutSessionCompleted(
  session: Stripe.Checkout.Session,
  locals: App.Locals,
) {
  await syncOrder(session, locals);
}
```

## src/routes/webhooks/stripe/handlers/checkout/server.test.ts

```ts
import { beforeAll, describe, expect, it } from "vitest";
import type Stripe from "stripe";
import stripe from "#lib/stripe.js";
import { handleCheckoutSessionCompleted } from "./completed";

/**
 * A real test-mode Checkout Session stands in for the webhook's payload: its
 * line items come from Stripe either way, and an unpaid session lists them
 * just as a paid one does.
 */
describe("checkout.session.completed", () => {
  let session: Stripe.Checkout.Session;

  beforeAll(async () => {
    const name = "Test Product - Orders";
    const found = await stripe.products.search({
      query: `name:"${name}" AND metadata["test"]:"true"`,
      limit: 1,
    });
    const product =
      found.data[0] ??
      (await stripe.products.create({ name, metadata: { test: "true" } }));
    const price =
      (
        await stripe.prices.list({
          product: product.id,
          active: true,
          limit: 1,
        })
      ).data[0] ??
      (await stripe.prices.create({
        product: product.id,
        unit_amount: 1500,
        currency: "usd",
      }));
    session = await stripe.checkout.sessions.create({
      mode: "payment",
      line_items: [{ price: price.id, quantity: 2 }],
      success_url: "https://example.com/checkout/success",
      cancel_url: "https://example.com/cart",
    });
  });

  it("records the order and its lines, and a redelivery updates them", async (context) => {
    await handleCheckoutSessionCompleted(session, {
      admin: context.admin,
    } as App.Locals);

    const order = await context.admin.collection("orders").getOne(session.id);
    expect(order.status).toBe("unpaid");
    expect(order.amount_total).toBe(3000);
    expect(order.currency).toBe("USD");

    const lines = await context.admin.collection("order_lines").getFullList({
      filter: context.admin.filter("order = {:order}", { order: session.id }),
    });
    expect(lines).toHaveLength(1);
    expect(lines[0].quantity).toBe(2);
    expect(lines[0].unit_amount).toBe(1500);
    expect(lines[0].name).toBe("Test Product - Orders");

    await handleCheckoutSessionCompleted(session, {
      admin: context.admin,
    } as App.Locals);
    expect(
      await context.admin.collection("order_lines").getFullList({
        filter: context.admin.filter("order = {:order}", { order: session.id }),
      }),
    ).toHaveLength(1);

    // The lines go with the order.
    await context.admin.collection("orders").delete(session.id);
  });
});
```

## src/routes/webhooks/stripe/handlers/checkout/shared.ts

```ts
import type Stripe from "stripe";
import type { RecordModel } from "pocketbase";
import stripe from "#lib/stripe.js";

/**
 * What a Checkout Session becomes in `orders`: keyed by the session id, so a
 * redelivered or replayed event updates the row it already made. `status` is
 * Stripe's `payment_status` (`paid`, `unpaid`, `no_payment_required`); an
 * order whose payment clears later (a bank debit) is created `unpaid` and
 * updated by the async payment event.
 *
 * A signed-in buyer checked out as their Stripe customer, which
 * `stripe_customers` maps back to the user; a guest has only an email.
 */
export async function orderRecordFields(
  session: Stripe.Checkout.Session,
  locals: App.Locals,
) {
  const paymentIntent =
    typeof session.payment_intent === "string"
      ? session.payment_intent
      : (session.payment_intent?.id ?? null);
  const customerId =
    typeof session.customer === "string"
      ? session.customer
      : (session.customer?.id ?? null);
  const customer = customerId
    ? await locals.admin
        .collection("stripe_customers")
        .getOne<{ id: string; user: string }>(customerId)
        .catch(() => null)
    : null;
  return {
    status: session.payment_status,
    amount_total: session.amount_total ?? 0,
    currency: (session.currency ?? "").toUpperCase(),
    email: session.customer_details?.email ?? null,
    payment_intent: paymentIntent,
    customer: customer?.id ?? null,
    user: customer?.user ?? null,
  };
}

/**
 * Records the session as an order with its lines, from Stripe's own line
 * items (the webhook payload carries none). Returns the stored order.
 */
export async function syncOrder(
  session: Stripe.Checkout.Session,
  locals: App.Locals,
): Promise<RecordModel> {
  const orders = locals.admin.collection("orders");
  const fields = await orderRecordFields(session, locals);
  let order: RecordModel;
  try {
    order = await orders.update(session.id, fields);
  } catch {
    order = await orders.create({ id: session.id, ...fields });
  }

  const lines = locals.admin.collection("order_lines");
  for await (const line of stripe.checkout.sessions.listLineItems(session.id, {
    limit: 100,
    expand: ["data.price.product"],
  })) {
    const product = line.price?.product;
    const lineFields = {
      order: session.id,
      price: line.price?.id ?? null,
      product: typeof product === "string" ? product : (product?.id ?? null),
      name: line.description,
      unit_amount: line.price?.unit_amount ?? null,
      quantity: line.quantity ?? 1,
      amount_total: line.amount_total,
      currency: line.currency.toUpperCase(),
    };
    try {
      await lines.update(line.id, lineFields);
    } catch {
      await lines.create({ id: line.id, ...lineFields });
    }
  }
  return order;
}
```
