# Checkout An rpc creates a Checkout Session and sends the buyer to Stripe. When the buyer comes back, the app asks Stripe whether the session paid and records it. The webhook (`payments/webhook`) calls the same function, for buyers who close the tab before the redirect. Without a key, in development, the same rpc sends the buyer to a test checkout page in the app instead. ## The Payments Table ```bash Terminal elements create migration "add payments" -tables=payments ``` ```sql app/migrations/20260509120000-add-payments.migration.sql create table payments ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), stripeSessionId text not null unique, orderId uuid not null references orders (id), amountTotal integer not null, currency text not null ); ``` `orderId` points at whatever the app sells: an order, an invoice, a ticket. `stripeSessionId` is unique because the return page and the webhook can both record the same payment, and Stripe can deliver an event more than once. A test checkout stores `test_` there. ## Start Checkout Prices live in the app's own rows, in cents. Pass them inline with `price_data`; Stripe needs no product or price created ahead of time. The `stripe` package returns promises, so the rpc is `async` and awaits each Stripe call (`elements man rpc`). ```ts // app/shared/checkout.ts import { getAppUrl, sql, tx } from "@elements/app"; import { stripe, testCheckout } from "#app/shared/stripe"; import { ensureWebhook } from "#app/shared/stripe-webhook"; export interface OrderLine { name: string; unitAmount: number; quantity: number; } /** Returns the url to send the buyer to: Stripe, or the test checkout. */ export async function startCheckout(orderId: string, email: string, lines: OrderLine[]): Promise { if (testCheckout()) { return `/checkout/test/${orderId}`; } await ensureWebhook(); let checkout = await stripe().checkout.sessions.create({ mode: "payment", customer_email: email, client_reference_id: orderId, line_items: lines.map((line) => ({ quantity: line.quantity, price_data: { currency: "usd", unit_amount: line.unitAmount, product_data: { name: line.name }, }, })), success_url: `${getAppUrl()}/checkout/return?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${getAppUrl()}/orders/${orderId}`, }); return checkout.url!; } /** * Records a paid session. Idempotent: the return page and the webhook both * call it, in either order, any number of times. */ export async function fulfillCheckout(sessionId: string): Promise { let checkout = await stripe().checkout.sessions.retrieve(sessionId); if (checkout.payment_status !== "paid" || !checkout.client_reference_id) { return false; } recordPayment(checkout.id, checkout.client_reference_id, checkout.amount_total!, checkout.currency!); return true; } /** The one place a payment is recorded, real or test. */ export function recordPayment(sessionId: string, orderId: string, amountTotal: number, currency: string) { tx(() => { sql(` insert into payments ( stripeSessionId, orderId, amountTotal, currency ) values ( ${sessionId}, ${orderId}, ${amountTotal}, ${currency} ) on conflict (stripeSessionId) do nothing `); sql(` update orders set status = 'paid' where id = ${orderId} and status = 'pending' `); }); } ``` `fulfillCheckout` trusts only what it reads back from Stripe, never a value the browser sent, so calling it from a page the buyer can reload is safe. Grant what was bought in `recordPayment`, next to the insert, and nowhere else: send the receipt email, mark the ticket valid, start the download. A `where status = 'pending'` guard keeps side effects to the first call. The page's rpc looks up the order and its lines on the server, checks the caller owns it, and returns the url: ```ts // app/pages/order/services.ts import { session, sql } from "@elements/app"; import { startCheckout, OrderLine } from "#app/shared/checkout"; /** @rpc */ export async function payOrder(orderId: string): Promise { let userId = session.getOrThrow("userId"); let order = sql<{ email: string }>(` select u.email from orders o join users u on u.id = o.userId where o.id = ${orderId} and o.userId = ${userId} and o.status = 'pending' `).firstOrThrow(); let lines = sql(` select p.name, p.priceCents as unitAmount, l.quantity from orderLines l join products p on p.id = l.productId where l.orderId = ${orderId} `).all(); return await startCheckout(orderId, order.email, lines); } ``` ```ehtml app/pages/order/template.ehtml import { redirect } from "@elements/app"; import { payOrder } from "./services"; async function pay(orderId: string) { redirect(await payOrder(orderId)); } ``` Never take a price from the browser. The amount is always read from the database inside the rpc. ## The Test Checkout Development without a key. A page in the app stands in for Stripe's hosted checkout: the line items, the total, and one Pay button. It has no card fields; the app never collects a card number, test or not. Build it to look like the rest of the app, with a "Test mode" badge and a note that adding `STRIPE_SECRET_KEY` to `config/env/development.env` switches the button to real Stripe Checkout. `elements create page checkout-test`, routed at `/checkout/test/:orderId`. The route and the rpc both refuse unless `testCheckout()` is true, so neither does anything once a key is set: ```ts // app/pages/checkout-test/services.ts import { ForbiddenError, session, sql } from "@elements/app"; import { recordPayment } from "#app/shared/checkout"; import { testCheckout } from "#app/shared/stripe"; /** @rpc */ export function payTestOrder(orderId: string) { if (!testCheckout()) { throw new ForbiddenError("The test checkout is off."); } let userId = session.getOrThrow("userId"); let order = sql<{ totalCents: number }>(` select sum(p.priceCents * l.quantity)::int as totalCents from orders o join orderLines l on l.orderId = o.id join products p on p.id = l.productId where o.id = ${orderId} and o.userId = ${userId} and o.status = 'pending' group by o.id `).firstOrThrow("order not found"); recordPayment(`test_${orderId}`, orderId, order.totalCents, "usd"); } ``` ```ts // app/pages/checkout-test/index.ts import { NotFoundError, Request, Response, session, sql } from "@elements/app"; import { OrderLine } from "#app/shared/checkout"; import { testCheckout } from "#app/shared/stripe"; import html from "./template"; export default function route(req: Request, res: Response) { if (!testCheckout()) { throw new NotFoundError(); } let userId = session.getOrThrow("userId"); let order = sql<{ id: string }>(` select id from orders where id = ${req.params.orderId} and userId = ${userId} and status = 'pending' `).firstOrThrow("order not found"); let lines = sql(` select p.name, p.priceCents as unitAmount, l.quantity from orderLines l join products p on p.id = l.productId where l.orderId = ${order.id} `).all(); return new html({ order, lines }); } ``` For a guest checkout with no account, key the page and the rpc by the order's unguessable token instead of the session's user. The template's Pay button calls `payTestOrder(order.id)` and then redirects to the order page, which shows paid. A Cancel link goes back to the order without paying. ## The Return Page ```ts // app/pages/checkout-return/index.ts import { Request, Response } from "@elements/app"; import { fulfillCheckout } from "#app/shared/checkout"; import html from "./template"; export default async function route(req: Request, res: Response) { let paid = await fulfillCheckout(String(req.query.session_id)); return new html({ paid }); } ``` The template says "Payment received" when `paid` is true and "Payment processing" otherwise; a payment method that settles later is recorded by the webhook. Most apps redirect straight to the order page instead, which already shows its status live. ```ts // index.ts app.route("/checkout/return", checkoutReturn); ``` ## Testing The test checkout is the payment path a test can drive end to end: call the page's rpc, then `payTestOrder`, and check the order is paid and everything granted on payment happened. `payTestOrder` calls the same `recordPayment` that Stripe payments do. Tests read `development.env`, so once the owner adds a key the test checkout is off; return early from these tests when `stripeConfigured()` is true, so they neither fail nor create real sandbox sessions. The rpcs are `async`, so `await` them in an `async` test. With a sandbox key set, pay by hand with `4242 4242 4242 4242`, any future expiry and any CVC. `4000 0000 0000 0002` is declined. Then check the row: ```bash Terminal elements db -sql "select stripe_session_id, amount_total from payments" ``` ## Related - `payments/webhook`: the second caller of `fulfillCheckout`, and `ensureWebhook()`. - `payments/subscriptions`: `mode: "subscription"`.