From 0a8e5047aa4ef39534fc105fd11c440ffe19d4cf Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 21 Oct 2026 13:01:44 +0000 Subject: [PATCH] feat(invoices): send payer, payee and property to CoinPay; read the nested webhook requestPayment now passes customer_email (payer email), account_id (payer id) and property agenticjobs.work top-level on /api/payments/create, and the same plus payee_id in metadata. The peer-invoice model is unchanged: the payer still pays the payee's own wallet via merchant_wallet_address. applyWebhook read event/payment_id flat, so a real nested CoinPay webhook ({id: evt_..., type, data: {payment_id, status}}) was always ignored and invoices only settled on the next page load. It now reads data first with the flat shape as a fallback. Co-Authored-By: Claude Opus 5.5 --- src/core/coinpay.ts | 21 +++++++++++- src/core/invoices.ts | 44 +++++++++++++++++++++--- test/billing.test.ts | 79 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/core/coinpay.ts b/src/core/coinpay.ts index 55e1dd7..c60b046 100644 --- a/src/core/coinpay.ts +++ b/src/core/coinpay.ts @@ -65,6 +65,9 @@ export interface Payment { export class CoinPayProblem extends Error {} +/** The site every payment here is for, as CoinPay records it (`property`). */ +export const PROPERTY = 'agenticjobs.work'; + export interface CoinPayClient { readonly config: CoinPayConfig; authorizeUrl(input: { state: string; codeChallenge: string; redirectUri: string }): string; @@ -83,6 +86,11 @@ export interface CoinPayClient { redirectUrl: string; idempotencyKey: string; metadata: Record; + /** Who is paying: their email and agenticjobs user id. Sent top-level and in metadata. */ + customerEmail?: string | null; + accountId?: string | null; + /** Who is being paid: the payee's agenticjobs user id. */ + payeeId?: string | null; }): Promise; getPayment(id: string): Promise; /** Where a payer goes to pay. */ @@ -276,7 +284,18 @@ export function createCoinPay(config: CoinPayConfig, fetchImpl: Fetch = fetch): merchant_wallet_address: input.payeeAddress, description: input.description, redirect_url: input.redirectUrl, - metadata: { ...input.metadata, idempotency_key: input.idempotencyKey }, + // Optional on /payments/create: a malformed one is dropped by CoinPay, never a 400. + ...(input.customerEmail ? { customer_email: input.customerEmail } : {}), + ...(input.accountId ? { account_id: input.accountId } : {}), + property: PROPERTY, + metadata: { + ...input.metadata, + property: PROPERTY, + ...(input.accountId ? { account_id: input.accountId } : {}), + ...(input.customerEmail ? { customer_email: input.customerEmail } : {}), + ...(input.payeeId ? { payee_id: input.payeeId } : {}), + idempotency_key: input.idempotencyKey, + }, }), }); if (result.status !== 200 && result.status !== 201) { diff --git a/src/core/invoices.ts b/src/core/invoices.ts index c8ed7df..0e26945 100644 --- a/src/core/invoices.ts +++ b/src/core/invoices.ts @@ -307,6 +307,11 @@ export async function requestPayment( return synced; } + const payer = await pool.query<{ email: string }>('select email from users where id = $1', [ + input.payerId, + ]); + const payerEmail = payer.rows[0]?.email ?? null; + let minted; try { minted = await coinpay.createPayment({ @@ -324,6 +329,9 @@ export async function requestPayment( thread_id: current.threadId, payer_id: input.payerId, }, + customerEmail: payerEmail, + accountId: input.payerId, + payeeId: current.payee.id, }); } catch (error) { if (error instanceof CoinPayProblem) return error.message; @@ -419,15 +427,43 @@ export async function markPaid( * to the next page load, which clears it the same way and does not depend on * a delivery that may never come. */ +export function readWebhook(payload: Record): { + event: string; + paymentId: string; + status: string; + txHash: string | null; + metadata: Record; +} { + // CoinPay nests the payment under `data`; the top-level `id` is the EVENT id, + // never the payment's. Read `data` first, the old flat shape as a fallback. + const nested = payload['data']; + const data = + typeof nested === 'object' && nested !== null ? (nested as Record) : payload; + const s = (v: unknown) => (typeof v === 'string' ? v : ''); + const event = s(payload['type']) || s(payload['event']); + const paymentId = s(data['payment_id']) || (data === payload ? '' : s(data['id'])); + const meta = data['metadata'] ?? payload['metadata']; + return { + event, + paymentId, + status: s(data['status']).toLowerCase(), + txHash: s(data['tx_hash']) || null, + metadata: typeof meta === 'object' && meta !== null ? (meta as Record) : {}, + }; +} + export async function applyWebhook( pool: pg.Pool, payload: Record, ): Promise<'paid' | 'ignored' | 'unknown'> { - const event = typeof payload['event'] === 'string' ? payload['event'] : ''; - const paymentId = typeof payload['payment_id'] === 'string' ? payload['payment_id'] : ''; + const { event, paymentId, status, txHash } = readWebhook(payload); if (paymentId === '') return 'ignored'; - if (event !== 'payment.confirmed' && event !== 'payment.forwarded') return 'ignored'; - const txHash = typeof payload['tx_hash'] === 'string' ? payload['tx_hash'] : null; + const settled = + event === 'payment.confirmed' || + event === 'payment.forwarded' || + status === 'confirmed' || + status === 'forwarded'; + if (!settled) return 'ignored'; const paid = await markPaid(pool, { paymentId, txHash }); return paid ? 'paid' : 'unknown'; } diff --git a/test/billing.test.ts b/test/billing.test.ts index f31ffcf..6748c06 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -16,7 +16,7 @@ import { pkcePair, SCOPES, } from '../dist/core/coinpay.js'; -import { parseAmount } from '../dist/core/invoices.js'; +import { applyWebhook, parseAmount, readWebhook } from '../dist/core/invoices.js'; import { inboxMessage } from '../dist/core/mail.js'; const FULL = { @@ -138,6 +138,9 @@ test('the payment request names the payee wallet and the board business, with th redirectUrl: 'https://b/inbox/t', idempotencyKey: 'k', metadata: { invoice_id: 'i' }, + customerEmail: 'payer@example.com', + accountId: 'user-payer', + payeeId: 'user-payee', }); assert.equal(payment.id, 'pay_1'); assert.equal(payment.currency, 'USDC_POL'); @@ -154,6 +157,80 @@ test('the payment request names the payee wallet and the board business, with th assert.equal(sent['blockchain'], 'USDC_POL'); assert.equal(sent['amount_usd'], 1200); assert.equal((sent['metadata'] as Record)['idempotency_key'], 'k'); + // Who pays, who is paid and which site, top-level and in metadata. + assert.equal(sent['customer_email'], 'payer@example.com'); + assert.equal(sent['account_id'], 'user-payer'); + assert.equal(sent['property'], 'agenticjobs.work'); + assert.deepEqual(sent['metadata'], { + invoice_id: 'i', + property: 'agenticjobs.work', + account_id: 'user-payer', + customer_email: 'payer@example.com', + payee_id: 'user-payee', + idempotency_key: 'k', + }); +}); + +test('the webhook is read from the nested data, never the event id, with the old flat shape as a fallback', async () => { + const nested = { + id: 'evt_pay_9_1700000000', + type: 'payment.forwarded', + data: { + payment_id: 'pay_9', + status: 'forwarded', + tx_hash: '0xabc', + customer_email: 'payer@example.com', + account_id: 'user-payer', + property: 'agenticjobs.work', + metadata: { invoice_id: 'i', payee_id: 'user-payee' }, + }, + }; + assert.deepEqual(readWebhook(nested), { + event: 'payment.forwarded', + paymentId: 'pay_9', + status: 'forwarded', + txHash: '0xabc', + metadata: { invoice_id: 'i', payee_id: 'user-payee' }, + }); + assert.equal( + readWebhook({ event: 'payment.confirmed', payment_id: 'pay_flat' }).paymentId, + 'pay_flat', + ); + + const marked: unknown[][] = []; + const pool = { + query: async (_sql: string, params: unknown[]) => { + marked.push(params); + return { rowCount: params[0] === 'pay_9' || params[0] === 'pay_flat' ? 1 : 0, rows: [] }; + }, + } as never; + assert.equal(await applyWebhook(pool, nested), 'paid'); + assert.deepEqual( + marked[0]!.slice(0, 2), + ['pay_9', '0xabc'], + 'settled by the payment id, not evt_', + ); + assert.equal( + await applyWebhook(pool, { event: 'payment.confirmed', payment_id: 'pay_flat' }), + 'paid', + ); + assert.equal( + await applyWebhook(pool, { + id: 'evt', + type: 'payment.detected', + data: { payment_id: 'pay_9', status: 'detected' }, + }), + 'ignored', + ); + assert.equal( + await applyWebhook(pool, { + id: 'evt', + type: 'payment.confirmed', + data: { payment_id: 'pay_nobody', status: 'confirmed' }, + }), + 'unknown', + ); + assert.equal(marked.length, 3, 'detected never reaches the database'); }); test('an HTML answer from CoinPay is reported as such, not parsed', async () => {