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.
The Payments Table
elements create migration "add payments" -tables=payments
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).
// 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<string> {
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;
}
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:
// 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);
}
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
// 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
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 offulfillCheckout.payments/subscriptions:mode: "subscription".