From c2236a929155d286e632980e8de185f1356b709f Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Wed, 21 Oct 2026 13:05:17 +0000
Subject: [PATCH] feat(billing): CoinPay hosted checkout sessions; nested
webhook with forwarded = paid
Credit packs now open a CoinPay checkout session (POST /checkout/sessions)
instead of a single-chain payment: property outreachgraph.com, account_id =
user id, customer_email = user email, org/workspace/pack/purchase ids in
metadata. The buyer picks any coin or PayPal on CoinPay's page, so the chain
picker is gone and the API ignores a sent blockchain. Rows are keyed by the
session id; the webhook reads data first, matches checkout_session_id then
payment_id, and credits on confirmed or forwarded (idempotent on the row key).
The client needs only COINPAY_API_KEY + COINPAY_WEBHOOK_SECRET now.
Co-Authored-By: Claude Opus 5.5
---
apps/api/src/app.ts | 4 +-
apps/api/src/billing.test.ts | 210 +++++++++++++++++++--------
apps/api/src/billing.ts | 152 +++++++++++--------
apps/server/src/index.ts | 12 +-
apps/web/components/credit-packs.tsx | 59 +-------
packages/payments/src/coinpay.ts | 121 ++++++++++++++-
packages/payments/src/index.ts | 4 +
7 files changed, 374 insertions(+), 188 deletions(-)
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 058f46f..454f681 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -6058,7 +6058,8 @@ export function createApp(options: AppOptions): Hono {
c.req.raw,
z.object({
packId: z.string().min(1),
- blockchain: z.string().min(1),
+ // Older clients still send a chain; the buyer now picks on CoinPay's page.
+ blockchain: z.string().optional(),
}),
);
@@ -6068,7 +6069,6 @@ export function createApp(options: AppOptions): Hono {
workspaceId: actor.workspaceId,
userId: actor.userId,
packId: body.packId,
- blockchain: body.blockchain,
appUrl: options.appUrl ?? 'https://outreachgraph.com',
...(options.apiUrl ? { apiUrl: options.apiUrl } : {}),
});
diff --git a/apps/api/src/billing.test.ts b/apps/api/src/billing.test.ts
index 0e2518d..5d34502 100644
--- a/apps/api/src/billing.test.ts
+++ b/apps/api/src/billing.test.ts
@@ -66,29 +66,51 @@ function confirmedBody(paymentId: string): string {
});
}
-/** A client whose `createPayment` does not reach the network. */
-function stubbedClient(paymentId = 'pay_stub'): CoinPayClient {
+/** A client whose `createCheckoutSession` does not reach the network. */
+function stubbedClient(
+ sessionId = 'pay_stub',
+ seen?: { body?: Record },
+): CoinPayClient {
const stub = client();
- stub.createPayment = async () => ({
- paymentId,
- paymentUrl: `https://coinpayportal.com/pay/${paymentId}`,
- paymentAddress: 'bc1qexample',
- cryptoAmount: '0.0009',
- expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
- status: 'pending',
- });
+ stub.createCheckoutSession = async (input) => {
+ if (seen) seen.body = stub.checkoutSessionBody(input);
+ return {
+ sessionId,
+ url: `https://coinpayportal.com/checkout/${sessionId}`,
+ status: 'open',
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
+ };
+ };
return stub;
}
+/** A nested CoinPay event for a hosted checkout session. */
+function sessionEvent(sessionId: string, status: string, paymentId = 'pay_real'): string {
+ return JSON.stringify({
+ id: `evt_${paymentId}_1761040000`,
+ type: `payment.${status}`,
+ business_id: 'biz',
+ created_at: new Date().toISOString(),
+ data: {
+ payment_id: paymentId,
+ status,
+ checkout_session_id: sessionId,
+ customer_email: 'owner@example.com',
+ account_id: SEED.userId,
+ property: 'outreachgraph.com',
+ metadata: { organization_id: SEED.organizationId },
+ },
+ });
+}
+
async function purchase(db: SeededDatabase['db'], paymentId = 'pay_stub'): Promise {
const result = await startCreditPurchase(db, stubbedClient(paymentId), {
organizationId: SEED.organizationId,
workspaceId: SEED.workspaceId,
userId: SEED.userId,
packId: 'pack_500',
- blockchain: 'BTC',
appUrl: 'https://outreachgraph.com',
});
@@ -108,7 +130,7 @@ describe('startCreditPurchase', () => {
appUrl: 'https://outreachgraph.com',
});
- expect(result.paymentUrl).toContain('coinpayportal.com/pay/');
+ expect(result.paymentUrl).toContain('coinpayportal.com/checkout/');
expect(result.pack.credits).toBe(500);
const row = await queryOne<{ status: string; credits: number }>(
@@ -138,72 +160,59 @@ describe('startCreditPurchase', () => {
).rejects.toThrow(BillingError);
});
- test('refuses a chain CoinPayPortal would reject at checkout', async () => {
- seeded = await seedDatabase('billing-badchain');
+ test('opens a hosted session with the buyer email, account id and property', async () => {
+ seeded = await seedDatabase('billing-session');
+ const seen: { body?: Record } = {};
- await expect(
- startCreditPurchase(seeded.db, stubbedClient(), {
- organizationId: SEED.organizationId,
- workspaceId: SEED.workspaceId,
- userId: SEED.userId,
- packId: 'pack_500',
- blockchain: 'NOT_A_CHAIN',
- appUrl: 'https://outreachgraph.com',
- }),
- ).rejects.toThrow(BillingError);
+ const result = await startCreditPurchase(seeded.db, stubbedClient('cs_test1', seen), {
+ organizationId: SEED.organizationId,
+ workspaceId: SEED.workspaceId,
+ userId: SEED.userId,
+ packId: 'pack_500',
+ appUrl: 'https://outreachgraph.com',
+ });
+
+ const body = seen.body ?? {};
+ const user = await queryOne<{ email: string }>(
+ seeded.db,
+ 'SELECT email FROM users WHERE id = ?',
+ [SEED.userId],
+ );
+ expect(body.customer_email).toBe(user?.email.toLowerCase());
+ expect(body.account_id).toBe(SEED.userId);
+ expect(body.property).toBe('outreachgraph.com');
+ expect(body.currency).toBe('USD');
+ expect(body.blockchain).toBeUndefined();
+ expect(body.success_url).toBe(
+ `https://outreachgraph.com/billing?purchase=${result.purchaseId}`,
+ );
+ expect((body.metadata as Record).organization_id).toBe(SEED.organizationId);
+ expect(result.paymentUrl).toBe('https://coinpayportal.com/checkout/cs_test1');
+
+ const row = await queryOne<{ payment_id: string; blockchain: string }>(
+ seeded.db,
+ 'SELECT payment_id, blockchain FROM credit_purchases WHERE id = ?',
+ [result.purchaseId],
+ );
+ expect(row?.payment_id).toBe('cs_test1');
+ expect(row?.blockchain).toBe('ANY');
});
- test('refuses a chain name where a ticker code is required', async () => {
- // The bug this pins. CoinPayPortal upper-cases and matches, so "bitcoin"
- // becomes "BITCOIN", matches nothing, and is refused *after* the API key
- // has been accepted — a 400 that looks exactly like a credentials fault.
- seeded = await seedDatabase('billing-chainname');
+ test('refuses a buyer with no email', async () => {
+ seeded = await seedDatabase('billing-noemail');
await expect(
startCreditPurchase(seeded.db, stubbedClient(), {
organizationId: SEED.organizationId,
workspaceId: SEED.workspaceId,
- userId: SEED.userId,
+ userId: 'usr_nobody',
packId: 'pack_500',
- blockchain: 'bitcoin',
appUrl: 'https://outreachgraph.com',
}),
).rejects.toThrow(BillingError);
});
- test('accepts a ticker in either case and sends the canonical form', async () => {
- seeded = await seedDatabase('billing-chaincase');
- let sent: string | undefined;
-
- const stub = stubbedClient();
- stub.createPayment = async (input) => {
- sent = input.blockchain;
- return {
- paymentId: 'pay_case',
- paymentUrl: 'https://coinpayportal.com/pay/pay_case',
- paymentAddress: 'addr',
- cryptoAmount: '1',
- expiresAt: new Date().toISOString(),
- status: 'pending',
- };
- };
-
- await startCreditPurchase(seeded.db, stub, {
- organizationId: SEED.organizationId,
- workspaceId: SEED.workspaceId,
- userId: SEED.userId,
- packId: 'pack_500',
- blockchain: 'usdc_sol',
- appUrl: 'https://outreachgraph.com',
- });
-
- expect(sent).toBe('USDC_SOL');
- });
-
- test('does not offer a chain with no wallet behind it', () => {
- // USDC_BASE is a valid code on the portal side. Offering it would produce
- // "No wallet configured for this business" at the worst moment.
- expect(BLOCKCHAINS).not.toContain('USDC_BASE' as never);
+ test('still exports the chain list for older callers', () => {
expect(BLOCKCHAINS).toContain('USDC_SOL');
});
});
@@ -377,6 +386,79 @@ describe('handleCoinPayWebhook', () => {
});
});
+describe('handleCoinPayWebhook with hosted checkout sessions', () => {
+ test('a nested confirmed event settles the purchase keyed by checkout_session_id', async () => {
+ seeded = await seedDatabase('billing-cs-confirm');
+ await purchase(seeded.db, 'cs_abc');
+
+ const body = sessionEvent('cs_abc', 'confirmed');
+ const result = await handleCoinPayWebhook(seeded.db, client(), {
+ rawBody: body,
+ signature: await sign(body),
+ });
+
+ expect(result.credited).toBe(true);
+ expect((await creditsFor(seeded.db, SEED.organizationId)).remaining).toBe(500);
+ });
+
+ test('confirmed then forwarded credits once; forwarded alone also counts', async () => {
+ seeded = await seedDatabase('billing-cs-forwarded');
+ await purchase(seeded.db, 'cs_fwd');
+
+ for (const status of ['confirmed', 'forwarded']) {
+ const body = sessionEvent('cs_fwd', status);
+ await handleCoinPayWebhook(seeded.db, client(), {
+ rawBody: body,
+ signature: await sign(body),
+ });
+ }
+ expect((await creditsFor(seeded.db, SEED.organizationId)).remaining).toBe(500);
+ });
+
+ test('forwarded without a prior confirmed still credits', async () => {
+ seeded = await seedDatabase('billing-cs-fwd-only');
+ await purchase(seeded.db, 'cs_fwd2');
+
+ const body = sessionEvent('cs_fwd2', 'forwarded');
+ const result = await handleCoinPayWebhook(seeded.db, client(), {
+ rawBody: body,
+ signature: await sign(body),
+ });
+ expect(result.credited).toBe(true);
+ });
+
+ test('an expired session is recorded and credits nothing', async () => {
+ seeded = await seedDatabase('billing-cs-expired');
+ await purchase(seeded.db, 'cs_exp');
+
+ const body = sessionEvent('cs_exp', 'expired');
+ const result = await handleCoinPayWebhook(seeded.db, client(), {
+ rawBody: body,
+ signature: await sign(body),
+ });
+ expect(result.credited).toBe(false);
+ const row = await queryOne<{ status: string }>(
+ seeded.db,
+ 'SELECT status FROM credit_purchases WHERE payment_id = ?',
+ ['cs_exp'],
+ );
+ expect(row?.status).toBe('expired');
+ });
+
+ test('an unsigned session event is refused', async () => {
+ seeded = await seedDatabase('billing-cs-unsigned');
+ await purchase(seeded.db, 'cs_uns');
+
+ await expect(
+ handleCoinPayWebhook(seeded.db, client(), {
+ rawBody: sessionEvent('cs_uns', 'confirmed'),
+ signature: null,
+ }),
+ ).rejects.toThrow(BillingError);
+ expect((await creditsFor(seeded.db, SEED.organizationId)).remaining).toBe(0);
+ });
+});
+
describe('billingOverview', () => {
test('reports the packs, the balance and what was bought', async () => {
seeded = await seedDatabase('billing-overview');
diff --git a/apps/api/src/billing.ts b/apps/api/src/billing.ts
index 16bd61e..06e3022 100644
--- a/apps/api/src/billing.ts
+++ b/apps/api/src/billing.ts
@@ -12,7 +12,7 @@
import { CREDIT_PACKS, creditPackById, newId, type CreditPack } from '@outreachgraph/domain';
import { now, queryOne, type Client } from '@outreachgraph/db';
import { creditsFor, grantCredits } from '@outreachgraph/pipeline';
-import { CoinPayClient, normaliseBlockchain } from '@outreachgraph/payments';
+import { CoinPayClient, readWebhookEvent } from '@outreachgraph/payments';
export class BillingError extends Error {
readonly status: number;
@@ -37,13 +37,12 @@ export interface PurchaseRow {
}
/**
- * Opens a checkout and records our side of it before the customer sees a page.
+ * Opens a hosted CoinPay checkout session and records our side of it.
*
- * The row is written *first*, deliberately. A payment that exists at
- * CoinPayPortal with no row here is a payment whose webhook we would have to
- * refuse, and refunding crypto is a support conversation rather than an API
- * call. The opposite ordering — row first, payment second — leaves at worst an
- * abandoned `pending` row, which costs nothing and is visible.
+ * The buyer picks the coin (or PayPal) on CoinPay's page, so no chain is chosen
+ * here. The row is keyed by the session id (`cs_…`), which CoinPay echoes on
+ * every webhook as `data.checkout_session_id`; the organization credited is
+ * read from this row, never from the webhook's metadata.
*/
export async function startCreditPurchase(
db: Client,
@@ -53,43 +52,51 @@ export async function startCreditPurchase(
readonly workspaceId: string;
readonly userId: string;
readonly packId: string;
- readonly blockchain: string;
+ /** Ignored: kept so older clients that still send a chain are not refused. */
+ readonly blockchain?: string;
+ /** The buyer's email; looked up from the user row when absent. */
+ readonly email?: string;
readonly appUrl: string;
readonly apiUrl?: string;
},
): Promise<{
readonly purchaseId: string;
readonly paymentUrl: string;
+ readonly sessionId: string;
readonly pack: CreditPack;
}> {
const pack = creditPackById(input.packId);
if (!pack) throw new BillingError(`No such credit pack: ${input.packId}`);
- // Normalised rather than merely validated, because the portal upper-cases
- // before matching: a UI sending `usdc_sol` means the same request, and
- // refusing it would be a 400 nobody could explain.
- const blockchain = normaliseBlockchain(input.blockchain);
-
- if (!blockchain) {
- throw new BillingError(`Unsupported blockchain: ${input.blockchain}`);
+ let email = input.email?.trim();
+ if (!email) {
+ const user = await queryOne<{ email: string }>(db, 'SELECT email FROM users WHERE id = ?', [
+ input.userId,
+ ]);
+ email = user?.email?.trim();
}
+ if (!email) throw new BillingError('Your account has no email to send the receipt to.');
+
const purchaseId = newId('creditPurchase');
- const payment = await coinpay.createPayment({
+ const session = await coinpay.createCheckoutSession({
amountUsd: pack.priceUsd,
- blockchain,
- description: `OutreachGraph — ${pack.name}`,
+ description: `OutreachGraph: ${pack.name}`,
+ customerEmail: email,
+ accountId: input.userId,
// Read on the way back only to cross-check and to make a support ticket
// legible. The organization credited comes from the row below.
metadata: {
purchase_id: purchaseId,
organization_id: input.organizationId,
+ workspace_id: input.workspaceId,
+ user_id: input.userId,
pack_id: pack.id,
},
- ...(input.apiUrl ? { webhookUrl: `${input.apiUrl}/api/v1/coinpay/callback` } : {}),
// Back to the page they left from, which is where the new balance and the
// purchase's status are shown.
- redirectUrl: `${input.appUrl}/billing?purchase=${purchaseId}`,
+ successUrl: `${input.appUrl}/billing?purchase=${purchaseId}`,
+ cancelUrl: `${input.appUrl}/billing`,
});
const stamp = now();
@@ -106,24 +113,26 @@ export async function startCreditPurchase(
pack.id,
pack.credits,
pack.priceUsd,
- blockchain,
- payment.paymentId,
- payment.paymentUrl,
+ // The buyer chooses on CoinPay's page; the session decides nothing here.
+ 'ANY',
+ session.sessionId,
+ session.url,
stamp,
stamp,
],
});
- return { purchaseId, paymentUrl: payment.paymentUrl, pack };
+ return { purchaseId, paymentUrl: session.url, sessionId: session.sessionId, pack };
}
/**
* Applies a CoinPayPortal webhook.
*
- * Only `payment.confirmed` moves money into credits. `payment.detected` means
- * the transaction is visible but not yet buried under enough blocks, and
- * crediting on it would hand out prospects for a payment that can still be
- * reorganised out of existence.
+ * `payment.confirmed` and `payment.forwarded` move money into credits (CoinPay
+ * re-sends a failed `forwarded` but gives up on `confirmed`, so both must
+ * count). `payment.detected` means visible but not yet buried under enough
+ * blocks, and crediting on it would hand out prospects for a payment that can
+ * still be reorganised out of existence.
*
* Returns what happened, so the caller can answer 200 to everything it
* understood. A webhook endpoint that returns 500 for "I have already handled
@@ -142,57 +151,76 @@ export async function handleCoinPayWebhook(
const verified = await coinpay.verifyWebhook(input.rawBody, input.signature);
if (!verified) throw new BillingError('Bad webhook signature', 401);
- let event: { type?: string; data?: { payment_id?: string } };
+ let payload: unknown;
try {
- event = JSON.parse(input.rawBody);
+ payload = JSON.parse(input.rawBody);
} catch {
throw new BillingError('Webhook body is not JSON', 400);
}
- const type = String(event.type ?? '');
- const paymentId = String(event.data?.payment_id ?? '');
+ const event = readWebhookEvent(payload);
- if (type === 'test.webhook') {
+ if (event.type === 'test.webhook') {
return { handled: true, credited: false, detail: 'test webhook acknowledged' };
}
- if (!paymentId) throw new BillingError('Webhook carried no payment id', 400);
-
- const purchase = await queryOne<{
- id: string;
- organization_id: string;
- credits: number;
- status: string;
- pack_id: string;
- }>(
- db,
- `SELECT id, organization_id, credits, status, pack_id
- FROM credit_purchases WHERE payment_id = ?`,
- [paymentId],
- );
+ if (event.refs.length === 0) throw new BillingError('Webhook carried no payment id', 400);
+
+ // Keyed by the checkout session id for hosted checkout, by the payment id for
+ // purchases opened before it. Either way the row decides who is credited.
+ let purchase:
+ | {
+ id: string;
+ organization_id: string;
+ credits: number;
+ status: string;
+ pack_id: string;
+ payment_id: string;
+ }
+ | undefined;
+ for (const ref of event.refs) {
+ purchase =
+ (await queryOne<{
+ id: string;
+ organization_id: string;
+ credits: number;
+ status: string;
+ pack_id: string;
+ payment_id: string;
+ }>(
+ db,
+ `SELECT id, organization_id, credits, status, pack_id, payment_id
+ FROM credit_purchases WHERE payment_id = ?`,
+ [ref],
+ )) ?? undefined;
+ if (purchase) break;
+ }
// A payment we never opened. Answered rather than raised: it is either
// another product on the same CoinPayPortal business, or noise, and neither
// is served by us retrying.
if (!purchase) {
- return { handled: false, credited: false, detail: `unknown payment ${paymentId}` };
+ return { handled: false, credited: false, detail: `unknown payment ${event.refs.join(',')}` };
}
- if (type !== 'payment.confirmed') {
- await db.execute({
- sql: `UPDATE credit_purchases SET status = ?, updated_at = ? WHERE id = ?`,
- args: [webhookStatus(type), now(), purchase.id],
- });
+ if (!event.paid) {
+ if (purchase.status !== 'confirmed') {
+ await db.execute({
+ sql: `UPDATE credit_purchases SET status = ?, updated_at = ? WHERE id = ?`,
+ args: [webhookStatus(event.type, event.status), now(), purchase.id],
+ });
+ }
- return { handled: true, credited: false, detail: `recorded ${type}` };
+ return { handled: true, credited: false, detail: `recorded ${event.type || event.status}` };
}
- // `grantCredits` collides on the payment id if this confirmation has already
- // been applied, so a duplicate delivery is a no-op rather than free credits.
+ // `grantCredits` collides on the purchase's own key if this payment has
+ // already been applied (confirmed then forwarded, or a redelivery), so a
+ // duplicate is a no-op rather than free credits.
const credited = await grantCredits(db, {
organizationId: purchase.organization_id,
credits: purchase.credits,
- paymentId,
+ paymentId: purchase.payment_id,
reason: `CoinPayPortal ${purchase.pack_id}`,
});
@@ -210,11 +238,11 @@ export async function handleCoinPayWebhook(
};
}
-function webhookStatus(type: string): string {
- if (type === 'payment.detected') return 'detected';
- if (type === 'payment.failed') return 'failed';
- if (type === 'payment.expired') return 'expired';
- if (type === 'payment.forwarded') return 'confirmed';
+function webhookStatus(type: string, status: string): string {
+ const s = status || type.replace(/^payment\./, '');
+ if (s === 'detected') return 'detected';
+ if (s === 'failed') return 'failed';
+ if (s === 'expired') return 'expired';
return 'pending';
}
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 0e0dcfb..38258d1 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -585,21 +585,21 @@ const commitHash = process.env.COMMIT_HASH ?? process.env.RAILWAY_GIT_COMMIT_SHA
/**
* Selling credits, if this deployment has been given the means to.
*
- * All three variables or none. A client holding two of them fails at the
- * moment a customer clicks Buy, which is the worst possible time to discover
+ * The business key and the webhook secret, or nothing (the business id is
+ * optional: hosted checkout sessions are named by the key). A client missing
+ * either fails at the moment a customer clicks Buy, which is the worst possible time to discover
* a missing environment variable; refusing to construct it means `/billing`
* reports honestly that it cannot take payments, and the checkout button is
* never offered in the first place.
*/
const coinpay =
- process.env.COINPAY_API_KEY &&
- process.env.COINPAY_BUSINESS_ID &&
- process.env.COINPAY_WEBHOOK_SECRET
+ process.env.COINPAY_API_KEY && process.env.COINPAY_WEBHOOK_SECRET
? new CoinPayClient({
apiKey: process.env.COINPAY_API_KEY,
- businessId: process.env.COINPAY_BUSINESS_ID,
webhookSecret: process.env.COINPAY_WEBHOOK_SECRET,
+ ...(process.env.COINPAY_BUSINESS_ID ? { businessId: process.env.COINPAY_BUSINESS_ID } : {}),
...(process.env.COINPAY_BASE_URL ? { baseUrl: process.env.COINPAY_BASE_URL } : {}),
+ ...(process.env.COINPAY_PROPERTY ? { property: process.env.COINPAY_PROPERTY } : {}),
})
: undefined;
diff --git a/apps/web/components/credit-packs.tsx b/apps/web/components/credit-packs.tsx
index 8056622..4bd7f7b 100644
--- a/apps/web/components/credit-packs.tsx
+++ b/apps/web/components/credit-packs.tsx
@@ -8,8 +8,8 @@ import type { BillingView, CreditPackView } from '../lib/api';
*
* The checkout itself is CoinPayPortal's hosted page, so this component's only
* real job is to be honest before sending someone there: what a pack costs,
- * what it buys, and — the part a crypto checkout gets wrong most often — that
- * the price is quoted in dollars and the chain is only how it is paid.
+ * what it buys, and that the price is quoted in dollars. The coin (or PayPal)
+ * is chosen on CoinPay's hosted checkout page, not here.
*
* There is no "confirming payment" spinner here on purpose. A chain payment
* settles when it settles, and a page that pretends to wait for it either lies
@@ -18,36 +18,7 @@ import type { BillingView, CreditPackView } from '../lib/api';
* webhook lands, which is the truth and is also resumable.
*/
-/**
- * The chains on offer, as CoinPayPortal names them.
- *
- * Ticker codes rather than chain names because that is what the API matches
- * on — it upper-cases the value and looks it up, so "bitcoin" is refused after
- * the credentials have already been accepted, which reads as a broken account.
- *
- * Stablecoins first: this is a $15–$199 purchase, and quoting it in a currency
- * that does not move between the quote and the confirmation is the difference
- * between paying $59 and paying whatever BTC did in the next ten minutes.
- */
-const CHAINS = [
- { id: 'USDC_SOL', label: 'USDC on Solana' },
- { id: 'USDC_POL', label: 'USDC on Polygon' },
- { id: 'USDC_ETH', label: 'USDC on Ethereum' },
- { id: 'USDT_SOL', label: 'USDT on Solana' },
- { id: 'USDT_POL', label: 'USDT on Polygon' },
- { id: 'BTC', label: 'Bitcoin' },
- { id: 'ETH', label: 'Ethereum' },
- { id: 'SOL', label: 'Solana' },
- { id: 'POL', label: 'Polygon' },
- { id: 'BCH', label: 'Bitcoin Cash' },
- { id: 'DOGE', label: 'Dogecoin' },
- { id: 'XRP', label: 'XRP' },
- { id: 'ADA', label: 'Cardano' },
- { id: 'BNB', label: 'BNB' },
-] as const;
-
export function CreditPacks({ initial }: { initial: BillingView }) {
- const [chain, setChain] = useState('USDC_SOL');
const [busy, setBusy] = useState();
const [error, setError] = useState();
@@ -64,7 +35,7 @@ export function CreditPacks({ initial }: { initial: BillingView }) {
const response = await fetch('/api/v1/billing/checkout', {
method: 'POST',
headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ packId: pack.id, blockchain: chain }),
+ body: JSON.stringify({ packId: pack.id }),
});
const body = (await response.json()) as {
@@ -128,26 +99,10 @@ export function CreditPacks({ initial }: { initial: BillingView }) {
) : (
<>
-
-
-
-
- Prices are in US dollars. The chain only decides how you pay.
-
-
+
+ Prices are in US dollars. Pay with any major coin or PayPal on the CoinPay checkout
+ page.
+
{initial.packs.map((pack) => (
diff --git a/packages/payments/src/coinpay.ts b/packages/payments/src/coinpay.ts
index adee7c1..a9abf1c 100644
--- a/packages/payments/src/coinpay.ts
+++ b/packages/payments/src/coinpay.ts
@@ -113,9 +113,70 @@ export const BLOCKCHAIN_LABELS: Readonly> = {
export interface CoinPayConfig {
readonly apiKey: string;
- readonly businessId: string;
+ /** Optional for hosted checkout: the business key already names the business. */
+ readonly businessId?: string;
readonly webhookSecret: string;
readonly baseUrl?: string;
+ /** Site hostname recorded on every checkout session (default outreachgraph.com). */
+ readonly property?: string;
+}
+
+/** A hosted checkout session: send the customer to `url`. */
+export interface CheckoutSession {
+ readonly sessionId: string;
+ readonly url: string;
+ readonly status: string;
+ readonly expiresAt?: string;
+}
+
+export interface CheckoutSessionInput {
+ readonly amountUsd: number;
+ readonly description: string;
+ /** Required by CoinPay: where the receipt goes. */
+ readonly customerEmail: string;
+ /** Required by CoinPay: our stable id for the buyer. */
+ readonly accountId: string;
+ readonly metadata?: Record;
+ readonly successUrl?: string;
+ readonly cancelUrl?: string;
+ readonly expiresInMinutes?: number;
+}
+
+/**
+ * What a webhook says, read the way CoinPay sends it.
+ *
+ * The body is nested: `{ id: 'evt_…', type, data: { payment_id, status,
+ * checkout_session_id, customer_email, account_id, property, metadata } }`.
+ * The top-level `id` is the EVENT, never the payment, so `data` is read first
+ * and the older flat shape is only a fallback.
+ */
+export interface ReadWebhook {
+ readonly type: string;
+ readonly status: string;
+ /** Ids a purchase row may be keyed by: checkout session first, then payment. */
+ readonly refs: readonly string[];
+ readonly paid: boolean;
+ readonly metadata: Record;
+}
+
+const PAID_STATUSES = new Set(['confirmed', 'forwarded']);
+const PAID_TYPES = new Set(['payment.confirmed', 'payment.forwarded']);
+
+export function readWebhookEvent(payload: unknown): ReadWebhook {
+ const event = (payload ?? {}) as Record;
+ const data = ((event.data as Record | undefined) ?? event) as Record<
+ string,
+ unknown
+ >;
+ const type = String(event.type ?? event.event ?? '');
+ const status = String(data.status ?? '').toLowerCase();
+ const refs = [data.checkout_session_id, data.payment_id]
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
+ .filter((value, index, all) => all.indexOf(value) === index);
+ const paid = status ? PAID_STATUSES.has(status) : PAID_TYPES.has(type);
+ const metadata = ((data.metadata ?? event.metadata ?? {}) as Record) ?? {};
+
+ return { type, status, refs, paid, metadata };
}
export interface CreatedPayment {
@@ -168,12 +229,14 @@ export class CoinPayClient {
private readonly apiKey: string;
private readonly businessId: string;
private readonly webhookSecret: string;
+ readonly property: string;
private readonly baseUrl: string;
private readonly portalUrl: string;
constructor(config: CoinPayConfig) {
this.apiKey = config.apiKey;
- this.businessId = config.businessId;
+ this.businessId = config.businessId ?? '';
+ this.property = config.property ?? 'outreachgraph.com';
this.webhookSecret = config.webhookSecret;
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.portalUrl = this.baseUrl.replace(/\/api\/?$/, '');
@@ -260,6 +323,60 @@ export class CoinPayClient {
};
}
+ /**
+ * The request body for `POST /checkout/sessions`. Public so tests can pin it.
+ */
+ checkoutSessionBody(input: CheckoutSessionInput): Record {
+ return {
+ amount: input.amountUsd,
+ currency: 'USD',
+ description: input.description,
+ customer_email: input.customerEmail.trim().toLowerCase(),
+ account_id: input.accountId,
+ property: this.property,
+ metadata: { site: this.property, ...(input.metadata ?? {}) },
+ ...(input.successUrl ? { success_url: input.successUrl } : {}),
+ ...(input.cancelUrl ? { cancel_url: input.cancelUrl } : {}),
+ expires_in_minutes: input.expiresInMinutes ?? 60,
+ };
+ }
+
+ /**
+ * Opens a hosted checkout session. The customer picks any coin the business
+ * takes, or PayPal, on CoinPay's page; we never choose a chain for them.
+ */
+ async createCheckoutSession(input: CheckoutSessionInput): Promise {
+ const body = await this.request<{
+ id: string;
+ url: string;
+ status?: string;
+ expires_at?: string;
+ }>('/checkout/sessions', {
+ method: 'POST',
+ body: JSON.stringify(this.checkoutSessionBody(input)),
+ });
+
+ if (!body.id || !body.url) {
+ throw new CoinPayError('CoinPayPortal returned a checkout session with no id or url', 502);
+ }
+
+ return {
+ sessionId: body.id,
+ url: body.url,
+ status: body.status ?? 'open',
+ ...(body.expires_at ? { expiresAt: body.expires_at } : {}),
+ };
+ }
+
+ /** A checkout session's status (open, paid, expired, ...). */
+ async getCheckoutSession(sessionId: string): Promise<{ status: string }> {
+ const body = await this.request<{ status?: string; session?: { status?: string } }>(
+ `/checkout/sessions/${encodeURIComponent(sessionId)}`,
+ );
+
+ return { status: body.session?.status ?? body.status ?? 'unknown' };
+ }
+
/** The authoritative status, for when a webhook was missed. */
async getPayment(paymentId: string): Promise<{ status: string; amountUsd?: number }> {
const body = await this.request<{ payment: { status: string; amount?: number } }>(
diff --git a/packages/payments/src/index.ts b/packages/payments/src/index.ts
index c0cbb49..3e4710d 100644
--- a/packages/payments/src/index.ts
+++ b/packages/payments/src/index.ts
@@ -5,6 +5,10 @@ export {
CoinPayError,
isBlockchain,
normaliseBlockchain,
+ readWebhookEvent,
+ type CheckoutSession,
+ type CheckoutSessionInput,
+ type ReadWebhook,
type Blockchain,
type CoinPayConfig,
type CreatedPayment,