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,