diff --git a/service/admin-pages.js b/service/admin-pages.js index 8b03098..f9479cc 100644 --- a/service/admin-pages.js +++ b/service/admin-pages.js @@ -82,6 +82,7 @@ function adminNav(session, here = '') { Admin ${link('/admin', 'Dashboard')} ${link('/admin/listings', 'Listings')} + ${link('/admin/payments', 'Payments')} ${link('/admin/modes', 'Modes')} ${link('/admin/audit', 'Audit')} @@ -92,6 +93,100 @@ function adminNav(session, here = '') { `; } +/* ------------------------------- payments ------------------------------- */ + +export function paymentsPage({ session, invoices, config, notice = null }) { + return { + title: 'Admin — payments', + description: 'Invoices and payment status.', + here: '/admin/payments', + body: ` +${adminNav(session, '/admin/payments')} + +
+ +${notice ? `
${escapeHtml(notice)}
` : ''} + +
+

Payments

+ +
+
+

${escapeHtml(config.required ? 'required' : 'off')}

+

payment gate — ${escapeHtml(config.required ? 'new listings land as pending' : 'new listings publish immediately')}

+
+
+

$${escapeHtml(String(config.fee_usd))}

+

listing fee per publication

+
+
+

${escapeHtml(config.address || '(not set)')}

+

operator receive address

+
+
+ + ${!config.address ? ` +
+ No receive address configured. Set PROMETHEUS_PAYMENT_ADDRESS + in the service environment and restart. Until then, /api/invoice refuses to issue. +
` : ''} + ${config.required && !config.address ? ` +
+ Payments are required but no address is set. New listings are being created as + pending but cannot be settled. Set the address and restart, + or unset PAYMENT_REQUIRED. +
` : ''} + +

Invoices (${invoices.length})

+ ${invoices.length === 0 ? `

No invoices issued yet.

` : ` + + + + + + + ${invoices.map((i) => renderInvoiceRow(i)).join('\n')} + +
idlistingsatsstatusissuedexpires / paid
`} +
+ +
+

Mempool watcher

+

The mempool watcher that would auto-reconcile incoming payments to pending + invoices is a follow-up milestone. For now, confirm a payment by hand once it lands — + paste the txid into the Mark paid form on an invoice row. The listing becomes visible + on browse the moment the invoice flips to paid.

+
+ +
`, + }; +} + +function renderInvoiceRow(i) { + const paidChip = + i.status === 'paid' ? `paid` : + i.status === 'expired' ? `expired` : + `pending`; + const rightCell = i.status === 'paid' + ? `
${escapeHtml(i.paid_at)}
${escapeHtml((i.paid_txid || '').slice(0, 20))}…
` + : `
${escapeHtml(i.expires_at)}
`; + const action = i.status === 'pending' + ? `
+ + +
` + : `${i.status === 'paid' ? 'by ' + escapeHtml(i.paid_by || '') : ''}`; + return ` + ${escapeHtml(i.id)} + ${escapeHtml(i.listing_id)} + ${i.amount_sat.toLocaleString()} + ${paidChip} + ${escapeHtml(i.issued_at)} + ${rightCell} + ${action} + `; +} + /* ------------------------------- dashboard ------------------------------- */ export function dashboardPage({ session, counts, total, modes, recentListings, recentAudit }) { diff --git a/service/admin.js b/service/admin.js index e8c4c91..a127880 100644 --- a/service/admin.js +++ b/service/admin.js @@ -21,8 +21,12 @@ import { allModes, setMode, MODES } from './modes.js'; import { CATEGORIES, countsByCategory } from './pages-browse.js'; import { logAudit } from './audit.js'; import { - loginPage, dashboardPage, listingsPage, modesPage, auditPage, + loginPage, dashboardPage, listingsPage, modesPage, auditPage, paymentsPage, } from './admin-pages.js'; +import { allInvoices } from './db.js'; +import { + manuallyMarkPaid, LISTING_FEE_USD, RECEIVE_ADDRESS, paymentsRequired, +} from './payments.js'; /* ----------------- dispatch (called from server.js) ----------------- */ @@ -57,12 +61,18 @@ export async function handleAdmin(req, res, url) { return showModes(res, session); } if (url === '/admin/audit') return showAudit(res, session); + if (url === '/admin/payments') return showPayments(res, session); const withdrawMatch = url.match(/^\/admin\/listings\/([^/]+)\/([^/]+)\/withdraw$/); if (withdrawMatch && req.method === 'POST') { return doWithdraw(req, res, session, `${decodeURIComponent(withdrawMatch[1])}/${decodeURIComponent(withdrawMatch[2])}`); } + const markPaidMatch = url.match(/^\/admin\/payments\/([^/]+)\/mark-paid$/); + if (markPaidMatch && req.method === 'POST') { + return doMarkPaid(req, res, session, decodeURIComponent(markPaidMatch[1])); + } + return notFoundHtml(res); } @@ -125,6 +135,16 @@ function showAudit(res, session) { renderPage(res, auditPage({ session, entries })); } +function showPayments(res, session) { + const invoices = allInvoices(); + const config = { + required: paymentsRequired(), + fee_usd: LISTING_FEE_USD, + address: RECEIVE_ADDRESS, + }; + renderPage(res, paymentsPage({ session, invoices, config })); +} + /* ----------------- POST actions ----------------- */ async function doWithdraw(req, res, session, id) { @@ -143,6 +163,23 @@ async function doWithdraw(req, res, session, id) { res.end(); } +async function doMarkPaid(req, res, session, invoiceId) { + const form = await readForm(req); + try { + const invoice = manuallyMarkPaid(invoiceId, { txid: form.txid || '', actor: session.name }); + logAudit({ + actor: session.name, + action: 'mark_invoice_paid', + target: invoiceId, + details: { listing_id: invoice.listing_id, txid: form.txid || null, amount_sat: invoice.amount_sat }, + }); + } catch (err) { + // Fall through to the redirect; the admin can retry from the UI. + } + res.writeHead(302, { location: '/admin/payments' }); + res.end(); +} + async function saveModes(req, res, session) { const form = await readForm(req); diff --git a/service/db.js b/service/db.js index 3e247c1..8106666 100644 --- a/service/db.js +++ b/service/db.js @@ -48,6 +48,11 @@ db.exec(` signed_message TEXT NOT NULL, -- exact bytes the publisher signed created_at TEXT NOT NULL, withdrawn_at TEXT, + -- payment_status is one of: 'free' | 'pending' | 'paid'. Free is the opt-in + -- default (the launch wave underwrites hosting). When PAYMENT_REQUIRED is + -- set, new listings land as 'pending' and browse hides them until the + -- matching invoice settles. + payment_status TEXT NOT NULL DEFAULT 'free', UNIQUE (publisher, slug) ); @@ -56,6 +61,27 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_listings_publisher ON listings(publisher); + -- Invoices for the paid wave. One receiving address serves every invoice; + -- we distinguish them by amount — each invoice has a random few-sat jitter + -- on top of the base USD-equivalent, so no two in-flight invoices expect + -- the same number of sats to the same address at the same time. + CREATE TABLE IF NOT EXISTS invoices ( + id TEXT PRIMARY KEY, -- opaque base32 token + listing_id TEXT NOT NULL REFERENCES listings(id) ON DELETE CASCADE, + address TEXT NOT NULL, -- the operator receive address + amount_sat INTEGER NOT NULL, -- exact sats expected + amount_usd TEXT NOT NULL, -- the USD figure the quote was based on + rate_sat_per_usd INTEGER NOT NULL, -- the price snapshot used for amount_sat + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, -- 20 min after issued_at + status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'paid' | 'expired' + paid_at TEXT, + paid_txid TEXT, + paid_by TEXT -- 'manual' | 'watcher' + ); + CREATE INDEX IF NOT EXISTS idx_invoices_listing ON invoices(listing_id); + CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status); + -- Operator-visible settings. Admin reads/writes through /admin/modes etc. -- Values are text; callers coerce when they read. CREATE TABLE IF NOT EXISTS settings ( @@ -96,13 +122,19 @@ const stmtInsert = db.prepare(` const stmtGet = db.prepare(`SELECT * FROM listings WHERE id = ?`); +// "Visible" rows are those that would be shown on browse — not withdrawn, not +// awaiting payment. 'free' and 'paid' both qualify as settled. const stmtAllActive = db.prepare(` - SELECT * FROM listings WHERE withdrawn_at IS NULL ORDER BY created_at DESC + SELECT * FROM listings + WHERE withdrawn_at IS NULL AND payment_status IN ('free', 'paid') + ORDER BY created_at DESC `); const stmtByCategory = db.prepare(` SELECT * FROM listings - WHERE category = ? AND withdrawn_at IS NULL + WHERE category = ? + AND withdrawn_at IS NULL + AND payment_status IN ('free', 'paid') ORDER BY created_at DESC `); @@ -111,12 +143,13 @@ const stmtByPublisher = db.prepare(` `); const stmtCountActive = db.prepare(` - SELECT COUNT(*) AS n FROM listings WHERE withdrawn_at IS NULL + SELECT COUNT(*) AS n FROM listings + WHERE withdrawn_at IS NULL AND payment_status IN ('free', 'paid') `); const stmtCountByCategory = db.prepare(` SELECT category, COUNT(*) AS n FROM listings - WHERE withdrawn_at IS NULL + WHERE withdrawn_at IS NULL AND payment_status IN ('free', 'paid') GROUP BY category `); @@ -226,3 +259,75 @@ export function insertAuditRow(row) { export function recentAudit(limit = 50) { return stmtAuditRecent.all(limit); } + +/* ----------------------- invoices ----------------------- */ + +const stmtInsertListingWithStatus = db.prepare(` + INSERT INTO listings ( + id, publisher, slug, name, description, category, version, + homepage, icon, download_url, download_sha256, download_size, + scan_report, signer_address, signature, signed_message, created_at, + payment_status + ) VALUES ( + @id, @publisher, @slug, @name, @description, @category, @version, + @homepage, @icon, @download_url, @download_sha256, @download_size, + @scan_report, @signer_address, @signature, @signed_message, @created_at, + @payment_status + ) +`); + +/** Insert a listing while explicitly setting payment_status (for paid mode). */ +export function insertListingWithPaymentStatus(row) { + stmtInsertListingWithStatus.run(row); +} + +const stmtListingSetPaid = db.prepare(` + UPDATE listings SET payment_status = 'paid' + WHERE id = ? AND payment_status != 'paid' +`); + +export function markListingPaid(id) { + const info = stmtListingSetPaid.run(id); + return info.changes > 0; +} + +const stmtInvInsert = db.prepare(` + INSERT INTO invoices ( + id, listing_id, address, amount_sat, amount_usd, rate_sat_per_usd, + issued_at, expires_at + ) VALUES ( + @id, @listing_id, @address, @amount_sat, @amount_usd, @rate_sat_per_usd, + @issued_at, @expires_at + ) +`); +const stmtInvGet = db.prepare(`SELECT * FROM invoices WHERE id = ?`); +const stmtInvByListing = db.prepare(` + SELECT * FROM invoices WHERE listing_id = ? ORDER BY issued_at DESC +`); +const stmtInvAll = db.prepare(`SELECT * FROM invoices ORDER BY issued_at DESC`); +const stmtInvMarkPaid = db.prepare(` + UPDATE invoices + SET status = 'paid', paid_at = @paid_at, paid_txid = @paid_txid, paid_by = @paid_by + WHERE id = @id AND status = 'pending' +`); +const stmtInvMarkExpired = db.prepare(` + UPDATE invoices SET status = 'expired' + WHERE id = ? AND status = 'pending' AND datetime(expires_at) < datetime('now') +`); +const stmtInvPendingByAddr = db.prepare(` + SELECT * FROM invoices + WHERE address = ? AND amount_sat = ? AND status = 'pending' + AND datetime(expires_at) >= datetime('now') + ORDER BY issued_at ASC + LIMIT 1 +`); + +export function insertInvoice(row) { stmtInvInsert.run(row); } +export function getInvoice(id) { return stmtInvGet.get(id) || null; } +export function invoicesForListing(id) { return stmtInvByListing.all(id); } +export function allInvoices() { return stmtInvAll.all(); } +export function markInvoicePaid(args) { const info = stmtInvMarkPaid.run(args); return info.changes > 0; } +export function markInvoiceExpired(id) { const info = stmtInvMarkExpired.run(id); return info.changes > 0; } +export function matchPendingInvoice(address, amount_sat) { + return stmtInvPendingByAddr.get(address, amount_sat) || null; +} diff --git a/service/invoice-api.js b/service/invoice-api.js new file mode 100644 index 0000000..d2abc27 --- /dev/null +++ b/service/invoice-api.js @@ -0,0 +1,87 @@ +/** + * 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); +} diff --git a/service/listings.js b/service/listings.js index 9e3b767..c87f2b3 100644 --- a/service/listings.js +++ b/service/listings.js @@ -32,7 +32,9 @@ import { allActiveListings, listingsByCategory, listingsByPublisher, + insertListingWithPaymentStatus, } from './db.js'; +import { paymentsRequired } from './payments.js'; const CATEGORIES = new Set([ 'communication', 'privacy', 'wallets', 'games', @@ -102,6 +104,9 @@ export async function handleListingCreate(req, res) { const slug = String(body.slug).toLowerCase(); const id = `${name}/${slug}`; + // In paid mode the listing lands as 'pending' and is hidden from browse + // until an invoice settles against it; in free mode it is immediately live. + const paid = paymentsRequired(); const row = { id, publisher: name, @@ -120,10 +125,11 @@ export async function handleListingCreate(req, res) { signature: String(body.signature), signed_message: message, created_at: now, + payment_status: paid ? 'pending' : 'free', }; try { - insertListing(row); + insertListingWithPaymentStatus(row); } catch (err) { if (/UNIQUE constraint failed/.test(String(err?.message))) { return json(res, 409, { @@ -138,6 +144,7 @@ export async function handleListingCreate(req, res) { ok: true, id, listing: toPublic(row), + ...(paid ? { payment_required: true, note: 'POST /api/invoice with this listing_id to receive a payment request' } : {}), }); } diff --git a/service/payments.js b/service/payments.js new file mode 100644 index 0000000..10b15d9 --- /dev/null +++ b/service/payments.js @@ -0,0 +1,144 @@ +/** + * Invoices for the paid wave. + * + * Design in short: + * One operator receive address (PROMETHEUS_PAYMENT_ADDRESS) collects every + * payment. We distinguish invoices by amount — the base is the USD-pegged + * fee (PROMETHEUS_LISTING_FEE_USD, default 1 USD), plus a random 1..256 sat + * jitter so two in-flight invoices never expect the same amount at the same + * time. The mempool watcher matches incoming (address, amount) pairs to a + * pending invoice and marks it paid. + * + * This file owns invoice creation and status transitions. The mempool watcher + * (service/payment-watcher.js) is a separate concern — it calls `tryMatchPayment()` + * here with (address, amount, txid) as the only state it needs to tell us. + * + * The HD-wallet alternative (one address per invoice) is specifically NOT what + * we do, because deriving + spending from many addresses requires a seeded + * wallet on the server and a signing operation to consolidate. One address + + * amount-matching is the shape BitPay used for years; it is simple, auditable + * and only costs the admin a bit of accounting on the UTXO side. + */ + +import { randomBytes } from 'node:crypto'; +import { + insertInvoice, getInvoice, markInvoicePaid, markInvoiceExpired, + matchPendingInvoice, markListingPaid, getListing, invoicesForListing, +} from './db.js'; +import { usdToSats } from './price.js'; + +const INVOICE_TTL_MS = 20 * 60 * 1000; // 20 min, matches the quote lock +const JITTER_MAX = 256; // random 1..256 sats added to the base +const DEFAULT_FEE_USD = 1; + +export const RECEIVE_ADDRESS = process.env.PROMETHEUS_PAYMENT_ADDRESS || ''; +export const LISTING_FEE_USD = Number(process.env.PROMETHEUS_LISTING_FEE_USD) || DEFAULT_FEE_USD; +export const PAYMENT_REQUIRED = process.env.PAYMENT_REQUIRED === '1'; + +export function paymentsConfigured() { + return RECEIVE_ADDRESS.length > 0; +} + +export function paymentsRequired() { + return PAYMENT_REQUIRED && paymentsConfigured(); +} + +/** + * Create a new invoice for a listing. Returns the invoice row. + */ +export async function createInvoice(listingId) { + if (!paymentsConfigured()) { + throw new Error('payments not configured — set PROMETHEUS_PAYMENT_ADDRESS'); + } + const listing = getListing(listingId); + if (!listing) throw new Error(`no such listing: ${listingId}`); + + const { sats: baseSats, rate_sat_per_usd } = await usdToSats(LISTING_FEE_USD); + const jitter = (randomBytes(1)[0] % JITTER_MAX) + 1; + const amount_sat = baseSats + jitter; + + const now = Date.now(); + const row = { + id: freshInvoiceId(), + listing_id: listingId, + address: RECEIVE_ADDRESS, + amount_sat, + amount_usd: String(LISTING_FEE_USD), + rate_sat_per_usd, + issued_at: new Date(now).toISOString(), + expires_at: new Date(now + INVOICE_TTL_MS).toISOString(), + }; + insertInvoice(row); + return { ...row, status: 'pending' }; +} + +/** + * Called by the mempool watcher when it sees a (presumably-settlement) tx. + * Matches to the single pending invoice with that (address, amount) and marks + * it paid, plus flips the listing's payment_status. Returns the invoice row + * on success, null on no-match. + */ +export function tryMatchPayment({ address, amount_sat, txid, by = 'watcher' }) { + const invoice = matchPendingInvoice(address, amount_sat); + if (!invoice) return null; + + const paid_at = new Date().toISOString(); + const ok = markInvoicePaid({ id: invoice.id, paid_at, paid_txid: txid, paid_by: by }); + if (!ok) return null; + + markListingPaid(invoice.listing_id); + return { ...invoice, status: 'paid', paid_at, paid_txid: txid, paid_by: by }; +} + +/** + * Manual admin confirmation path — same effect as the watcher reconciling a + * payment, but attributed differently in the audit trail. + */ +export function manuallyMarkPaid(invoiceId, { txid, actor }) { + const invoice = getInvoice(invoiceId); + if (!invoice) throw new Error(`no such invoice: ${invoiceId}`); + if (invoice.status === 'paid') return invoice; + + const paid_at = new Date().toISOString(); + markInvoicePaid({ + id: invoiceId, paid_at, + paid_txid: txid || '(no txid supplied)', + paid_by: `manual:${actor}`, + }); + markListingPaid(invoice.listing_id); + return { ...invoice, status: 'paid', paid_at, paid_txid: txid, paid_by: `manual:${actor}` }; +} + +/** + * Expire a specific invoice if its deadline has passed. Harmless on an + * already-paid or already-expired one. Returns true if a transition happened. + */ +export function reapInvoice(id) { + return markInvoiceExpired(id); +} + +/** + * Public shape of an invoice for the UI / API. + */ +export function toPublic(invoice) { + return { + id: invoice.id, + listing_id: invoice.listing_id, + address: invoice.address, + amount_sat: invoice.amount_sat, + amount_bch: (invoice.amount_sat / 1e8).toFixed(8), + amount_usd: invoice.amount_usd, + issued_at: invoice.issued_at, + expires_at: invoice.expires_at, + status: invoice.status, + paid_at: invoice.paid_at || undefined, + paid_txid: invoice.paid_txid || undefined, + paid_by: invoice.paid_by || undefined, + bitcoincash_uri: `bitcoincash:${invoice.address}?amount=${(invoice.amount_sat / 1e8).toFixed(8)}&label=${encodeURIComponent('Prometheus listing: ' + invoice.listing_id)}`, + }; +} + +/** Fresh base32-ish id. 12 bytes = 96 bits = plenty for an invoice. */ +function freshInvoiceId() { + return randomBytes(12).toString('base64url'); +} diff --git a/service/price.js b/service/price.js new file mode 100644 index 0000000..39f8d0d --- /dev/null +++ b/service/price.js @@ -0,0 +1,48 @@ +/** + * BCH ↔ USD price lookup, with a 60-second in-memory cache. + * + * We pull from CoinGecko by default — public, no API key, generous for the + * once-a-minute cadence we need. The price feeds the invoice quote; a + * publisher's quote is locked at issue time and does not re-quote while they + * decide whether to pay. + * + * On fetch failure we return the last good value if it is under 15 minutes + * old. Beyond that, we throw — handing out invoices with stale prices is + * worse than refusing to quote until the oracle recovers. + */ + +const CACHE_TTL_MS = 60_000; +const STALE_FALLBACK_MS = 15 * 60_000; +const SOURCE = process.env.PROMETHEUS_PRICE_SOURCE + || 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin-cash&vs_currencies=usd'; + +let cached = null; // { price_usd, at } + +export async function bchUsd() { + const now = Date.now(); + if (cached && now - cached.at < CACHE_TTL_MS) return cached.price_usd; + + try { + const resp = await fetch(SOURCE, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const body = await resp.json(); + const p = Number(body?.['bitcoin-cash']?.usd); + if (!Number.isFinite(p) || p <= 0) throw new Error('bad price in response'); + cached = { price_usd: p, at: now }; + return p; + } catch (err) { + if (cached && now - cached.at < STALE_FALLBACK_MS) return cached.price_usd; + throw new Error(`price oracle: ${err?.message || err}`); + } +} + +/** USD → sats, using the current (or last-good) BCH/USD price. */ +export async function usdToSats(usd) { + const price = await bchUsd(); // USD per BCH + const bch = Number(usd) / price; // BCH per `usd` dollars + const sats = Math.round(bch * 1e8); // satoshis per BCH = 1e8 + return { sats, rate_sat_per_usd: Math.round(1e8 / price), price_usd: price }; +} diff --git a/service/server.js b/service/server.js index cbd44da..bd6cdf9 100644 --- a/service/server.js +++ b/service/server.js @@ -30,6 +30,7 @@ import { categoryPage, appPage, CATEGORIES } from './pages-browse.js'; import { handleAdmin } from './admin.js'; import { renderSitemap, renderRobots } from './seo.js'; import { allActiveListings } from './db.js'; +import { handleInvoiceCreate, handleInvoiceGet } from './invoice-api.js'; const PORT = Number(process.env.PORT) || 3700; const BIND = process.env.BIND || '127.0.0.1'; @@ -88,6 +89,10 @@ const server = createServer(async (req, res) => { handleListingIndex(req, res, req.url); } else if (url.startsWith('/api/listings/')) { handleListingGet(req, res, req.url); + } else if (url === '/api/invoice') { + await handleInvoiceCreate(req, res); + } else if (url.startsWith('/api/invoice/')) { + handleInvoiceGet(req, res, req.url); } else if (url === '/healthz') { await serveHealth(res); } else if (url === '/.well-known/prometheus-service-key.json') {