diff --git a/.env.example b/.env.example index 3222aac..fe8f0ac 100644 --- a/.env.example +++ b/.env.example @@ -35,7 +35,11 @@ REDIS_URL=redis://localhost:6379 DATABASE_URL=postgres://localhost:5432/nightcell7 # --- CoinPayPortal (sole payment integration, PRD §24) --------------------- -COINPAY_API_BASE=https://api.coinpayportal.com +COINPAY_API_BASE=https://coinpayportal.com +# Optional: discovered from the API key when unset. +COINPAY_BUSINESS_ID= +# Hosted checkout sessions: auto (feature-detect), on, off. +COINPAY_CHECKOUT_SESSIONS=auto COINPAY_API_KEY= COINPAY_WEBHOOK_SECRET= diff --git a/apps/site/app/_components/checkout-form.tsx b/apps/site/app/_components/checkout-form.tsx new file mode 100644 index 0000000..8a64767 --- /dev/null +++ b/apps/site/app/_components/checkout-form.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState, type FormEvent } from "react"; + +interface Method { + id: string; + label: string; + rail: "crypto" | "paypal"; +} + +interface Methods { + hosted: boolean; + methods: Method[]; +} + +/** + * Start a CoinPay checkout. + * + * The price is never sent: the API prices the order from its own catalog. + * When CoinPay hosts the picker (checkout sessions) there is one button; until + * then the buyer picks one of the coins the CoinPay business takes, or PayPal. + */ +export function CheckoutForm({ episodeId }: { episodeId: string }) { + const [methods, setMethods] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + fetch("/api/v1/checkout/coinpay/methods", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status))))) + .then((data: Methods) => live && setMethods(data)) + .catch(() => live && setError("Payments are unavailable right now. Try again shortly.")); + return () => { + live = false; + }; + }, []); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + const form = new FormData(event.currentTarget); + const email = String(form.get("email") ?? "").trim(); + const method = String(form.get("method") ?? "").trim(); + + try { + const res = await fetch("/api/v1/checkout/coinpay", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + episodeId, + ...(email ? { email } : {}), + ...(method ? { method } : {}), + // One key per attempt: a double click replays, a new choice does not. + idempotencyKey: `web_${crypto.randomUUID()}`, + }), + }); + const body = (await res.json().catch(() => ({}))) as { + paymentUrl?: string; + error?: { message?: string }; + }; + if (!res.ok || !body.paymentUrl) { + setError(body.error?.message ?? "Checkout could not be started. Try another method."); + return; + } + window.location.assign(body.paymentUrl); + } catch { + setError("Could not reach the server. Check your connection and try again."); + } finally { + setBusy(false); + } + } + + if (!methods && !error) return

Loading payment options…

