examples/storefront Public
// Unpaid orders hold stock (checkout takes it), so they cannot wait forever. Every five minutes// the cron trigger cancels orders still pending_payment after PAYMENT_WINDOW_MS, cancelling the// PaymentIntent first so a late payment cannot land on a cancelled order.import type { OrderEvent } from "./events.ts";import { cancelOrder } from "./orders.ts";import type { Payments } from "./payments.ts";export const PAYMENT_WINDOW_MS = 30 * 60 * 1000;const BATCH = 50;// One run looks at no more than this many orders, so its calls to Stripe stay bounded. The next// run starts again from the oldest.export const MAX_PER_RUN = 200;export type ExpiryReport = { cancelled: string[]; left_to_webhook: string[] };type Expired = { id: string; payment_intent_id: string | null; created_at: number };export async function expireOrders(db: D1Database, events: Queue<OrderEvent>, payments: Payments, now: number): Promise<ExpiryReport> { const report: ExpiryReport = { cancelled: [], left_to_webhook: [] }; // Orders left to the webhook stay pending_payment, so a query that always starts at the oldest // order would keep returning them and never reach the ones behind. The run pages through the // expired orders by (created_at, id) instead, past every order it has already looked at. let after = null as Expired | null; let seen = 0; while (seen < MAX_PER_RUN) { const { results } = await db .prepare( `SELECT id, payment_intent_id, created_at FROM orders WHERE status = 'pending_payment' AND created_at < ?1 AND (?2 IS NULL OR (created_at, id) > (?2, ?3)) ORDER BY created_at, id LIMIT ?4`, ) .bind(now - PAYMENT_WINDOW_MS, after?.created_at ?? null, after?.id ?? null, Math.min(BATCH, MAX_PER_RUN - seen)) .all<Expired>(); for (const o of results) { // The intent succeeded or is processing: the payment won the race, and the webhook will // mark the order paid. Leave it alone. if (o.payment_intent_id && !(await payments.cancelIntent(o.payment_intent_id))) { report.left_to_webhook.push(o.id); continue; } if ((await cancelOrder(db, events, o.id, now)).changed) report.cancelled.push(o.id); } seen += results.length; after = results.at(-1) ?? null; if (results.length < BATCH) break; } return report;}