Record what was bought through Stripe Checkout. Adds orders and order_lines collections written by Stripe's webhook inside the stripe-event workflow, so the app can act on a purchase with retries. An order is one Checkout Session keyed by its id, so a redelivered event updates the row it already made; its status follows Stripe's payment_status, and the lines are Stripe's own line items. Requires payments with Stripe, the cart, and a backend.
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,
})),
};
};
<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>
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,
})),
};
};
<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>
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);
}
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);
}
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);
});
});
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;
}
import { error } from "@sveltejs/kit";
import stripe from "#lib/stripe.js";
interface OrderRecord {
status: string;
amount_total: number;
currency: string;
}
interface OrderLineRecord {
id: string;
name: string;
quantity: number;
amount_total: number;
currency: string;
}
/**
* The order as the webhook recorded it, with its lines; until that event has
* arrived (it usually has), Stripe's own summary of the session.
*/
export const load = async ({ url, locals }) => {
const id = url.searchParams.get("session_id");
if (!id) error(404, "Not found");
const order = await locals.admin
.collection("orders")
.getOne<OrderRecord>(id)
.catch(() => null);
if (order) {
const lines = await locals.admin
.collection("order_lines")
.getFullList<OrderLineRecord>({
filter: locals.admin.filter("order = {:order}", { order: id }),
sort: "created",
});
return {
amountTotal: order.amount_total,
currency: order.currency,
paid: order.status === "paid",
lines: lines.map((line) => ({
id: line.id,
name: line.name,
quantity: line.quantity,
amountTotal: line.amount_total,
currency: line.currency,
})),
};
}
try {
const session = await stripe.checkout.sessions.retrieve(id);
return {
amountTotal: session.amount_total,
currency: session.currency,
paid: session.payment_status === "paid",
lines: [],
};
} catch {
error(404, "Not found");
}
};
<script lang="ts">
import { onMount } from "svelte";
import { cart, formatAmount } from "#lib/cart.svelte.js";
let { data } = $props();
onMount(() => {
cart.load();
if (data.paid) cart.clear();
});
</script>
<section data-role="content" class="max-w-xl">
{#if data.paid}
<h1 class="mb-2 text-2xl font-semibold">Thank you</h1>
<p class="text-muted-foreground">
{#if data.amountTotal !== null && data.currency}
Your payment of {formatAmount(data.amountTotal, data.currency)} went through.
{:else}
Your payment went through.
{/if}
</p>
{:else}
<h1 class="mb-2 text-2xl font-semibold">Payment pending</h1>
<p class="text-muted-foreground">
Your order is placed; the payment has not cleared yet.
</p>
{/if}
{#if data.lines.length > 0}
<ul class="mt-6 divide-y border-y">
{#each data.lines as line (line.id)}
<li class="flex items-center justify-between gap-4 py-3 text-sm">
<span>{line.quantity} × {line.name}</span>
<span>{formatAmount(line.amountTotal, line.currency)}</span>
</li>
{/each}
</ul>
{/if}
</section>
import type Stripe from "stripe";
import { handlePaymentIntentSucceeded } from "./payment-intent/succeeded";
import { handlePaymentMethodAttached } from "./payment-method/attached";
import { handleCheckoutSessionCompleted } from "./checkout/completed";
import { handleCheckoutAsyncPayment } from "./checkout/async-payment";
/**
* 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;
case "checkout.session.completed":
await handleCheckoutSessionCompleted(event.data.object, locals);
break;
case "checkout.session.async_payment_succeeded":
case "checkout.session.async_payment_failed":
await handleCheckoutAsyncPayment(event.data.object, locals);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}