From f905a443045f9ef7b946d9d18aeeb65522da9e8c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 21 Oct 2026 13:24:29 +0000 Subject: [PATCH] Sell the cookbook PDF through CoinPay hosted checkout sessions /book/buy offered a hardcoded list of 15 chains and opened /api/payments/create with no return URL and no buyer, so a buyer was left on CoinPay's page and the download link only ever appeared in the webhook response. It now asks for a receipt email and opens POST /api/checkout/sessions (customer_email, account_id = the email, since the site has no accounts, property hqtui.com, metadata product/reference), stores the cs_ id as the purchase key and redirects to the hosted page: any coin the business holds a wallet for, or PayPal. CoinPay returns the buyer to /book/thanks?session=cs_..., which shows the download link once the purchase is paid, asks CoinPay with the business key when the buyer is back before the webhook, and otherwise refreshes itself. The webhook settles by checkout_session_id, then payment_id for purchases opened before sessions; confirmed and forwarded both settle and markPaid stays idempotent. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/book/checkout/route.ts | 21 +-- apps/web/app/api/book/webhook/route.ts | 12 +- apps/web/app/book/buy/page.tsx | 66 ++++----- apps/web/app/book/page.tsx | 2 +- apps/web/app/book/thanks/page.tsx | 86 +++++++++++ apps/web/lib/coinpay.ts | 186 +++++++++++++----------- apps/web/lib/db.ts | 15 ++ apps/web/test/coinpay-session.test.ts | 70 +++++++++ 8 files changed, 322 insertions(+), 136 deletions(-) create mode 100644 apps/web/app/book/thanks/page.tsx create mode 100644 apps/web/test/coinpay-session.test.ts diff --git a/apps/web/app/api/book/checkout/route.ts b/apps/web/app/api/book/checkout/route.ts index ffdef13..8d53012 100644 --- a/apps/web/app/api/book/checkout/route.ts +++ b/apps/web/app/api/book/checkout/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; -import { createPayment, DEFAULT_CHAIN, isSupportedChain } from "@/lib/coinpay"; +import { createCheckoutSession, normalizeEmail } from "@/lib/coinpay"; import { recordCheckout } from "@/lib/db"; export const dynamic = "force-dynamic"; /** - * Starts a purchase and sends the buyer to CoinPay. + * Starts a purchase: opens a CoinPay hosted checkout session and sends the + * buyer there to pay in any coin the business holds a wallet for, or PayPal. * * A server-side redirect rather than a cross-origin form post, because the * site's CSP sets `form-action 'self'` and a form aimed at another origin is @@ -14,19 +15,19 @@ export const dynamic = "force-dynamic"; */ export async function POST(request: Request): Promise { const form = await request.formData().catch(() => null); - const requested = String(form?.get("chain") ?? DEFAULT_CHAIN); - const chain = isSupportedChain(requested) ? requested : DEFAULT_CHAIN; + const email = normalizeEmail(form?.get("email")); + // A relative Location, not one built from request.url: behind the proxy that + // is the container's own address. + if (!email) return new Response(null, { status: 303, headers: { location: "/book/buy?error=email" } }); try { - const payment = await createPayment(chain); - await recordCheckout(payment.id, chain); - return NextResponse.redirect(payment.url, 303); + const session = await createCheckoutSession(email); + // The session id is the purchase key until the webhook confirms it. + await recordCheckout(session.id, "SESSION"); + return NextResponse.redirect(session.url, 303); } catch (error) { // Never render the upstream body: it can carry the request we signed. console.error("hqtui: cookbook checkout failed", error); - // A relative Location, not one built from request.url: behind Railway's - // proxy that is the container's own address, and the browser would be sent - // to https://0.0.0.0:8080. return new Response(null, { status: 303, headers: { location: "/book/buy?error=1" } }); } } diff --git a/apps/web/app/api/book/webhook/route.ts b/apps/web/app/api/book/webhook/route.ts index 1002875..8c3fb18 100644 --- a/apps/web/app/api/book/webhook/route.ts +++ b/apps/web/app/api/book/webhook/route.ts @@ -6,7 +6,8 @@ import { markPaid } from "@/lib/db"; export const dynamic = "force-dynamic"; /** - * CoinPay tells us a payment confirmed. + * CoinPay tells us a payment confirmed. Settles by checkout_session_id, then + * payment_id; confirmed and forwarded both settle, a repeat changes nothing. * * The signature is over the raw body, so the body is read as text and parsed * afterwards. Re-serialising the parsed object would change the bytes and the @@ -33,12 +34,15 @@ export async function POST(request: Request): Promise { } const event = readWebhook(body); - if (!event.paymentId) return NextResponse.json({ ok: true, ignored: "no payment id" }); + // A hosted checkout session payment is keyed by its cs_ id (what checkout + // stored); a payment opened before sessions is keyed by its payment id. + const key = event.sessionId ?? event.paymentId; + if (!key) return NextResponse.json({ ok: true, ignored: "no payment id" }); if (!event.settled) { return NextResponse.json({ ok: true, ignored: event.type || event.status || "event" }); } - const result = await markPaid(event.paymentId, isOurs(event.metadata)); + const result = await markPaid(key, isOurs(event.metadata, event.property)); if (result === "unknown") return NextResponse.json({ ok: true, settled: false, reason: "unknown payment" }); if (result === "unconfigured") return NextResponse.json({ ok: true, settled: false, reason: "no database" }); @@ -48,6 +52,6 @@ export async function POST(request: Request): Promise { ok: true, settled: true, duplicate: result === "already_paid", - download: `/api/book/download?token=${signDownloadToken(event.paymentId)}`, + download: `/api/book/download?token=${signDownloadToken(key)}`, }); } diff --git a/apps/web/app/book/buy/page.tsx b/apps/web/app/book/buy/page.tsx index 83456a1..9748f56 100644 --- a/apps/web/app/book/buy/page.tsx +++ b/apps/web/app/book/buy/page.tsx @@ -2,24 +2,22 @@ import Link from "next/link"; import { SiteFooter, SiteNav } from "@/components/site/nav"; import { allChapters, BOOK_TITLE } from "@/lib/book"; -import { CHAINS, configured, DEFAULT_CHAIN, PRICE_USD } from "@/lib/coinpay"; +import { configured, PRICE_USD } from "@/lib/coinpay"; import { recordView } from "@/lib/db"; export const dynamic = "force-dynamic"; export const metadata = { title: `Get the PDF — ${BOOK_TITLE}`, - description: `The typeset PDF of ${BOOK_TITLE}, one dollar, paid in crypto.`, + description: `The typeset PDF of ${BOOK_TITLE}, one dollar, paid in crypto or PayPal.`, }; -type Search = { searchParams: Promise<{ error?: string }> }; +type Search = { searchParams: Promise<{ error?: string; cancelled?: string }> }; export default async function Buy({ searchParams }: Search) { await recordView("/book/buy"); - const { error } = await searchParams; + const { error, cancelled } = await searchParams; const chapters = allChapters(); - const stablecoins = CHAINS.filter((chain) => chain.group === "Stablecoin"); - const coins = CHAINS.filter((chain) => chain.group === "Coin"); return (
@@ -45,39 +43,33 @@ export default async function Buy({ searchParams }: Search) { {error ? (

- That did not start. Nothing was charged. Try again, or pick another chain. + {error === "email" + ? "Enter an email address so CoinPay can send your receipt." + : "That did not start. Nothing was charged. Try again in a minute."} +

+ ) : null} + {cancelled && !error ? ( +

+ Checkout cancelled. Nothing was charged.

) : null} {configured() ? (
-

The whole book is on this site and in the EPUB, both free. The PDF is a dollar, paid in - crypto through CoinPay, and it exists because typeset things are nicer to keep. + crypto or PayPal through CoinPay, and it exists because typeset things are nicer to keep.

    diff --git a/apps/web/app/book/thanks/page.tsx b/apps/web/app/book/thanks/page.tsx new file mode 100644 index 0000000..814ef98 --- /dev/null +++ b/apps/web/app/book/thanks/page.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; + +import { SiteFooter, SiteNav } from "@/components/site/nav"; +import { BOOK_TITLE } from "@/lib/book"; +import { sessionPaid, signDownloadToken } from "@/lib/coinpay"; +import { markPaid, purchaseStatus } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: `Thanks | ${BOOK_TITLE}`, + robots: { index: false, follow: false }, +}; + +type Search = { searchParams: Promise<{ session?: string }> }; + +const SESSION_ID = /^cs_[A-Za-z0-9]{8,64}$/; + +/** + * Where CoinPay sends the buyer after paying. The session id in the URL is the + * purchase key. Paid (by webhook, or by asking CoinPay with the business key + * when the buyer is back first) shows the download link; otherwise the page + * refreshes itself until the payment confirms. + */ +export default async function Thanks({ searchParams }: Search) { + const { session } = await searchParams; + const key = session && SESSION_ID.test(session) ? session : null; + + let paid = false; + if (key) { + const status = await purchaseStatus(key); + if (status === "paid") paid = true; + else if (status === "pending" && (await sessionPaid(key))) { + const result = await markPaid(key, false); + paid = result === "paid" || result === "already_paid"; + } + } + + return ( +
    + {key && !paid ? : null} + +
    + + ← {BOOK_TITLE} + + {!key ? ( + <> +

    Nothing to show here

    +

    + This page opens after a checkout.{" "} + + Get the PDF + + . +

    + + ) : paid ? ( + <> +

    Thank you. It is yours.

    +

    The link works for 48 hours. Keep this page or the file.

    + + Download the PDF + + + ) : ( + <> +

    Waiting for the payment to confirm

    +

    + This page checks again every few seconds. Crypto can take a minute or two to confirm; PayPal + is usually instant. If it does not arrive,{" "} + + email us + {" "} + with the checkout id {key}. +

    + + )} +
    + +
    + ); +} diff --git a/apps/web/lib/coinpay.ts b/apps/web/lib/coinpay.ts index 4842aae..40b3251 100644 --- a/apps/web/lib/coinpay.ts +++ b/apps/web/lib/coinpay.ts @@ -1,10 +1,8 @@ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; /** - * Selling the cookbook PDF for a dollar, in crypto, through CoinPay. - * - * Crypto only. On a one dollar sale a card fee takes most of it, and this is - * the rail CoinPay exists for. The HTML and the EPUB are free, so nobody is + * Selling the cookbook PDF for a dollar through a CoinPay hosted checkout + * session: any coin the business holds a wallet for, or PayPal. No card. The HTML and the EPUB are free, so nobody is * paywalled out of the content; the PDF is the typeset artifact. * * Next inlines `process.env.NAME` at build time, so every secret here is read @@ -32,109 +30,116 @@ function siteBase(): string { } export function configured(): boolean { - return Boolean(env("COINPAY_API_KEY") && env("COINPAY_BUSINESS_ID")); + return Boolean(env("COINPAY_API_KEY")); } export const PRICE_USD = 1; export const PROPERTY = "hqtui.com"; export const PRODUCT = "hqtui-cookbook-pdf"; -export interface Chain { - /** What the payments API calls `blockchain`. */ - code: string; - label: string; - group: "Stablecoin" | "Coin"; +/** Sessions last this long; an unpaid one simply expires. */ +export const SESSION_WINDOW_MINUTES = 60; + +/** The site's public origin, for return URLs. Never taken from the request. */ +export function siteOrigin(): string { + return (env("SITE_URL") ?? env("NEXT_PUBLIC_SITE_URL") ?? `https://${PROPERTY}`).replace(/\/+$/, ""); } -/** - * Every chain the business has a wallet for. A payment on a chain with no - * wallet is refused by the API rather than falling back to a platform wallet, - * so this list and the business's wallets have to agree. - */ -export const CHAINS: Chain[] = [ - { code: "USDC_SOL", label: "USDC on Solana", group: "Stablecoin" }, - { code: "USDC_POL", label: "USDC on Polygon", group: "Stablecoin" }, - { code: "USDC_ETH", label: "USDC on Ethereum", group: "Stablecoin" }, - { code: "USDT_SOL", label: "USDT on Solana", group: "Stablecoin" }, - { code: "USDT_POL", label: "USDT on Polygon", group: "Stablecoin" }, - { code: "USDT_ETH", label: "USDT on Ethereum", group: "Stablecoin" }, - { code: "SOL", label: "Solana", group: "Coin" }, - { code: "POL", label: "Polygon", group: "Coin" }, - { code: "ETH", label: "Ethereum", group: "Coin" }, - { code: "BTC", label: "Bitcoin", group: "Coin" }, - { code: "BCH", label: "Bitcoin Cash", group: "Coin" }, - { code: "BNB", label: "BNB", group: "Coin" }, - { code: "ADA", label: "Cardano", group: "Coin" }, - { code: "XRP", label: "XRP", group: "Coin" }, - { code: "DOGE", label: "Dogecoin", group: "Coin" }, -]; +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -/** - * A dollar is a rounding error next to an Ethereum fee, so the default is the - * cheapest thing that settles in seconds. Every other chain is still offered. - */ -export const DEFAULT_CHAIN = "USDC_SOL"; - -export function isSupportedChain(code: string): boolean { - return CHAINS.some((chain) => chain.code === code); +/** A trimmed, lower-cased email, or null when it is not one. */ +export function normalizeEmail(value: unknown): string | null { + const email = typeof value === "string" ? value.trim().toLowerCase() : ""; + return email && email.length <= 254 && EMAIL.test(email) ? email : null; } -export interface CreatedPayment { +export interface CheckoutSession { + /** cs_..., also our purchase key (book_purchases.payment_id). */ id: string; - /** Where to send the buyer. */ + /** CoinPay's hosted checkout page. */ url: string; } /** - * Creates a crypto payment and returns where to send the buyer. - * - * No `merchant_wallet_address`: the payee belongs on the business, which - * resolves the right wallet per chain. Passing one here would offer a single - * address for every chain, which is wrong the moment there is more than one. + * The body POSTed to /api/checkout/sessions. The buyer has no account here, so + * the account is the buyer's email (CoinPay requires both fields). + */ +export function sessionBody(email: string, reference: string): Record { + const origin = siteOrigin(); + return { + amount: PRICE_USD, + currency: "USD", + description: "The High Quality Terminal UI Cookbook (PDF)", + customer_email: email, + account_id: email, + property: PROPERTY, + metadata: { property: PROPERTY, product: PRODUCT, reference, email, account_id: email }, + success_url: `${origin}/book/thanks?session={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/book/buy?cancelled=1`, + expires_in_minutes: SESSION_WINDOW_MINUTES, + }; +} + +/** + * Opens a CoinPay hosted checkout session. The buyer picks any coin the + * business holds a wallet for, or PayPal, on CoinPay's page, so there is no + * chain list to keep in step with the business's wallets here. */ -export async function createPayment( - chain: string = DEFAULT_CHAIN, - buyer: { accountId?: string | null; email?: string | null } = {}, -): Promise { +export async function createCheckoutSession( + email: string, + fetchImpl: typeof fetch = fetch, +): Promise { const key = env("COINPAY_API_KEY"); - const business = env("COINPAY_BUSINESS_ID"); - if (!key || !business) throw new Error("CoinPay is not configured"); - if (!isSupportedChain(chain)) throw new Error(`unsupported chain ${chain}`); + if (!key) throw new Error("CoinPay is not configured"); + const buyer = normalizeEmail(email); + if (!buyer) throw new Error("a valid email is required"); - const response = await fetch(`${apiBase()}/payments/create`, { + const response = await fetchImpl(`${apiBase()}/checkout/sessions`, { method: "POST", - // The business API key travels as a bearer token. `x-api-key` is refused - // with "Missing authorization header", which reads like the key is absent - // rather than in the wrong place. + // The business API key travels as a bearer token; x-api-key is refused. headers: { "content-type": "application/json", authorization: `Bearer ${key}` }, - body: JSON.stringify({ - business_id: business, - amount: PRICE_USD, - blockchain: chain, - description: "The High Quality Terminal UI Cookbook (PDF)", - // `property` names the site so a webhook (or a human reading the CoinPay - // dashboard) can tell which fleet site a payment belongs to. The buyer is - // anonymous today; account_id and email ride along when a caller has them. - metadata: { - property: PROPERTY, - product: PRODUCT, - reference: randomUUID(), - ...(buyer.accountId ? { account_id: buyer.accountId } : {}), - ...(buyer.email ? { email: buyer.email } : {}), - }, - }), + body: JSON.stringify(sessionBody(buyer, randomUUID())), + signal: AbortSignal.timeout(10_000), }); - if (!response.ok) { - throw new Error(`CoinPay refused the payment: ${response.status} ${await response.text()}`); + throw new Error(`CoinPay refused the checkout session: ${response.status} ${await response.text()}`); } - const body = (await response.json()) as { payment?: { id?: string } }; - // The id is on `payment`, not at the top level. - const id = body.payment?.id; - if (!id) throw new Error("CoinPay returned no payment id"); + const body = (await response.json()) as { id?: string; url?: string }; + if (!body.id || !body.url) throw new Error("CoinPay returned no checkout session"); + // Only ever send buyers to CoinPay's own origin. + const origin = new URL(siteBase()).origin; + const url = new URL(body.url, origin); + if (url.origin !== origin) throw new Error("CoinPay returned an off-site checkout url"); + return { id: body.id, url: url.toString() }; +} - return { id, url: `${siteBase()}/pay/${id}` }; +/** + * Asks CoinPay whether a session was paid, for the thanks page when the buyer + * comes back before the webhook. Authenticated with the business key, so the + * answer can be trusted. Null on any failure. + */ +export async function sessionPaid( + sessionId: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const key = env("COINPAY_API_KEY"); + if (!key || !/^cs_[A-Za-z0-9]{8,64}$/.test(sessionId)) return null; + try { + const response = await fetchImpl(`${apiBase()}/checkout/sessions/${sessionId}`, { + headers: { authorization: `Bearer ${key}` }, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) return null; + const body = (await response.json()) as { session?: { status?: string; property?: string }; status?: string }; + const session = body.session ?? body; + if ((session as { property?: string }).property && (session as { property?: string }).property !== PROPERTY) { + return false; + } + return ["paid", "complete", "completed", "confirmed"].includes(String(session.status ?? "").toLowerCase()); + } catch { + return null; + } } /** CoinPay rejects a signature older than this, and so do we. */ @@ -180,6 +185,10 @@ export function verifyWebhook( export interface WebhookEvent { type: string; paymentId: string | null; + /** cs_... when the payment came through a hosted checkout session. */ + sessionId: string | null; + /** The site CoinPay says the session belongs to. */ + property: string | null; status: string; metadata: Record; /** True when the event says the money arrived. */ @@ -204,14 +213,23 @@ export function readWebhook(body: unknown): WebhookEvent { const paymentId = data.payment_id ?? payload.payment?.id ?? (payload.data ? data.id : null) ?? null; const status = String(data.status ?? "").toLowerCase(); const metadata = (data.metadata ?? payload.metadata ?? {}) as Record; + const session = data.checkout_session_id ?? payload.checkout_session_id ?? metadata.checkout_session_id ?? null; const settled = SETTLED.has(status) || (!status && (type === "payment.confirmed" || type === "payment.forwarded")); - return { type, paymentId: paymentId ? String(paymentId) : null, status, metadata, settled }; + return { + type, + paymentId: paymentId ? String(paymentId) : null, + sessionId: session ? String(session) : null, + property: data.property ? String(data.property) : null, + status, + metadata, + settled, + }; } /** True when the metadata says this payment was created by this site. */ -export function isOurs(metadata: Record): boolean { - return metadata.property === PROPERTY || metadata.product === PRODUCT; +export function isOurs(metadata: Record, property?: unknown): boolean { + return property === PROPERTY || metadata.property === PROPERTY || metadata.product === PRODUCT; } const TOKEN_TTL_SECONDS = 48 * 60 * 60; diff --git a/apps/web/lib/db.ts b/apps/web/lib/db.ts index 2e8415a..5171946 100644 --- a/apps/web/lib/db.ts +++ b/apps/web/lib/db.ts @@ -267,3 +267,18 @@ export async function ping(): Promise { clearTimeout(timer); } } + +/** A purchase's status ('pending' | 'paid'), or null when there is no such row. */ +export async function purchaseStatus(key: string): Promise { + if (!configured()) return null; + try { + await migrate(); + const [rows] = await execute([ + { sql: `SELECT status FROM book_purchases WHERE payment_id = ?`, args: [key] }, + ]); + return rows?.[0]?.status ? String(rows[0].status) : null; + } catch (error) { + console.error("hqtui: purchaseStatus failed", error); + return null; + } +} diff --git a/apps/web/test/coinpay-session.test.ts b/apps/web/test/coinpay-session.test.ts new file mode 100644 index 0000000..5bc53b2 --- /dev/null +++ b/apps/web/test/coinpay-session.test.ts @@ -0,0 +1,70 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createCheckoutSession, isOurs, normalizeEmail, readWebhook, sessionBody } from "../lib/coinpay.ts"; + +test("the session body carries email, account, property and our reference", () => { + const body = sessionBody("buyer@example.com", "ref-1") as Record; + assert.equal(body.amount, 1); + assert.equal(body.currency, "USD"); + assert.equal(body.customer_email, "buyer@example.com"); + // No accounts on hqtui.com: a guest's account is their email. + assert.equal(body.account_id, "buyer@example.com"); + assert.equal(body.property, "hqtui.com"); + assert.equal(body.metadata.product, "hqtui-cookbook-pdf"); + assert.equal(body.metadata.reference, "ref-1"); + assert.equal(body.success_url, "https://hqtui.com/book/thanks?session={CHECKOUT_SESSION_ID}"); + assert.equal(body.cancel_url, "https://hqtui.com/book/buy?cancelled=1"); + assert.equal(body.expires_in_minutes, 60); +}); + +test("checkout POSTs /api/checkout/sessions with the bearer key and returns the hosted url", async () => { + process.env.COINPAY_API_KEY = "cp_live_test"; + process.env.COINPAY_API_URL = "https://coinpayportal.com"; + let seen: { url: string; init: RequestInit } | null = null; + const fake = (async (url: string, init: RequestInit) => { + seen = { url, init }; + return new Response(JSON.stringify({ success: true, id: "cs_abcdefgh12345678", url: "https://coinpayportal.com/checkout/cs_abcdefgh12345678" }), { status: 201 }); + }) as unknown as typeof fetch; + const session = await createCheckoutSession(" Buyer@Example.com ", fake); + assert.deepEqual(session, { id: "cs_abcdefgh12345678", url: "https://coinpayportal.com/checkout/cs_abcdefgh12345678" }); + assert.equal(seen!.url, "https://coinpayportal.com/api/checkout/sessions"); + assert.equal((seen!.init.headers as Record).authorization, "Bearer cp_live_test"); + const body = JSON.parse(String(seen!.init.body)); + assert.equal(body.customer_email, "buyer@example.com"); + assert.equal(body.property, "hqtui.com"); +}); + +test("checkout refuses a bad email and an off-site checkout url", async () => { + process.env.COINPAY_API_KEY = "cp_live_test"; + await assert.rejects(createCheckoutSession("nope")); + const evil = (async () => + new Response(JSON.stringify({ id: "cs_x", url: "https://evil.example/checkout/cs_x" }), { status: 201 })) as unknown as typeof fetch; + await assert.rejects(createCheckoutSession("a@b.co", evil), /off-site/); + assert.equal(normalizeEmail(" A@B.CO "), "a@b.co"); + assert.equal(normalizeEmail("a@b"), null); +}); + +test("a session webhook is keyed by checkout_session_id, the same for confirmed and forwarded", () => { + const base = { + id: "evt_pay_1_1", + data: { + payment_id: "pay_1", + checkout_session_id: "cs_abcdefgh12345678", + customer_email: "buyer@example.com", + account_id: "buyer@example.com", + property: "hqtui.com", + metadata: { reference: "ref-1" }, + }, + }; + const confirmed = readWebhook({ ...base, type: "payment.confirmed", data: { ...base.data, status: "confirmed" } }); + const forwarded = readWebhook({ ...base, type: "payment.forwarded", data: { ...base.data, status: "forwarded" } }); + assert.equal(confirmed.sessionId, "cs_abcdefgh12345678"); + assert.equal(confirmed.paymentId, "pay_1"); + assert.equal(confirmed.settled && forwarded.settled, true); + // markPaid is keyed by this id and is a no-op the second time. + assert.equal(confirmed.sessionId ?? confirmed.paymentId, forwarded.sessionId ?? forwarded.paymentId); + // The session names the site even when metadata does not. + assert.equal(isOurs(confirmed.metadata, confirmed.property), true); + const pending = readWebhook({ ...base, type: "payment.pending", data: { ...base.data, status: "pending" } }); + assert.equal(pending.settled, false); +});