# 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. ## The Payments Table ```bash elements create migration "add payments" -tables=payments ``` ```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. ## 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 and does not take part in the automatic await transform, so the rpc is `async` (`elements man rpc`). ```ts // app/shared/checkout.ts import { getAppUrl, sql } from "@elements/app"; import { stripe } from "#app/shared/stripe"; export interface OrderLine { name: string; unitAmount: number; quantity: number; } export async function startCheckout(orderId: string, email: string, lines: OrderLine[]): Promise { 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; } sql(` insert into payments (stripeSessionId, orderId, amountTotal, currency) values (${checkout.id}, ${checkout.client_reference_id}, ${checkout.amount_total}, ${checkout.currency}) on conflict (stripeSessionId) do nothing `); sql(`update orders set status = 'paid' where id = ${checkout.client_reference_id} and status = 'pending'`); return true; } ``` `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 it, 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 redirects: ```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 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 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 With a sandbox key set, pay with `4242 4242 4242 4242`, any future expiry and any CVC. `4000 0000 0000 0002` is declined. Then check the row: ``` elements db -sql "select stripe_session_id, amount_total from payments" ``` A test can call `fulfillCheckout` only against Stripe, so test the order logic around it and run the checkout by hand, or from the setup page's test payment. ## Related - `payments/webhook`: the second caller of `fulfillCheckout`. - `payments/subscriptions`: `mode: "subscription"`.