Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
87 lines
3 KiB
JavaScript
87 lines
3 KiB
JavaScript
/**
|
|
* Public invoice endpoints.
|
|
*
|
|
* POST /api/invoice {listing_id} → create a fresh invoice
|
|
* GET /api/invoice/<id> → status
|
|
*
|
|
* No auth. The invoice contents are a payment request to the operator's own
|
|
* address — paying the exact amount shown settles the listing it was issued
|
|
* for. If somebody creates an unused invoice, they have an unused invoice;
|
|
* nothing moves until the mempool watcher (or an admin) sees the payment.
|
|
*/
|
|
|
|
import {
|
|
createInvoice,
|
|
paymentsConfigured,
|
|
toPublic as invoiceToPublic,
|
|
reapInvoice,
|
|
} from './payments.js';
|
|
import { getInvoice, getListing } from './db.js';
|
|
|
|
export async function handleInvoiceCreate(req, res) {
|
|
if (req.method !== 'POST') return json(res, 405, { ok: false, reason: 'method not allowed' });
|
|
if (!paymentsConfigured()) {
|
|
return json(res, 503, { ok: false, reason: 'payments are not enabled on this instance' });
|
|
}
|
|
|
|
let body;
|
|
try { body = await readJson(req); }
|
|
catch (err) { return json(res, 400, { ok: false, reason: String(err?.message || err) }); }
|
|
|
|
const listingId = String(body?.listing_id || '').trim();
|
|
if (!listingId) return json(res, 400, { ok: false, reason: '`listing_id` required' });
|
|
const listing = getListing(listingId);
|
|
if (!listing) return json(res, 404, { ok: false, reason: 'no such listing' });
|
|
|
|
try {
|
|
const invoice = await createInvoice(listingId);
|
|
return json(res, 201, { ok: true, invoice: invoiceToPublic(invoice) });
|
|
} catch (err) {
|
|
return json(res, 500, { ok: false, reason: String(err?.message || err) });
|
|
}
|
|
}
|
|
|
|
export function handleInvoiceGet(req, res, url) {
|
|
const parts = new URL(url, 'http://localhost').pathname.split('/').filter(Boolean);
|
|
// ['api', 'invoice', '<id>']
|
|
if (parts.length !== 3) return json(res, 400, { ok: false, reason: 'expected /api/invoice/<id>' });
|
|
const id = parts[2];
|
|
const invoice = getInvoice(id);
|
|
if (!invoice) return json(res, 404, { ok: false, reason: 'no such invoice' });
|
|
|
|
// A pending invoice past its deadline flips to 'expired' here lazily — the
|
|
// mempool watcher does not need a periodic sweeper if every status check
|
|
// takes care of its own invoice.
|
|
if (invoice.status === 'pending' && Date.parse(invoice.expires_at) < Date.now()) {
|
|
reapInvoice(id);
|
|
invoice.status = 'expired';
|
|
}
|
|
return json(res, 200, { ok: true, invoice: invoiceToPublic(invoice) });
|
|
}
|
|
|
|
/* -------- helpers -------- */
|
|
|
|
const MAX_BODY = 32 * 1024;
|
|
|
|
async function readJson(req) {
|
|
const chunks = [];
|
|
let total = 0;
|
|
for await (const chunk of req) {
|
|
total += chunk.length;
|
|
if (total > MAX_BODY) throw new Error('body too large');
|
|
chunks.push(chunk);
|
|
}
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
if (!body.trim()) throw new Error('empty request body');
|
|
return JSON.parse(body);
|
|
}
|
|
|
|
function json(res, status, body) {
|
|
const payload = JSON.stringify(body);
|
|
res.writeHead(status, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'cache-control': 'no-store',
|
|
'content-length': Buffer.byteLength(payload),
|
|
});
|
|
res.end(payload);
|
|
}
|