/** * Public invoice endpoints. * * POST /api/invoice {listing_id} → create a fresh invoice * GET /api/invoice/ → 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', ''] if (parts.length !== 3) return json(res, 400, { ok: false, reason: 'expected /api/invoice/' }); 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); }