Manual Payments Checkout

Checkout

elements man payments/checkout Read as markdown

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

Terminal
elements create migration "add payments" -tables=payments
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_<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.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<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.ts
import { 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.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:

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"); }
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<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.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.

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:

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".