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); +});