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
21 changes: 20 additions & 1 deletion src/core/coinpay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -83,6 +86,11 @@ export interface CoinPayClient {
redirectUrl: string;
idempotencyKey: string;
metadata: Record<string, string>;
/** 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<Payment>;
getPayment(id: string): Promise<Payment | null>;
/** Where a payer goes to pay. */
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 40 additions & 4 deletions src/core/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, unknown>): {
event: string;
paymentId: string;
status: string;
txHash: string | null;
metadata: Record<string, unknown>;
} {
// 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<string, unknown>) : 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<string, unknown>) : {},
};
}

export async function applyWebhook(
pool: pg.Pool,
payload: Record<string, unknown>,
): 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';
}
79 changes: 78 additions & 1 deletion test/billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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');
Expand All @@ -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<string, unknown>)['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 () => {
Expand Down
Loading