; + + return ( +
+
+ + +
+ + {methods && !methods.hosted ? ( +
+ + +
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + +
+ ); +} diff --git a/apps/site/app/checkout/[episode]/page.tsx b/apps/site/app/checkout/[episode]/page.tsx index 13a5ed9..2cf6772 100644 --- a/apps/site/app/checkout/[episode]/page.tsx +++ b/apps/site/app/checkout/[episode]/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { CATALOG, formatPrice } from "@nightcell7/entitlements"; -import { PageShell, DraftNotice } from "../../_components/page-shell"; +import { PageShell } from "../../_components/page-shell"; +import { CheckoutForm } from "../../_components/checkout-form"; export const metadata: Metadata = { title: "Checkout" }; @@ -32,28 +33,18 @@ export default async function CheckoutPage({ params }: { params: Promise<{ episo
  • Offline single-player access
  • - - Purchasing is not open yet. Checkout opens with the episode; until then no - order can be completed, and you will not be charged. - +

    - In the meantime the demo and the multiplayer alpha are free and need no payment details: -

    -

    - - Play the free demo - - - Multiplayer Alpha - + Not sure yet? The demo and the multiplayer alpha are free and need no payment details:{" "} + play the free demo.

    -

    How it will work

    +

    How it works

      -
    1. You are sent to CoinPayPortal to pay.
    2. +
    3. You pay on CoinPayPortal, in crypto or with PayPal.
    4. - You come back to a page that says pending — that page never grants access by + You come back to a page that says pending. That page never grants access by itself.
    5. Your unlock happens only when the payment is confirmed by a verified webhook.
    6. diff --git a/apps/site/app/checkout/cancel/page.tsx b/apps/site/app/checkout/cancel/page.tsx new file mode 100644 index 0000000..3d04baa --- /dev/null +++ b/apps/site/app/checkout/cancel/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import { CATALOG } from "@nightcell7/entitlements"; +import { PageShell } from "../../_components/page-shell"; + +export const metadata: Metadata = { title: "Checkout canceled" }; + +export default function CheckoutCancelPage() { + const episode = CATALOG[0]; + return ( + +

      + {episode ? ( + + Try again + + ) : null} +

      +
      + ); +} diff --git a/apps/site/app/checkout/success/page.tsx b/apps/site/app/checkout/success/page.tsx new file mode 100644 index 0000000..8087ce7 --- /dev/null +++ b/apps/site/app/checkout/success/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import { PageShell } from "../../_components/page-shell"; + +export const metadata: Metadata = { title: "Payment pending" }; + +/** + * Where CoinPay sends the buyer back. It says *pending* and nothing more: + * only a verified webhook unlocks the episode (PRD §24.2). + */ +export default function CheckoutSuccessPage() { + return ( + +

      + Crypto payments confirm after the network does, usually within a few minutes. PayPal + payments can take up to about ten minutes. +

      +

      + Signed in? The episode shows up in your library. Bought as a guest? + Your one-time claim link arrives by email. +

      +
      + ); +} diff --git a/docs/coinpay/README.md b/docs/coinpay/README.md index e6bcbae..ce750cc 100644 --- a/docs/coinpay/README.md +++ b/docs/coinpay/README.md @@ -9,7 +9,7 @@ contract. 1. **The server catalog is the price authority.** `resolvePrice()` in `packages/entitlements/src/catalog.ts` is the only source of an amount, and - `CoinpayClient.createInvoice()` has no amount parameter at all. + `CoinpayClient.createCheckout()` has no amount parameter at all. 2. **The browser return URL proves nothing.** `/checkout/success` shows a pending state. Only a verified webhook moves an order forward. 3. **`paid` is not `fulfilled`.** They are separate states so a failed grant can @@ -34,19 +34,40 @@ listed is rejected — an unknown provider event must never walk an order into a arbitrary state. An unrecognised provider status maps to `manual_review`, never to `paid`. +## Rails + +`CoinpayClient.createCheckout()` picks the rail: + +- **Hosted checkout session** (`POST /api/checkout/sessions`) whenever CoinPay + has it. Feature-detected (a 404 means not yet; rechecked every 10 minutes), + or forced with `COINPAY_CHECKOUT_SESSIONS=on|off`. CoinPay shows every coin + and PayPal on its own page. +- Otherwise the checkout page shows a picker: every coin the business holds a + wallet for (`GET /api/supported-coins`) plus PayPal. A coin goes to + `POST /api/payments/create`, PayPal to `POST /api/paypal/payments/create`. + +Every rail sends `metadata: { property, account_id, email, order_id }`. The +CoinPay id is stored on the order as `:` (`orders.coinpay_order_id`), +because a crypto `payment.confirmed` and every PayPal event carry only the +payment id, not the metadata. + ## Webhook verification ``` -POST /api/v1/webhooks/coinpay -x-coinpay-timestamp: -x-coinpay-signature: hex(hmac-sha256(secret, ".")) +POST /api/v1/webhooks/coinpay (and the singular /webhook/coinpay) +X-CoinPay-Signature: t=,v1=hex(hmac-sha256(secret, ".")) +{ "id": "evt__", "type": "payment.confirmed", + "data": { "payment_id", "status", "amount", "metadata"? }, "created_at", "business_id" } ``` The signature covers the **raw body**, verified before any JSON parsing. The timestamp is bound into the signed payload so an old body cannot be replayed -with a fresh header; events outside a 300 s window are rejected. +with a fresh header; events outside a 300 s window are rejected. The top-level +`id` is the event; the payment id is `data.payment_id`. The order is found by +the stored ref first, then `metadata.order_id`; an event for no order of ours +is acknowledged with `{ received: true, unknownOrder: true }`. -Verification failures return **400**, not 500 — the provider should not retry an +Verification failures return **400**, not 500: the provider should not retry an event we can never accept. ## Fulfilment path diff --git a/packages/coinpay/src/client.ts b/packages/coinpay/src/client.ts index 6bb3858..193ffc2 100644 --- a/packages/coinpay/src/client.ts +++ b/packages/coinpay/src/client.ts @@ -1,4 +1,3 @@ -import { z } from "zod"; import { resolvePrice } from "@nightcell7/entitlements"; /** @@ -7,40 +6,104 @@ import { resolvePrice } from "@nightcell7/entitlements"; * The ONLY payment integration in V1 (PRD §24.1). There is intentionally no * provider abstraction layer here: a second processor is a product decision, * and a premature interface would invite one to be added quietly. + * + * Three CoinPay rails, one call (`createCheckout`): + * + * - hosted checkout sessions (`POST /api/checkout/sessions`): CoinPay shows + * every coin the business takes plus PayPal on its own page. Used whenever + * the endpoint exists; it is feature-detected, because a 404 there means + * this CoinPay does not have it yet. + * - crypto (`POST /api/payments/create`): the buyer picked a coin on our + * checkout page; the coin list is whatever the business holds a wallet for + * (`GET /api/supported-coins`), never a hard-coded guess. + * - PayPal (`POST /api/paypal/payments/create`). + * + * Every rail carries the same metadata: `property`, `account_id`, `email` and + * our `order_id`. The CoinPay reference each rail returns is stored on the + * order, because not every CoinPay webhook echoes metadata back (a crypto + * `payment.confirmed` and every PayPal event carry only the payment id). */ +export const COINPAY_DEFAULT_ORIGIN = "https://coinpayportal.com"; +export const PROPERTY = "nightcell7.com"; +export const PAYPAL_METHOD = "paypal"; + +/** CoinPay's `blockchain` values, with the names a buyer recognises. */ +export const COIN_LABELS: Readonly> = { + BTC: "Bitcoin", + BCH: "Bitcoin Cash", + ETH: "Ethereum", + POL: "Polygon", + SOL: "Solana", + BNB: "BNB", + XRP: "XRP", + ADA: "Cardano", + DOGE: "Dogecoin", + USDC_ETH: "USDC on Ethereum", + USDC_POL: "USDC on Polygon", + USDC_SOL: "USDC on Solana", + USDC_BASE: "USDC on Base", + USDT_ETH: "USDT on Ethereum", + USDT_POL: "USDT on Polygon", + USDT_SOL: "USDT on Solana", + USDT_TRON: "USDT on TRON", +}; + +export type CheckoutRail = "session" | "crypto" | "paypal"; + +export interface CheckoutMethod { + /** A CoinPay `blockchain` value, or `paypal`. */ + id: string; + label: string; + rail: Exclude; +} + +export interface CheckoutMethods { + /** True when CoinPay hosts the picker itself (checkout sessions). */ + hosted: boolean; + methods: CheckoutMethod[]; +} + export interface CoinpayConfig { - apiBase: string; + /** CoinPay origin. `https://coinpayportal.com`, `/api` suffix tolerated. */ + apiBase?: string; apiKey: string; + /** Optional: discovered from the API key when absent. */ + businessId?: string; /** Where the buyer is returned to. Shows *pending*, never "unlocked". */ returnUrl: string; cancelUrl: string; - /** Verified webhooks are the only fulfilment trigger. */ - webhookUrl: string; + /** "auto" feature-detects hosted sessions; "off" never uses them. */ + sessions?: "auto" | "on" | "off"; fetchImpl?: typeof fetch; timeoutMs?: number; + /** Payment window, 15-120 minutes. */ + expiresInMinutes?: number; } -export const createInvoiceResultSchema = z.object({ - invoiceId: z.string().min(1), - /** Hosted CoinPay payment page the buyer is sent to. */ - paymentUrl: z.string().url(), - status: z.string().min(1), - expiresAt: z.string().optional(), -}); - -export type CreateInvoiceResult = z.infer; - -export interface CreateInvoiceInput { - /** Our internal order id; comes back on every webhook as `referenceId`. */ +export interface CreateCheckoutInput { + /** Our internal order id; travels as metadata.order_id. */ orderId: string; episodeId: string; - /** Optional — guest checkout is supported via the claim flow (PRD §23.4). */ + /** Optional: guest checkout is supported via the claim flow (PRD §23.4). */ email?: string; - /** Prevents a double-submit from creating two invoices. */ + /** Our user id when signed in. */ + accountId?: string | null; + /** A coin (`USDC_POL`) or `paypal`. Ignored when CoinPay hosts the picker. */ + method?: string; + /** Prevents a double-submit from creating two payments. */ idempotencyKey: string; } +export interface CheckoutResult { + rail: CheckoutRail; + /** `:`, stored on the order and matched by the webhook. */ + ref: string; + /** CoinPay's page the buyer is sent to. */ + paymentUrl: string; + expiresAt?: string; +} + export class CoinpayError extends Error { constructor( message: string, @@ -52,96 +115,275 @@ export class CoinpayError extends Error { } } +/** + * Normalise the configured base to CoinPay's origin. + * + * Vault values come as `https://coinpayportal.com/api`, and this repo once + * shipped `https://api.coinpayportal.com` (a host that does not resolve), so + * both are folded back onto the real origin rather than trusted verbatim. + */ +export function normaliseApiOrigin(base?: string): string { + if (!base) return COINPAY_DEFAULT_ORIGIN; + let url: URL; + try { + url = new URL(base); + } catch { + return COINPAY_DEFAULT_ORIGIN; + } + if (url.hostname === "api.coinpayportal.com") url.hostname = "coinpayportal.com"; + return url.origin; +} + +/** Make a ref from a rail and a CoinPay id. */ +export function coinpayRef(rail: CheckoutRail, id: string): string { + return `${rail}:${id}`; +} + +/** Every ref a webhook for this CoinPay id could match. */ +export function refsForCoinpayId(id: string | undefined | null): string[] { + if (!id) return []; + return [coinpayRef("crypto", id), coinpayRef("paypal", id), coinpayRef("session", id)]; +} + +const SESSION_PROBE_TTL_MS = 10 * 60_000; +const COINS_TTL_MS = 5 * 60_000; + export class CoinpayClient { private readonly fetchImpl: typeof fetch; private readonly timeoutMs: number; + private readonly origin: string; + private sessionsKnown: { available: boolean; at: number } | null = null; + private coinsCache: { coins: string[]; businessId: string | null; at: number } | null = null; constructor(private readonly config: CoinpayConfig) { if (!config.apiKey) throw new Error("COINPAY_API_KEY is required"); this.fetchImpl = config.fetchImpl ?? globalThis.fetch; this.timeoutMs = config.timeoutMs ?? 15_000; + this.origin = normaliseApiOrigin(config.apiBase); + } + + /** What the checkout page should offer. */ + async listMethods(): Promise { + const hosted = await this.sessionsAvailable(); + const { coins } = await this.supportedCoins(); + const methods: CheckoutMethod[] = coins.map((coin) => ({ + id: coin, + label: COIN_LABELS[coin] ?? coin, + rail: "crypto", + })); + methods.push({ id: PAYPAL_METHOD, label: "PayPal", rail: "paypal" }); + return { hosted, methods }; } /** - * Create a hosted invoice for an order. + * Create a CoinPay checkout for an order. * - * The amount comes from the server catalog, never from the caller — this + * The amount comes from the server catalog, never from the caller: this * signature has no `amount` parameter on purpose. */ - async createInvoice(input: CreateInvoiceInput): Promise { + async createCheckout(input: CreateCheckoutInput): Promise { const price = resolvePrice(input.episodeId); if (!price.ok) { throw new CoinpayError(`cannot price episode: ${price.reason}`, 400, price.reason); } - - const body = { - productId: price.entry.coinpayProductId, - referenceId: input.orderId, - amount: price.unitAmount, - currency: price.currency, - description: `NIGHTCELL 7 — ${price.entry.title}`, - customerEmail: input.email, - successUrl: this.config.returnUrl, - cancelUrl: this.config.cancelUrl, - webhookUrl: this.config.webhookUrl, + const amount = price.unitAmount / 100; + const description = `NIGHTCELL 7: ${price.entry.title}`; + const metadata = { + property: PROPERTY, + account_id: input.accountId ?? null, + email: input.email ?? null, + order_id: input.orderId, + episode_id: input.episodeId, }; + const expiresInMinutes = this.config.expiresInMinutes ?? 60; - const json = await this.request("/v1/invoices", { - method: "POST", - body: JSON.stringify(body), - headers: { "idempotency-key": input.idempotencyKey }, - }); + if (await this.sessionsAvailable()) { + const session = await this.tryCreateSession( + { + amount, + currency: price.currency, + description, + customer_email: input.email, + account_id: input.accountId ?? null, + property: PROPERTY, + metadata, + success_url: this.config.returnUrl, + cancel_url: this.config.cancelUrl, + expires_in_minutes: expiresInMinutes, + }, + input.idempotencyKey, + ); + if (session) return session; + } - const parsed = createInvoiceResultSchema.safeParse(json); - if (!parsed.success) { - throw new CoinpayError("unexpected invoice response shape", 502, "bad_response"); + const method = (input.method ?? "").trim(); + if (!method) { + throw new CoinpayError("choose a payment method", 400, "method_required"); } - return parsed.data; - } - /** Poll an invoice — used by reconciliation when a webhook was missed. */ - async getInvoice(invoiceId: string): Promise<{ status: string; amountPaid?: number }> { - const json = (await this.request(`/v1/invoices/${encodeURIComponent(invoiceId)}`, { - method: "GET", - })) as { status?: unknown; amountPaid?: unknown }; + if (method.toLowerCase() === PAYPAL_METHOD) { + const businessId = await this.businessId(); + const json = (await this.request("/api/paypal/payments/create", { + method: "POST", + body: JSON.stringify({ + business_id: businessId, + amount, + currency: price.currency, + description, + metadata, + customer_email: input.email, + invoiceNumber: input.orderId, + success_url: this.config.returnUrl, + cancel_url: this.config.cancelUrl, + }), + headers: { "idempotency-key": input.idempotencyKey }, + })) as { transaction_id?: unknown; checkout_url?: unknown; approve_url?: unknown }; + const id = typeof json.transaction_id === "string" ? json.transaction_id : ""; + const url = + typeof json.checkout_url === "string" + ? json.checkout_url + : typeof json.approve_url === "string" + ? json.approve_url + : ""; + if (!id || !url) + throw new CoinpayError("unexpected PayPal response shape", 502, "bad_response"); + return { rail: "paypal", ref: coinpayRef("paypal", id), paymentUrl: url }; + } - if (typeof json.status !== "string") { - throw new CoinpayError("invoice status missing", 502, "bad_response"); + const coin = method.toUpperCase(); + const { coins } = await this.supportedCoins(); + if (!coins.includes(coin)) { + // A real chain, spelled correctly, that this business has no wallet for + // is refused by CoinPay at the moment the buyer presses Buy. Refuse it + // here instead, with the list that is actually on offer. + throw new CoinpayError(`${coin} is not offered`, 400, "unsupported_method"); } + const json = (await this.request("/api/payments/create", { + method: "POST", + body: JSON.stringify({ + amount, + currency: price.currency, + blockchain: coin, + description, + metadata, + success_url: this.config.returnUrl, + redirect_url: this.config.returnUrl, + cancel_url: this.config.cancelUrl, + expires_in_minutes: expiresInMinutes, + }), + headers: { "idempotency-key": `${input.idempotencyKey}:${coin}` }, + })) as { payment?: { id?: unknown; expires_at?: unknown } }; + const id = typeof json.payment?.id === "string" ? json.payment.id : ""; + if (!id) throw new CoinpayError("unexpected payment response shape", 502, "bad_response"); return { - status: json.status, - amountPaid: typeof json.amountPaid === "number" ? json.amountPaid : undefined, + rail: "crypto", + ref: coinpayRef("crypto", id), + paymentUrl: `${this.origin}/pay/${encodeURIComponent(id)}`, + expiresAt: typeof json.payment?.expires_at === "string" ? json.payment.expires_at : undefined, }; } /** - * Request a provider-side refund. - * - * Manual crypto refunds are deliberately NOT implemented here — PRD §24.7 - * requires dual review and destination verification, which is an operator - * workflow, not an API call. + * Current CoinPay status of a stored ref, for reconciliation when a webhook + * was missed. Only crypto payments have a public status read; other rails + * return null and are left to their webhooks. */ - async refund(paymentId: string, reason: string): Promise<{ status: string }> { - const json = (await this.request(`/v1/payments/${encodeURIComponent(paymentId)}/refund`, { - method: "POST", - body: JSON.stringify({ reason }), - })) as { status?: unknown }; + async getPaymentStatus(ref: string): Promise { + const [rail, id] = splitRef(ref); + if (rail !== "crypto" || !id) return null; + const json = (await this.request(`/api/payments/${encodeURIComponent(id)}`, { + method: "GET", + })) as { payment?: { status?: unknown } }; + return typeof json.payment?.status === "string" ? json.payment.status : null; + } - if (typeof json.status !== "string") { - throw new CoinpayError("refund status missing", 502, "bad_response"); + private async sessionsAvailable(): Promise { + const mode = this.config.sessions ?? "auto"; + if (mode === "off") return false; + if (mode === "on") return true; + const known = this.sessionsKnown; + if (known && Date.now() - known.at < SESSION_PROBE_TTL_MS) return known.available; + // An empty body is refused by validation when the route exists, and 404s + // when it does not; nothing is created either way. + let available = false; + try { + const response = await this.raw("/api/checkout/sessions", { method: "POST", body: "{}" }); + available = response.status !== 404 && response.status !== 405; + } catch { + available = false; } - return { status: json.status }; + this.sessionsKnown = { available, at: Date.now() }; + return available; } - private async request( + private async tryCreateSession( + body: Record, + idempotencyKey: string, + ): Promise { + const response = await this.raw("/api/checkout/sessions", { + method: "POST", + body: JSON.stringify(body), + headers: { "idempotency-key": idempotencyKey }, + }); + if (response.status === 404 || response.status === 405) { + this.sessionsKnown = { available: false, at: Date.now() }; + return null; + } + const json = await readJson(response); + if (!response.ok) { + throw new CoinpayError( + `coinpay checkout session failed (${response.status})${providerError(json)}`, + response.status, + "request_failed", + ); + } + const s = (json as { session?: unknown }).session ?? json; + const id = typeof (s as { id?: unknown }).id === "string" ? (s as { id: string }).id : ""; + const url = typeof (s as { url?: unknown }).url === "string" ? (s as { url: string }).url : ""; + if (!id || !url) + throw new CoinpayError("unexpected session response shape", 502, "bad_response"); + const expiresAt = (s as { expires_at?: unknown }).expires_at; + return { + rail: "session", + ref: coinpayRef("session", id), + paymentUrl: url, + expiresAt: typeof expiresAt === "string" ? expiresAt : undefined, + }; + } + + private async supportedCoins(): Promise<{ coins: string[]; businessId: string | null }> { + const cached = this.coinsCache; + if (cached && Date.now() - cached.at < COINS_TTL_MS) return cached; + const json = (await this.request("/api/supported-coins?active_only=true", { + method: "GET", + })) as { coins?: Array<{ symbol?: unknown; is_active?: unknown }>; business_id?: unknown }; + const coins = (json.coins ?? []) + .filter((c) => c.is_active !== false && typeof c.symbol === "string") + .map((c) => String(c.symbol).toUpperCase()); + const fresh = { + coins: [...new Set(coins)], + businessId: typeof json.business_id === "string" ? json.business_id : null, + at: Date.now(), + }; + this.coinsCache = fresh; + return fresh; + } + + private async businessId(): Promise { + if (this.config.businessId) return this.config.businessId; + const { businessId } = await this.supportedCoins(); + if (!businessId) throw new CoinpayError("CoinPay business id unknown", 502, "no_business"); + return businessId; + } + + private async raw( path: string, init: { method: string; body?: string; headers?: Record }, - ): Promise { + ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); - try { - const response = await this.fetchImpl(`${this.config.apiBase}${path}`, { + return await this.fetchImpl(`${this.origin}${path}`, { method: init.method, headers: { authorization: `Bearer ${this.config.apiKey}`, @@ -152,19 +394,57 @@ export class CoinpayClient { body: init.body, signal: controller.signal, }); - - const text = await response.text(); - if (!response.ok) { - // The provider body may echo payer detail; keep it out of the message. - throw new CoinpayError( - `coinpay request failed (${response.status})`, - response.status, - "request_failed", - ); - } - return text ? JSON.parse(text) : {}; } finally { clearTimeout(timer); } } + + private async request( + path: string, + init: { method: string; body?: string; headers?: Record }, + ): Promise { + const response = await this.raw(path, init); + const json = await readJson(response); + if (!response.ok) { + // CoinPay's `error` string says what was wrong ("No BTC wallet + // configured..."), and carries no payer detail; keep it. + throw new CoinpayError( + `coinpay request failed (${response.status})${providerError(json)}`, + response.status, + "request_failed", + ); + } + return json; + } +} + +function splitRef(ref: string): [string, string] { + const i = ref.indexOf(":"); + return i < 0 ? ["", ref] : [ref.slice(0, i), ref.slice(i + 1)]; +} + +async function readJson(response: Response): Promise { + const text = await response.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return {}; + } +} + +function providerError(json: unknown): string { + const error = (json as { error?: unknown } | null)?.error; + return typeof error === "string" ? `: ${error.slice(0, 200)}` : ""; +} + +/** Where a stored ref can be paid again (a replayed checkout), when known. */ +export function paymentUrlForRef(ref: string | null | undefined, apiBase?: string): string | null { + if (!ref) return null; + const [rail, id] = splitRef(ref); + if (!id) return null; + const origin = normaliseApiOrigin(apiBase); + if (rail === "crypto") return `${origin}/pay/${encodeURIComponent(id)}`; + if (rail === "paypal") return `${origin}/pay/paypal/${encodeURIComponent(id)}`; + return null; } diff --git a/packages/coinpay/src/commerce.test.ts b/packages/coinpay/src/commerce.test.ts index b620c39..0549d8b 100644 --- a/packages/coinpay/src/commerce.test.ts +++ b/packages/coinpay/src/commerce.test.ts @@ -17,8 +17,11 @@ import { } from "@nightcell7/entitlements"; import { EPISODE } from "@nightcell7/game-core"; import { + CoinpayClient, WEBHOOK_TOLERANCE_SECONDS, + mapEventStatus, mapProviderStatus, + normaliseApiOrigin, redactEventForLog, signWebhookPayload, verifyWebhook, @@ -27,22 +30,24 @@ import { const SECRET = "whsec_test_0123456789abcdef"; function makeWebhook(overrides: Record = {}, atSeconds = 1_700_000_000) { + // CoinPay's wire shape (coinpayportal src/lib/webhooks/service.ts). const body = JSON.stringify({ - id: "evt_1", - type: "payment.completed", - createdAt: new Date(atSeconds * 1000).toISOString(), + id: "evt_pay_1_1700000000", + type: "payment.forwarded", data: { - referenceId: "ord_1", - paymentId: "pay_1", - status: "paid", - currency: "USD", - amount: 999, + payment_id: "pay_1", + status: "forwarded", + currency: "USDC_POL", + amount: 9.99, + amount_usd: 9.99, + metadata: { order_id: "ord_1", property: "nightcell7.com", account_id: "u1" }, }, + created_at: new Date(atSeconds * 1000).toISOString(), + business_id: "biz_1", ...overrides, }); return { rawBody: body, - timestamp: String(atSeconds), signature: signWebhookPayload(body, atSeconds, SECRET), secret: SECRET, nowSeconds: atSeconds, @@ -202,13 +207,15 @@ describe("coinpay webhooks", () => { const result = verifyWebhook(makeWebhook()); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.event.id).toBe("evt_1"); + expect(result.event.id).toBe("evt_pay_1_1700000000"); + expect(result.event.data.paymentId).toBe("pay_1"); expect(result.event.data.referenceId).toBe("ord_1"); + expect(result.event.data.property).toBe("nightcell7.com"); }); it("rejects a tampered body even with a valid-looking signature", () => { const input = makeWebhook(); - const tampered = { ...input, rawBody: input.rawBody.replace('"amount":999', '"amount":1') }; + const tampered = { ...input, rawBody: input.rawBody.replace('"amount":9.99', '"amount":1') }; expect(verifyWebhook(tampered)).toEqual({ ok: false, reason: "bad_signature" }); }); @@ -225,7 +232,8 @@ describe("coinpay webhooks", () => { ok: false, reason: "missing_signature", }); - expect(verifyWebhook({ ...input, timestamp: null })).toEqual({ + const noTimestamp = input.signature.replace(/^t=\d+,/, ""); + expect(verifyWebhook({ ...input, signature: noTimestamp })).toEqual({ ok: false, reason: "missing_timestamp", }); @@ -254,10 +262,10 @@ describe("coinpay webhooks", () => { const verified = verifyWebhook( makeWebhook({ data: { - referenceId: "ord_1", - paymentId: "pay_1", - status: "paid", - payerEmail: "buyer@example.com", + payment_id: "pay_1", + status: "completed", + payer_email: "buyer@example.com", + metadata: { order_id: "ord_1", email: "buyer@example.com" }, }, }), ); @@ -268,11 +276,207 @@ describe("coinpay webhooks", () => { expect(logged).toContain("ord_1"); }); + it("reads the payment id from data, never the top-level event id", () => { + const verified = verifyWebhook( + makeWebhook({ + type: "payment.confirmed", + data: { payment_id: "pay_9", status: "confirmed" }, + }), + ); + if (!verified.ok) throw new Error("expected a verified event"); + expect(verified.event.data.paymentId).toBe("pay_9"); + expect(verified.event.data.referenceId).toBeUndefined(); + expect(mapEventStatus(verified.event)).toBe(ORDER_STATUS.PAID); + }); + + it("maps CoinPay event types, keeping under- and overpayment", () => { + const base = { id: "e", createdAt: "", data: { paymentId: "p", status: "" } }; + expect(mapEventStatus({ ...base, type: "payment.expired" })).toBe(ORDER_STATUS.EXPIRED); + expect(mapEventStatus({ ...base, type: "payment.disputed" })).toBe(ORDER_STATUS.DISPUTED); + expect( + mapEventStatus({ + ...base, + type: "payment.confirmed", + data: { paymentId: "p", status: "underpaid" }, + }), + ).toBe(ORDER_STATUS.UNDERPAID); + }); +}); + +describe("coinpay client", () => { + type Call = { url: string; init: RequestInit }; + function fakeFetch(routes: Record [number, unknown]>, calls: Call[]) { + return (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const path = new URL(url).pathname; + const handler = routes[path]; + const [status, json] = handler + ? handler(init.body ? JSON.parse(String(init.body)) : null) + : [404, {}]; + return new Response(JSON.stringify(json), { status }); + }) as unknown as typeof fetch; + } + const coins = (): [number, unknown] => [ + 200, + { + business_id: "biz_1", + coins: [ + { symbol: "BTC", is_active: true }, + { symbol: "USDC_POL", is_active: true }, + ], + }, + ]; + + it("folds the dead api.coinpayportal.com host and an /api suffix onto the origin", () => { + expect(normaliseApiOrigin("https://api.coinpayportal.com")).toBe("https://coinpayportal.com"); + expect(normaliseApiOrigin("https://coinpayportal.com/api")).toBe("https://coinpayportal.com"); + }); + + it("falls back to a coin picker plus PayPal while hosted sessions 404", async () => { + const calls: Call[] = []; + const client = new CoinpayClient({ + apiKey: "cp_live_test", + returnUrl: "https://nightcell7.com/checkout/success", + cancelUrl: "https://nightcell7.com/checkout/cancel", + fetchImpl: fakeFetch({ "/api/supported-coins": coins }, calls), + }); + const listed = await client.listMethods(); + expect(listed.hosted).toBe(false); + expect(listed.methods.map((m) => m.id)).toEqual(["BTC", "USDC_POL", "paypal"]); + }); + + it("creates a crypto payment with property, account and email metadata", async () => { + const calls: Call[] = []; + let sent: Record = {}; + const client = new CoinpayClient({ + apiBase: "https://api.coinpayportal.com", + apiKey: "cp_live_test", + returnUrl: "https://nightcell7.com/checkout/success", + cancelUrl: "https://nightcell7.com/checkout/cancel", + fetchImpl: fakeFetch( + { + "/api/supported-coins": coins, + "/api/payments/create": (body) => { + sent = body as Record; + return [201, { success: true, payment: { id: "pay_1" } }]; + }, + }, + calls, + ), + }); + const result = await client.createCheckout({ + orderId: "ord_1", + episodeId: EPISODE.FALSE_DAWN, + email: "buyer@example.com", + accountId: "u1", + method: "usdc_pol", + idempotencyKey: "idem-1", + }); + expect(result).toMatchObject({ + rail: "crypto", + ref: "crypto:pay_1", + paymentUrl: "https://coinpayportal.com/pay/pay_1", + }); + expect(sent).toMatchObject({ amount: 9.99, blockchain: "USDC_POL", currency: "USD" }); + expect(sent.metadata).toMatchObject({ + property: "nightcell7.com", + account_id: "u1", + email: "buyer@example.com", + order_id: "ord_1", + }); + expect(calls.every((c) => c.url.startsWith("https://coinpayportal.com/"))).toBe(true); + }); + + it("refuses a coin the business has no wallet for before CoinPay does", async () => { + const client = new CoinpayClient({ + apiKey: "cp_live_test", + returnUrl: "r", + cancelUrl: "c", + fetchImpl: fakeFetch({ "/api/supported-coins": coins }, []), + }); + await expect( + client.createCheckout({ + orderId: "o", + episodeId: EPISODE.FALSE_DAWN, + method: "BASE", + idempotencyKey: "idem-2", + }), + ).rejects.toMatchObject({ code: "unsupported_method" }); + }); + + it("opens a PayPal order on the business", async () => { + let sent: Record = {}; + const client = new CoinpayClient({ + apiKey: "cp_live_test", + returnUrl: "r", + cancelUrl: "c", + fetchImpl: fakeFetch( + { + "/api/supported-coins": coins, + "/api/paypal/payments/create": (body) => { + sent = body as Record; + return [ + 200, + { transaction_id: "tx_1", checkout_url: "https://coinpayportal.com/pay/paypal/tx_1" }, + ]; + }, + }, + [], + ), + }); + const result = await client.createCheckout({ + orderId: "o", + episodeId: EPISODE.FALSE_DAWN, + method: "paypal", + idempotencyKey: "idem-3", + }); + expect(result).toMatchObject({ rail: "paypal", ref: "paypal:tx_1" }); + expect(sent).toMatchObject({ business_id: "biz_1", amount: 9.99 }); + }); + + it("uses a hosted checkout session once CoinPay has one", async () => { + let sent: Record = {}; + const client = new CoinpayClient({ + apiKey: "cp_live_test", + returnUrl: "r", + cancelUrl: "c", + fetchImpl: fakeFetch( + { + "/api/checkout/sessions": (body) => { + if (!body || Object.keys(body as object).length === 0) + return [400, { error: "amount is required" }]; + sent = body as Record; + return [201, { id: "cs_1", url: "https://coinpayportal.com/checkout/cs_1" }]; + }, + }, + [], + ), + }); + const result = await client.createCheckout({ + orderId: "o", + episodeId: EPISODE.FALSE_DAWN, + accountId: "u1", + idempotencyKey: "idem-4", + }); + expect(result).toMatchObject({ + rail: "session", + ref: "session:cs_1", + paymentUrl: "https://coinpayportal.com/checkout/cs_1", + }); + expect(sent).toMatchObject({ + amount: 9.99, + currency: "USD", + account_id: "u1", + property: "nightcell7.com", + expires_in_minutes: 60, + }); + }); + it("drives a full webhook -> order -> entitlement flow exactly once", () => { const verified = verifyWebhook(makeWebhook()); if (!verified.ok) throw new Error("expected a verified event"); - const mapped = mapProviderStatus(verified.event.data.status); + const mapped = mapEventStatus(verified.event); const toPaid = transition(ORDER_STATUS.PENDING, mapped); expect(toPaid).toEqual({ ok: true, status: ORDER_STATUS.PAID, changed: true }); @@ -285,7 +489,7 @@ describe("coinpay webhooks", () => { const grant = applyOrderToEntitlement({ userId: "u1", episodeId: EPISODE.FALSE_DAWN, - orderId: verified.event.data.referenceId, + orderId: verified.event.data.referenceId ?? "ord_1", orderStatus: ORDER_STATUS.FULFILLED, now: "2026-07-25T00:00:00.000Z", }); diff --git a/packages/coinpay/src/webhook.ts b/packages/coinpay/src/webhook.ts index 0b9c029..85b2ee7 100644 --- a/packages/coinpay/src/webhook.ts +++ b/packages/coinpay/src/webhook.ts @@ -1,5 +1,4 @@ import { createHmac, timingSafeEqual } from "node:crypto"; -import { z } from "zod"; import { ORDER_STATUS, type OrderStatus } from "@nightcell7/entitlements"; /** @@ -9,37 +8,48 @@ import { ORDER_STATUS, type OrderStatus } from "@nightcell7/entitlements"; * - the signature is verified against the RAW body, before any JSON parsing; * - stale events are rejected so a captured webhook cannot be replayed later; * - the provider event id is the idempotency key; - * - the browser return URL never unlocks anything — only a verified event + * - the browser return URL never unlocks anything; only a verified event * moves an order forward. + * + * The wire contract is CoinPay's (src/lib/webhooks/service.ts there): + * + * X-CoinPay-Signature: t=,v1=."> + * { "id": "evt__", "type": "payment.confirmed", + * "data": { "payment_id", "status", "amount", "metadata"? ... }, + * "created_at", "business_id" } + * + * The top-level `id` is the EVENT, not the payment. Hosted checkout sessions + * add `checkout_session_id`, `account_id`, `property`, `customer_email` and + * `metadata`, at the top level or inside `data`; both are read. */ export const COINPAY_SIGNATURE_HEADER = "x-coinpay-signature"; +/** Legacy: CoinPay puts the timestamp inside the signature header. */ export const COINPAY_TIMESTAMP_HEADER = "x-coinpay-timestamp"; /** Events older than this are rejected as replays. */ export const WEBHOOK_TOLERANCE_SECONDS = 300; -export const coinpayEventSchema = z.object({ - /** Provider-unique event id — stored under a unique constraint. */ - id: z.string().min(1).max(128), - type: z.string().min(1).max(64), - createdAt: z.string(), - data: z.object({ - /** Our internal order id, echoed back by the provider. */ - referenceId: z.string().min(1).max(128), - paymentId: z.string().min(1).max(128).optional(), - invoiceId: z.string().min(1).max(128).optional(), - status: z.string().min(1).max(64), - currency: z.string().min(3).max(8).optional(), - /** Minor units of the fiat amount the buyer was quoted. */ - amount: z.number().int().nonnegative().optional(), - amountPaid: z.number().int().nonnegative().optional(), - asset: z.string().max(32).optional(), - payerEmail: z.string().email().max(320).optional(), - }), -}); - -export type CoinpayEvent = z.infer; +export interface CoinpayEvent { + /** Provider-unique event id; stored under a unique constraint. */ + id: string; + type: string; + createdAt: string; + data: { + /** CoinPay payment (or PayPal transaction) id. */ + paymentId?: string; + checkoutSessionId?: string; + /** Our order id, from metadata.order_id, when CoinPay echoed metadata. */ + referenceId?: string; + accountId?: string; + property?: string; + status: string; + currency?: string; + /** USD major units, as CoinPay reports it. */ + amount?: number; + payerEmail?: string; + }; +} export type VerificationFailure = | "missing_signature" @@ -56,8 +66,10 @@ export type WebhookVerification = export interface VerifyWebhookInput { /** The exact bytes received. Never a re-serialised object. */ rawBody: string; + /** `t=,v1=` as CoinPay sends it. */ signature: string | null | undefined; - timestamp: string | null | undefined; + /** Only for a bare-hex signature; CoinPay itself never sends this. */ + timestamp?: string | null | undefined; secret: string; nowSeconds: number; } @@ -66,29 +78,40 @@ export interface VerifyWebhookInput { * Verify and parse a CoinPay webhook. * * The signed payload is `${timestamp}.${rawBody}`; binding the timestamp into - * the signature is what makes the freshness check meaningful — otherwise an - * attacker could replay an old body with a fresh header. + * the signature is what makes the freshness check meaningful. */ export function verifyWebhook(input: VerifyWebhookInput): WebhookVerification { if (!input.signature) return { ok: false, reason: "missing_signature" }; - if (!input.timestamp) return { ok: false, reason: "missing_timestamp" }; - const timestamp = Number(input.timestamp); + let ts: string | undefined; + const provided: string[] = []; + if (input.signature.includes("=")) { + for (const part of input.signature.split(",")) { + const [k, v] = part.split("=", 2).map((s) => s?.trim()); + if (k === "t") ts = v; + else if (k === "v1" && v) provided.push(v.toLowerCase()); + } + } else { + ts = input.timestamp ?? undefined; + provided.push(input.signature.trim().toLowerCase()); + } + if (!ts) return { ok: false, reason: "missing_timestamp" }; + if (provided.length === 0) return { ok: false, reason: "missing_signature" }; + + const timestamp = Number(ts); if (!Number.isFinite(timestamp)) return { ok: false, reason: "bad_timestamp" }; if (Math.abs(input.nowSeconds - timestamp) > WEBHOOK_TOLERANCE_SECONDS) { return { ok: false, reason: "stale_event" }; } - const expected = createHmac("sha256", input.secret) - .update(`${input.timestamp}.${input.rawBody}`) - .digest("hex"); - - const provided = input.signature.trim().toLowerCase(); - const a = Buffer.from(provided); - const b = Buffer.from(expected); - if (a.length !== b.length || !timingSafeEqual(a, b)) { - return { ok: false, reason: "bad_signature" }; - } + const expected = Buffer.from( + createHmac("sha256", input.secret).update(`${ts}.${input.rawBody}`).digest("hex"), + ); + const matched = provided.some((sig) => { + const a = Buffer.from(sig); + return a.length === expected.length && timingSafeEqual(a, expected); + }); + if (!matched) return { ok: false, reason: "bad_signature" }; let parsed: unknown; try { @@ -97,15 +120,66 @@ export function verifyWebhook(input: VerifyWebhookInput): WebhookVerification { return { ok: false, reason: "malformed_body" }; } - const event = coinpayEventSchema.safeParse(parsed); - if (!event.success) return { ok: false, reason: "invalid_event" }; + const event = parseEvent(parsed); + if (!event) return { ok: false, reason: "invalid_event" }; + return { ok: true, event }; +} + +function str(value: unknown, max = 320): string | undefined { + return typeof value === "string" && value.length > 0 && value.length <= max ? value : undefined; +} + +function num(value: unknown): number | undefined { + const n = typeof value === "string" ? Number(value) : value; + return typeof n === "number" && Number.isFinite(n) ? n : undefined; +} - return { ok: true, event: event.data }; +function obj(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; } -/** Test/dev helper for producing a valid signature. Never used in production. */ +/** Read CoinPay's nested shape, falling back to the flat test-webhook shape. */ +export function parseEvent(payload: unknown): CoinpayEvent | null { + const top = obj(payload); + const hasData = top.data && typeof top.data === "object"; + const d = hasData ? obj(top.data) : top; + const meta = { ...obj(top.metadata), ...obj(d.metadata) }; + + const id = str(top.id, 128); + const type = str(top.type, 64) ?? str(top.event, 64); + if (!id || !type) return null; + + // Read the payment id from `data` first: the top-level id is the event. + const paymentId = str(d.payment_id, 128) ?? (hasData ? str(d.id, 128) : undefined); + const checkoutSessionId = str(d.checkout_session_id, 128) ?? str(top.checkout_session_id, 128); + const referenceId = str(meta.order_id, 128) ?? str(meta.orderId, 128); + if (!paymentId && !checkoutSessionId && !referenceId) return null; + + return { + id, + type, + createdAt: str(top.created_at, 64) ?? str(top.createdAt, 64) ?? new Date().toISOString(), + data: { + paymentId, + checkoutSessionId, + referenceId, + accountId: str(top.account_id, 128) ?? str(d.account_id, 128) ?? str(meta.account_id, 128), + property: str(top.property, 128) ?? str(d.property, 128) ?? str(meta.property, 128), + status: str(d.status, 64) ?? "", + currency: str(d.currency, 16), + amount: num(d.amount_usd) ?? num(d.amount), + payerEmail: + str(top.customer_email) ?? str(d.customer_email) ?? str(d.payer_email) ?? str(meta.email), + }, + }; +} + +/** Test/dev helper producing CoinPay's `t=..,v1=..` header. Never used in production. */ export function signWebhookPayload(rawBody: string, timestamp: number, secret: string): string { - return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); + const v1 = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); + return `t=${timestamp},v1=${v1}`; } /** @@ -125,11 +199,15 @@ export function mapProviderStatus(providerStatus: string): OrderStatus { return ORDER_STATUS.PENDING; case "confirming": case "unconfirmed": + case "detected": return ORDER_STATUS.CONFIRMING; case "paid": case "complete": case "completed": case "confirmed": + case "forwarded": + case "forwarding": + case "captured": return ORDER_STATUS.PAID; case "underpaid": return ORDER_STATUS.UNDERPAID; @@ -139,6 +217,7 @@ export function mapProviderStatus(providerStatus: string): OrderStatus { return ORDER_STATUS.EXPIRED; case "canceled": case "cancelled": + case "failed": return ORDER_STATUS.CANCELED; case "refunded": return ORDER_STATUS.REFUNDED; @@ -152,6 +231,37 @@ export function mapProviderStatus(providerStatus: string): OrderStatus { } } +/** + * Event -> internal order status. The event type is CoinPay's statement of + * what happened; `data.status` refines it (an under- or overpayment still + * arrives as `payment.confirmed`). + */ +export function mapEventStatus(event: CoinpayEvent): OrderStatus { + const fromStatus = event.data.status ? mapProviderStatus(event.data.status) : null; + switch (event.type.toLowerCase()) { + case "payment.confirmed": + case "payment.forwarded": + case "payment.completed": + case "checkout.session.completed": + case "checkout_session.completed": + return fromStatus === ORDER_STATUS.UNDERPAID || fromStatus === ORDER_STATUS.OVERPAID + ? fromStatus + : ORDER_STATUS.PAID; + case "payment.expired": + case "checkout.session.expired": + case "checkout_session.expired": + return ORDER_STATUS.EXPIRED; + case "payment.failed": + return ORDER_STATUS.CANCELED; + case "payment.disputed": + return ORDER_STATUS.DISPUTED; + case "payment.dispute_resolved": + return ORDER_STATUS.MANUAL_REVIEW; + default: + return fromStatus ?? ORDER_STATUS.MANUAL_REVIEW; + } +} + /** * Redact provider payloads before logging (PRD §24.4, §33.2). * Payer identifiers and any key-like field must not reach the log stream. @@ -164,10 +274,12 @@ export function redactEventForLog(event: CoinpayEvent): Record data: { referenceId: event.data.referenceId, paymentId: event.data.paymentId, + checkoutSessionId: event.data.checkoutSessionId, + property: event.data.property, status: event.data.status, currency: event.data.currency, amount: event.data.amount, - // Deliberately omitted: payerEmail, asset-level payer details. + // Deliberately omitted: payerEmail, account id. }, }; } diff --git a/services/api/src/app.test.ts b/services/api/src/app.test.ts index ca5a874..2bf82af 100644 --- a/services/api/src/app.test.ts +++ b/services/api/src/app.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it, beforeEach } from "vitest"; import { HealthReporter, createLogger } from "@nightcell7/observability"; import { SESSION_COOKIE_NAME } from "@nightcell7/auth"; -import { - COINPAY_SIGNATURE_HEADER, - COINPAY_TIMESTAMP_HEADER, - signWebhookPayload, -} from "@nightcell7/coinpay"; +import { COINPAY_SIGNATURE_HEADER, signWebhookPayload } from "@nightcell7/coinpay"; import { CONTENT_VERSION, PROTOCOL_VERSION } from "@nightcell7/multiplayer-protocol"; import { verifyTicket } from "@nightcell7/multiplayer-protocol/server"; import { createApp, buildSyncUrl, generatePrivateCode, readCookie } from "./app"; @@ -31,6 +27,7 @@ const env: ApiEnv = { REDIS_URL: "redis://localhost:6379", DATABASE_URL: "postgres://localhost:5432/nightcell7_test", COINPAY_API_BASE: "https://api.coinpayportal.test", + COINPAY_CHECKOUT_SESSIONS: "off", COINPAY_API_KEY: "key", COINPAY_WEBHOOK_SECRET: WEBHOOK_SECRET, R2_BUCKET: "nightcell7-content-test", @@ -65,6 +62,10 @@ function fakeRepos(overrides: Partial = {}): Repositories { async findOrderByIdempotencyKey() { return null; }, + async findOrderIdForCoinpay({ refs, orderId }) { + if (refs.includes("crypto:pay_1")) return "ord_1"; + return orderId === "ord_meta" ? "ord_meta" : null; + }, async createOrder() {}, async attachCoinpayInvoice() {}, async listOrders() { @@ -114,6 +115,7 @@ interface Harness { app: ReturnType; enqueued: { queue: string; name: string; payload: unknown }[]; invoices: number; + lastMethod?: string; tickets: string[]; } @@ -123,19 +125,26 @@ function harness( ): Harness { const enqueued: Harness["enqueued"] = []; const tickets: string[] = []; - const state = { invoices: 0 }; + const state: { invoices: number; lastMethod?: string } = { invoices: 0 }; const coinpay = { - createInvoice: async () => { + createCheckout: async (input: { method?: string }) => { state.invoices += 1; + state.lastMethod = input.method; return { - invoiceId: `inv_${state.invoices}`, - paymentUrl: "https://pay.coinpayportal.test/inv_1", - status: "pending", + rail: "crypto", + ref: `crypto:pay_${state.invoices}`, + paymentUrl: `https://coinpayportal.com/pay/pay_${state.invoices}`, }; }, - getInvoice: async () => ({ status: "pending" }), - refund: async () => ({ status: "refunded" }), + listMethods: async () => ({ + hosted: false, + methods: [ + { id: "BTC", label: "Bitcoin", rail: "crypto" }, + { id: "paypal", label: "PayPal", rail: "paypal" }, + ], + }), + getPaymentStatus: async () => "pending", } as unknown as Dependencies["coinpay"]; const app = createApp({ @@ -177,6 +186,9 @@ function harness( get invoices() { return state.invoices; }, + get lastMethod() { + return state.lastMethod; + }, }; } @@ -257,10 +269,29 @@ describe("checkout", () => { expect(h.invoices).toBe(1); }); + it("passes the chosen coin or PayPal through to CoinPay", async () => { + const res = await h.app.request( + "/api/v1/checkout/coinpay", + jsonPost( + { episodeId: "false-dawn", idempotencyKey: "idem-key-9", method: "paypal" }, + "verified", + ), + ); + expect(res.status).toBe(200); + expect(h.lastMethod).toBe("paypal"); + }); + + it("lists every coin the business takes plus PayPal", async () => { + const res = await h.app.request("/api/v1/checkout/coinpay/methods"); + expect(res.status).toBe(200); + const body = (await res.json()) as { hosted: boolean; methods: { id: string }[] }; + expect(body.methods.map((m) => m.id)).toEqual(["BTC", "paypal"]); + }); + it("replays an existing order for a repeated idempotency key", async () => { const replayHarness = harness({ async findOrderByIdempotencyKey() { - return { id: "ord_existing", status: "pending" }; + return { id: "ord_existing", status: "pending", coinpayRef: null }; }, }); const res = await replayHarness.app.request( @@ -300,18 +331,21 @@ describe("coinpay webhook", () => { eventId: string, secret = WEBHOOK_SECRET, at = Math.floor(Date.now() / 1000), + paymentId = "pay_1", ) { + // CoinPay's real shape: nested `data`, top-level id is the EVENT, and a + // crypto confirmation carries no metadata, only the payment id. const body = JSON.stringify({ id: eventId, - type: "payment.completed", - createdAt: new Date(at * 1000).toISOString(), - data: { referenceId: "ord_1", paymentId: "pay_1", status: "paid" }, + type: "payment.confirmed", + data: { payment_id: paymentId, status: "confirmed", amount: 9.99 }, + created_at: new Date(at * 1000).toISOString(), + business_id: "biz_1", }); return new Request("https://nightcell7.com/api/v1/webhooks/coinpay", { method: "POST", headers: { "content-type": "application/json", - [COINPAY_TIMESTAMP_HEADER]: String(at), [COINPAY_SIGNATURE_HEADER]: signWebhookPayload(body, at, secret), }, body, @@ -325,7 +359,21 @@ describe("coinpay webhook", () => { const job = h.enqueued.find((e) => e.queue === "payments"); expect(job).toBeDefined(); - expect(job?.payload).toMatchObject({ providerEventId: "evt_a", mappedStatus: "paid" }); + expect(job?.payload).toMatchObject({ + providerEventId: "evt_a", + orderId: "ord_1", + mappedStatus: "paid", + paymentId: "pay_1", + }); + }); + + it("acknowledges an event for an order that is not ours without queueing", async () => { + const res = await h.app.fetch( + webhookRequest("evt_d", WEBHOOK_SECRET, Math.floor(Date.now() / 1000), "pay_other"), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ received: true, unknownOrder: true }); + expect(h.enqueued).toHaveLength(0); }); it("treats a replayed provider event as a no-op", async () => { diff --git a/services/api/src/app.ts b/services/api/src/app.ts index bf521d1..98920b0 100644 --- a/services/api/src/app.ts +++ b/services/api/src/app.ts @@ -21,8 +21,11 @@ import { signManifest, type ContentManifest, type ObjectSigner } from "@nightcel import { COINPAY_SIGNATURE_HEADER, COINPAY_TIMESTAMP_HEADER, - mapProviderStatus, + CoinpayError, + mapEventStatus, + paymentUrlForRef, redactEventForLog, + refsForCoinpayId, verifyWebhook, type CoinpayClient, } from "@nightcell7/coinpay"; @@ -251,6 +254,22 @@ export function createApp(deps: Dependencies) { episodeId: z.string().min(1).max(64), email: z.string().email().optional(), idempotencyKey: z.string().min(8).max(128), + /** A CoinPay coin (`USDC_POL`) or `paypal`; unused when CoinPay hosts the picker. */ + method: z.string().min(2).max(32).optional(), + }); + + /** + * What the checkout page offers: every coin the CoinPay business holds a + * wallet for, plus PayPal. `hosted` means CoinPay shows that picker itself + * (checkout sessions), so the page needs only one button. + */ + v1.get("/checkout/coinpay/methods", async (c) => { + try { + return c.json(await deps.coinpay.listMethods()); + } catch (error) { + deps.logger.error("coinpay methods lookup failed", { error: String(error) }); + throw new ApiError(502, "payments_unavailable", "Payments are unavailable right now."); + } }); v1.post("/checkout/coinpay", async (c) => { @@ -258,7 +277,7 @@ export function createApp(deps: Dependencies) { const body = checkoutSchema.parse(await c.req.json()); await limit("checkout", account?.userId ?? clientKey(c)); - // The server catalog is the price authority — note the absence of any + // The server catalog is the price authority; note the absence of any // client-supplied amount (PRD §24.1). const price = resolvePrice(body.episodeId); if (!price.ok) throw badRequest(price.reason, "That episode cannot be purchased right now."); @@ -272,7 +291,12 @@ export function createApp(deps: Dependencies) { const existing = await deps.repos.findOrderByIdempotencyKey(body.idempotencyKey); if (existing) { - return c.json({ orderId: existing.id, status: existing.status, replayed: true }); + return c.json({ + orderId: existing.id, + status: existing.status, + replayed: true, + paymentUrl: paymentUrlForRef(existing.coinpayRef, deps.env.COINPAY_API_BASE) ?? undefined, + }); } const orderId = `ord_${randomUUID()}`; @@ -286,27 +310,57 @@ export function createApp(deps: Dependencies) { idempotencyKey: body.idempotencyKey, }); - const invoice = await deps.coinpay.createInvoice({ - orderId, - episodeId: body.episodeId, - email: body.email, - idempotencyKey: body.idempotencyKey, - }); - await deps.repos.attachCoinpayInvoice(orderId, invoice.invoiceId); + let checkout; + try { + checkout = await deps.coinpay.createCheckout({ + orderId, + episodeId: body.episodeId, + email: body.email, + accountId: account?.userId ?? null, + method: body.method, + idempotencyKey: body.idempotencyKey, + }); + } catch (error) { + deps.logger.warn("coinpay checkout refused", { + correlationId: c.get("correlationId"), + orderId, + method: body.method, + error: String(error), + }); + if (error instanceof CoinpayError && error.status && error.status < 500) { + const code = + error.code === "method_required" || error.code === "unsupported_method" + ? error.code + : "payment_refused"; + throw new ApiError( + 400, + code, + error.message.replace(/^coinpay request failed \(\d+\): ?/, "") || + "That payment method is unavailable.", + ); + } + throw new ApiError(502, "payments_unavailable", "Payments are unavailable right now."); + } + // The CoinPay reference is stored because not every webhook echoes our + // metadata back: a crypto confirmation and a PayPal capture carry only + // their payment id. + await deps.repos.attachCoinpayInvoice(orderId, checkout.ref); deps.logger.info("checkout created", { correlationId: c.get("correlationId"), orderId, episodeId: body.episodeId, + rail: checkout.rail, }); return c.json({ orderId, // The buyer goes to CoinPay. The return page shows *pending* only; the // webhook is the sole fulfilment trigger (PRD §24.2). - paymentUrl: invoice.paymentUrl, + paymentUrl: checkout.paymentUrl, + rail: checkout.rail, status: "pending", - expiresAt: invoice.expiresAt, + expiresAt: checkout.expiresAt, }); }); @@ -361,13 +415,23 @@ export function createApp(deps: Dependencies) { const event = verification.event; + // Map the event to our order: by the CoinPay reference stored at checkout + // first (every rail sends its payment id), then by metadata.order_id. + const orderId = await deps.repos.findOrderIdForCoinpay({ + refs: [ + ...refsForCoinpayId(event.data.paymentId), + ...refsForCoinpayId(event.data.checkoutSessionId), + ], + orderId: event.data.referenceId, + }); + // Durably record the event BEFORE acknowledging, under a unique constraint // on the provider event id. A replay is a no-op, not a second fulfilment. const recorded = await deps.repos.recordPaymentEvent({ id: `pe_${randomUUID()}`, providerEventId: event.id, eventType: event.type, - orderId: event.data.referenceId, + orderId, payloadHash: createHash("sha256").update(rawBody).digest("hex"), receivedAt: now().toISOString(), }); @@ -377,11 +441,18 @@ export function createApp(deps: Dependencies) { return c.json({ received: true, duplicate: true }); } + if (!orderId) { + // Not ours (another property on the same business, or a test event): + // acknowledge so CoinPay stops retrying, and leave a trace. + deps.logger.warn("coinpay webhook for unknown order", redactEventForLog(event)); + return c.json({ received: true, unknownOrder: true }); + } + // Heavy work is queued so the acknowledgement stays fast (PRD §30.5). await deps.enqueue("payments", "coinpay-event", { providerEventId: event.id, - orderId: event.data.referenceId, - mappedStatus: mapProviderStatus(event.data.status), + orderId, + mappedStatus: mapEventStatus(event), paymentId: event.data.paymentId, correlationId: c.get("correlationId"), }); diff --git a/services/api/src/env.ts b/services/api/src/env.ts index 54fc374..1cd4700 100644 --- a/services/api/src/env.ts +++ b/services/api/src/env.ts @@ -19,9 +19,13 @@ export const apiEnvSchema = baseEnvSchema.extend({ /** postgres:// connection string; the database package rejects anything else. */ DATABASE_URL: z.string().min(1), - COINPAY_API_BASE: z.string().url(), + /** CoinPay origin; `/api` suffix and the old api.coinpayportal.com are normalised. */ + COINPAY_API_BASE: z.string().url().default("https://coinpayportal.com"), COINPAY_API_KEY: z.string().min(1), COINPAY_MERCHANT_ID: z.string().optional(), + COINPAY_BUSINESS_ID: z.string().optional(), + /** Hosted checkout sessions: auto feature-detects, on/off force it. */ + COINPAY_CHECKOUT_SESSIONS: z.enum(["auto", "on", "off"]).default("auto"), COINPAY_WEBHOOK_SECRET: z.string().min(16), // R2 is optional at boot so local development and CI do not need object diff --git a/services/api/src/index.ts b/services/api/src/index.ts index ebd8a87..48fab70 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -51,7 +51,8 @@ const coinpay = new CoinpayClient({ apiKey: env.COINPAY_API_KEY, returnUrl: `${env.PUBLIC_ORIGIN}/checkout/success`, cancelUrl: `${env.PUBLIC_ORIGIN}/checkout/cancel`, - webhookUrl: `${env.PUBLIC_ORIGIN}/api/v1/webhooks/coinpay`, + businessId: env.COINPAY_BUSINESS_ID ?? env.COINPAY_MERCHANT_ID, + sessions: env.COINPAY_CHECKOUT_SESSIONS, }); /** R2 is optional: unset credentials degrade to an obvious placeholder. */ diff --git a/services/api/src/repository.ts b/services/api/src/repository.ts index ba4a695..8f2f8f5 100644 --- a/services/api/src/repository.ts +++ b/services/api/src/repository.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; import type { Database } from "@nightcell7/database"; import { devices, @@ -37,7 +37,11 @@ export interface Repositories { ): Promise<{ episodeId: string; status: string; grantedAt: string }[]>; findEntitlement(userId: string, episodeId: string): Promise<{ status: string } | null>; - findOrderByIdempotencyKey(key: string): Promise<{ id: string; status: string } | null>; + findOrderByIdempotencyKey( + key: string, + ): Promise<{ id: string; status: string; coinpayRef: string | null } | null>; + /** Resolve a CoinPay webhook to our order id: stored refs first, then metadata. */ + findOrderIdForCoinpay(input: { refs: string[]; orderId?: string }): Promise; createOrder(input: CreateOrderInput): Promise; attachCoinpayInvoice(orderId: string, invoiceId: string): Promise; listOrders(userId: string): Promise; @@ -231,13 +235,33 @@ export function createRepositories(db: Database): Repositories { async findOrderByIdempotencyKey(key) { const rows = await db - .select({ id: orders.id, status: orders.status }) + .select({ id: orders.id, status: orders.status, coinpayRef: orders.coinpayOrderId }) .from(orders) .where(eq(orders.idempotencyKey, key)) .limit(1); return rows[0] ?? null; }, + async findOrderIdForCoinpay({ refs, orderId }) { + if (refs.length > 0) { + const rows = await db + .select({ id: orders.id }) + .from(orders) + .where(inArray(orders.coinpayOrderId, refs)) + .limit(1); + if (rows[0]) return rows[0].id; + } + if (orderId) { + const rows = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + if (rows[0]) return rows[0].id; + } + return null; + }, + async createOrder(input) { await db.insert(orders).values({ id: input.id, diff --git a/services/cron/src/index.ts b/services/cron/src/index.ts index 86357cd..3418fe3 100644 --- a/services/cron/src/index.ts +++ b/services/cron/src/index.ts @@ -20,7 +20,7 @@ const envSchema = baseEnvSchema.extend({ REDIS_URL: z.string().min(1), /** postgres:// connection string; the database package rejects anything else. */ DATABASE_URL: z.string().min(1), - COINPAY_API_BASE: z.string().url(), + COINPAY_API_BASE: z.string().url().default("https://coinpayportal.com"), COINPAY_API_KEY: z.string().min(1), /** Orders older than this with no terminal status get reconciled. */ RECONCILE_AFTER_MINUTES: z.coerce.number().int().positive().default(20), @@ -55,7 +55,6 @@ async function reconcilePayments(): Promise { apiKey: env.COINPAY_API_KEY, returnUrl: `${env.PUBLIC_ORIGIN}/checkout/success`, cancelUrl: `${env.PUBLIC_ORIGIN}/checkout/cancel`, - webhookUrl: `${env.PUBLIC_ORIGIN}/api/v1/webhooks/coinpay`, }); const cutoff = new Date(Date.now() - env.RECONCILE_AFTER_MINUTES * 60_000).toISOString(); @@ -75,8 +74,11 @@ async function reconcilePayments(): Promise { checked += 1; try { - const invoice = await coinpay.getInvoice(order.invoiceId); - const mapped = mapProviderStatus(invoice.status); + // Only crypto payments have a status read; PayPal and hosted sessions + // settle through their webhooks. + const providerStatus = await coinpay.getPaymentStatus(order.invoiceId); + if (!providerStatus) continue; + const mapped = mapProviderStatus(providerStatus); if (mapped === order.status) continue; drift += 1;