Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
109 changes: 109 additions & 0 deletions apps/site/app/_components/checkout-form.tsx
Original file line number Diff line number Diff line change
@@ -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<Methods | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
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 <p>Loading payment options…</p>;

return (
<form className="auth-form" onSubmit={onSubmit}>
<div className="auth-form__row">
<label htmlFor="checkout-email">Email for your receipt and claim link</label>
<input id="checkout-email" name="email" type="email" autoComplete="email" />
</div>

{methods && !methods.hosted ? (
<div className="auth-form__row">
<label htmlFor="checkout-method">Pay with</label>
<select id="checkout-method" name="method" defaultValue="USDC_POL" required>
{methods.methods.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
</div>
) : null}

{error ? (
<p className="auth-form__error" role="alert">
{error}
</p>
) : null}

<button className="button button--commerce" type="submit" disabled={busy || !methods}>
{busy ? "Opening CoinPay…" : "Continue to CoinPay"}
</button>
</form>
);
}
25 changes: 8 additions & 17 deletions apps/site/app/checkout/[episode]/page.tsx
Original file line number Diff line number Diff line change
@@ -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" };

Expand Down Expand Up @@ -32,28 +33,18 @@ export default async function CheckoutPage({ params }: { params: Promise<{ episo
<li>Offline single-player access</li>
</ul>

<DraftNotice>
<strong>Purchasing is not open yet.</strong> Checkout opens with the episode; until then no
order can be completed, and you will not be charged.
</DraftNotice>
<CheckoutForm episodeId={entry.episodeId} />

<p>
In the meantime the demo and the multiplayer alpha are free and need no payment details:
</p>
<p className="cta-row">
<a className="button button--primary" href="/play?mode=demo">
Play the free demo
</a>
<a className="button button--ghost" href="/multiplayer">
Multiplayer Alpha
</a>
Not sure yet? The demo and the multiplayer alpha are free and need no payment details:{" "}
<a href="/play?mode=demo">play the free demo</a>.
</p>

<h3>How it will work</h3>
<h3>How it works</h3>
<ol>
<li>You are sent to CoinPayPortal to pay.</li>
<li>You pay on CoinPayPortal, in crypto or with PayPal.</li>
<li>
You come back to a page that says <em>pending</em> — that page never grants access by
You come back to a page that says <em>pending</em>. That page never grants access by
itself.
</li>
<li>Your unlock happens only when the payment is confirmed by a verified webhook.</li>
Expand Down
20 changes: 20 additions & 0 deletions apps/site/app/checkout/cancel/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PageShell label="Checkout" title="Checkout canceled" lede="Nothing was charged.">
<p>
{episode ? (
<a className="button button--commerce" href={`/checkout/${episode.episodeId}`}>
Try again
</a>
) : null}
</p>
</PageShell>
);
}
27 changes: 27 additions & 0 deletions apps/site/app/checkout/success/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PageShell
label="Checkout"
title="Payment pending"
lede="Thanks. Your unlock appears as soon as CoinPay confirms the payment."
>
<p>
Crypto payments confirm after the network does, usually within a few minutes. PayPal
payments can take up to about ten minutes.
</p>
<p>
Signed in? The episode shows up in your <a href="/library">library</a>. Bought as a guest?
Your one-time claim link arrives by email.
</p>
</PageShell>
);
}
33 changes: 27 additions & 6 deletions docs/coinpay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<rail>:<id>` (`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: <unix seconds>
x-coinpay-signature: hex(hmac-sha256(secret, "<timestamp>.<raw body>"))
POST /api/v1/webhooks/coinpay (and the singular /webhook/coinpay)
X-CoinPay-Signature: t=<unix seconds>,v1=hex(hmac-sha256(secret, "<t>.<raw body>"))
{ "id": "evt_<payment>_<ts>", "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
Expand Down
Loading
Loading