Checkout
elements man payments/checkout Read as markdownAn 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
Terminalelements create migration "add payments" -tables=payments
app/migrations/20260509120000-add-payments.migration.sqlcreate 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_<orderId> 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).
app/shared/checkout.tsimport { 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<string> { 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<boolean> { 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:
app/pages/order/services.tsimport { session, sql } from "@elements/app"; import { startCheckout, OrderLine } from "#app/shared/checkout"; /** @rpc */ export async function payOrder(orderId: string): Promise<string> { 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<OrderLine>(` 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); }
app/pages/order/template.ehtmlimport { 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:
app/pages/checkout-test/services.tsimport { 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"); }
app/pages/checkout-test/index.tsimport { 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<OrderLine>(` 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
app/pages/checkout-return/index.tsimport { 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.
index.tsapp.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:
Terminalelements db -sql "select stripe_session_id, amount_total from payments"
Related
payments/webhook: the second caller offulfillCheckout, andensureWebhook().payments/subscriptions:mode: "subscription".