From 0aa648706e60fda6ec1d57df12d13f08c3a8d316 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 13 Jun 2026 13:36:05 -0500 Subject: [PATCH] Add self-serve billing: tiers, credits, BTCPay and Zaprite --- server/btcpay-client.js | 305 ++++++++++ server/credits.js | 546 +++++++++++++++-- server/job-credits.js | 178 +++++- server/pricing.js | 17 +- server/routes/balance.js | 69 ++- server/routes/btcpay-setup.js | 626 ++++++++++++++++++++ server/routes/credits.js | 748 ++++++++++++++++++++++++ server/routes/user-tier.js | 394 +++++++++++++ server/routes/zaprite-webhook.js | 135 +++++ server/test/credits.test.js | 136 +++++ server/test/tier-expiry.test.js | 79 +++ server/test/zaprite.test.js | 52 ++ server/zaprite-client.js | 160 +++++ startos/actions/setBtcpayConnection.ts | 108 ++++ startos/actions/setCreditPackages.ts | 110 ++++ startos/actions/setTierPrices.ts | 93 +++ startos/actions/setZapriteConnection.ts | 141 +++++ 17 files changed, 3781 insertions(+), 116 deletions(-) create mode 100644 server/btcpay-client.js create mode 100644 server/routes/btcpay-setup.js create mode 100644 server/routes/credits.js create mode 100644 server/routes/user-tier.js create mode 100644 server/routes/zaprite-webhook.js create mode 100644 server/test/credits.test.js create mode 100644 server/test/tier-expiry.test.js create mode 100644 server/test/zaprite.test.js create mode 100644 server/zaprite-client.js create mode 100644 startos/actions/setBtcpayConnection.ts create mode 100644 startos/actions/setCreditPackages.ts create mode 100644 startos/actions/setTierPrices.ts create mode 100644 startos/actions/setZapriteConnection.ts diff --git a/server/btcpay-client.js b/server/btcpay-client.js new file mode 100644 index 0000000..7f5d9ef --- /dev/null +++ b/server/btcpay-client.js @@ -0,0 +1,305 @@ +// Thin wrapper around BTCPay's Greenfield API for the Recap credit- +// purchase flow. Three things this module owns: +// +// 1. createInvoice() — POST /api/v1/stores/{storeId}/invoices to +// mint an invoice priced in sats. Embeds install_id + credits +// in invoice metadata so the webhook later knows which install +// and how many credits to grant. +// +// 2. getInvoice() — GET /api/v1/stores/{storeId}/invoices/{id} for +// polling settlement state from the Recap UI side. +// +// 3. validateWebhookSignature() — HMAC-SHA256 check on the +// BTCPay-Sig header so we only trust webhook posts that came +// from BTCPay with the operator-configured secret. +// +// Operator config (set via StartOS "Set BTCPay Connection" action): +// relay_btcpay_base_url e.g. https://btcpay.keysat.xyz +// relay_btcpay_store_id uuid-shaped store id from BTCPay +// relay_btcpay_api_key Greenfield token (scope: btcpay.store.canCreateInvoice + canViewInvoices) +// relay_btcpay_webhook_secret shared secret for webhook HMAC + +import crypto from "crypto"; + +// All sats here are quoted in the BTCPay store's display currency +// (which the operator set to "SATS"). BTCPay accepts the integer +// amount + currency code "SATS" and prices the invoice in sats +// directly — no fiat conversion involved. +const CURRENCY = "SATS"; + +export class BtcPayError extends Error { + constructor(status, message, body) { + super(message); + this.name = "BtcPayError"; + this.status = status; + this.body = body; + } +} + +// Create a new invoice for `sats` SATS, tagging it with the install +// + credit-package metadata so the webhook (later) knows what to +// grant. Returns the raw Greenfield invoice object — caller picks +// out `id`, `checkoutLink`, `expirationTime`, etc. +export async function createInvoice({ + baseURL, + storeId, + apiKey, + sats, + credits, + installId, + // Optional license fingerprint captured at buy time. When the buyer + // has an active Pro/Max license, the credit-grant should land on + // their license-keyed credit pool (which is shared across every + // install that uses the license) rather than the install-keyed pool. + // Stashed in invoice metadata so the webhook handler can route the + // credit to the right pool even after a restart. + licenseFingerprint = null, + packageLabel = "", + redirectURL = null, + redirectAutomatically = false, +}) { + assertConfigured({ baseURL, storeId, apiKey }); + const body = { + amount: String(sats), + currency: CURRENCY, + metadata: { + // Top-level metadata fields are surfaced in webhook events and + // are what the webhook handler reads to credit the install. + install_id: installId, + license_fingerprint: licenseFingerprint || null, + credits, + package_label: packageLabel, + product: "recap_credits", + }, + checkout: { + // Buyers should be able to leave the checkout page back to + // Recap after a successful payment. The Recap UI is also + // polling so the modal will update even if they don't follow + // this redirect; this is a courtesy that closes the loop + // when the buyer left the BTCPay tab in the foreground. + redirectURL: redirectURL || undefined, + // Auto-redirect once the invoice settles. Without this the + // buyer has to click "Return to merchant" themselves; with it + // the checkout page navigates back to the redirectURL as + // soon as payment confirms. + redirectAutomatically: redirectAutomatically === true, + // Speed up small-amount flows by enabling Lightning. Operator + // has Lightning configured on this store per their setup + // message; we just nudge the checkout to prefer it. + defaultPaymentMethod: "BTC-LightningLike", + }, + }; + return postInvoice({ baseURL, storeId, apiKey, body }); +} + +// Create a BTCPay invoice for a self-serve Pro/Max subscription period. The +// metadata (product:"recap_tier_subscription", user_id, tier, period_days) +// is what the webhook reads to call extendUserTier on settlement. +export async function createTierInvoice({ + baseURL, + storeId, + apiKey, + sats, + userId, + tier, + periodDays, + redirectURL = null, + redirectAutomatically = false, +}) { + assertConfigured({ baseURL, storeId, apiKey }); + const body = { + amount: String(sats), + currency: CURRENCY, + metadata: { + product: "recap_tier_subscription", + user_id: userId, + tier, + period_days: periodDays, + }, + checkout: { + redirectURL: redirectURL || undefined, + redirectAutomatically: redirectAutomatically === true, + defaultPaymentMethod: "BTC-LightningLike", + }, + }; + return postInvoice({ baseURL, storeId, apiKey, body }); +} + +// Shared invoice POST. Both createInvoice (credits) + createTierInvoice +// (subscriptions) build their own body and hand it here. +async function postInvoice({ baseURL, storeId, apiKey, body }) { + const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices`; + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `token ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch {} + if (!res.ok) { + throw new BtcPayError( + res.status, + `BTCPay createInvoice ${res.status}: ${text?.slice(0, 300) || res.statusText}`, + parsed + ); + } + return parsed; +} + +export async function getInvoice({ baseURL, storeId, apiKey, invoiceId }) { + assertConfigured({ baseURL, storeId, apiKey }); + const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices/${encodeURIComponent(invoiceId)}`; + const res = await fetch(url, { + headers: { Authorization: `token ${apiKey}` }, + signal: AbortSignal.timeout(10_000), + }); + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch {} + if (!res.ok) { + throw new BtcPayError( + res.status, + `BTCPay getInvoice ${res.status}: ${text?.slice(0, 300) || res.statusText}`, + parsed + ); + } + return parsed; +} + +// Fetch the per-payment-method breakdown for an invoice. This is the +// endpoint that returns the actual BOLT11 invoice string + Lightning +// payment link, which we need to render an inline QR on the Recap +// side (Phase 1 of the inline-payment migration). Caller decides +// which paymentMethod to extract from the array — for the credits +// flow we only care about BTC-LightningNetwork. +// +// Note: BTCPay generates the Lightning invoice asynchronously on +// some store configurations, so there's a small window after +// createInvoice() where the LN destination may not yet be populated. +// Caller can retry once with a short backoff if needed, or fall +// back to the checkout_url for the buyer to use BTCPay's hosted page. +export async function getInvoicePaymentMethods({ + baseURL, + storeId, + apiKey, + invoiceId, +}) { + assertConfigured({ baseURL, storeId, apiKey }); + const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices/${encodeURIComponent(invoiceId)}/payment-methods`; + const res = await fetch(url, { + headers: { Authorization: `token ${apiKey}` }, + signal: AbortSignal.timeout(10_000), + }); + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch {} + if (!res.ok) { + throw new BtcPayError( + res.status, + `BTCPay getInvoicePaymentMethods ${res.status}: ${text?.slice(0, 300) || res.statusText}`, + parsed + ); + } + return Array.isArray(parsed) ? parsed : []; +} + +// Helper: pick the Lightning BOLT11 + payment link out of a +// payment-methods array. Returns { bolt11, paymentLink } or null +// if no usable LN method exists (e.g., store doesn't have LN +// configured, or BTCPay hasn't generated it yet). The shape varies +// across BTCPay versions, particularly between 1.x and 2.x: +// • BTCPay 1.x uses `paymentMethod` with values like +// "BTC-LightningNetwork" or "BTC_LightningLike" +// • BTCPay 2.x uses `paymentMethodId` with values like +// "BTC-LN" (BOLT11-direct) or "BTC-LNURL" (LNURL-pay) +// We accept either field name. Among LN-ish entries we prefer +// BOLT11-direct over LNURL because every Lightning wallet supports +// BOLT11 but LNURL needs an LNURL-pay capable wallet (less common, +// and BTCPay's LNURL entry sometimes has a null destination until +// the buyer initiates a scan, which we can't render to a QR). +export function pickLightningFromPaymentMethods(methods) { + if (!Array.isArray(methods)) return null; + const candidates = methods.filter((m) => { + const id = String(m?.paymentMethodId || m?.paymentMethod || ""); + return ( + id === "BTC-LN" || + id === "BTC-LightningNetwork" || + id === "BTC_LightningLike" || + /lightning/i.test(id) || + /-LN$/i.test(id) || + /-LNURL$/i.test(id) + ); + }); + // Prefer BOLT11-direct over LNURL. Stable sort: LNURL last, + // everything else preserves original order. + candidates.sort((a, b) => { + const aId = String(a?.paymentMethodId || a?.paymentMethod || ""); + const bId = String(b?.paymentMethodId || b?.paymentMethod || ""); + const aLnurl = /lnurl/i.test(aId) ? 1 : 0; + const bLnurl = /lnurl/i.test(bId) ? 1 : 0; + return aLnurl - bLnurl; + }); + for (const m of candidates) { + const destination = String(m?.destination || "").trim(); + if (!destination) continue; // skip empty (BTC-LNURL before scan) + if (!/^ln[a-z0-9]+$/i.test(destination)) continue; // must look like a bolt11 + const paymentLink = + typeof m.paymentLink === "string" && m.paymentLink + ? m.paymentLink + : `lightning:${destination}`; + return { bolt11: destination, paymentLink }; + } + return null; +} + +// Webhook signature validation. BTCPay sends `BTCPay-Sig: sha256=` +// where is HMAC-SHA256(rawBody, webhookSecret). We re-compute +// and constant-time-compare. Returns true on match, false otherwise. +// +// Caller is responsible for passing the RAW request body bytes (not +// the parsed JSON object) — Express's body-parser doesn't preserve +// the exact bytes so we register a raw-body capture for the webhook +// route specifically. See routes/credits.js. +export function validateWebhookSignature({ rawBody, signatureHeader, secret }) { + if (!secret) return false; + if (typeof signatureHeader !== "string") return false; + const m = signatureHeader.match(/^sha256\s*=\s*([0-9a-f]{64})$/i); + if (!m) return false; + const expected = crypto + .createHmac("sha256", secret) + .update(rawBody) + .digest("hex"); + const provided = m[1].toLowerCase(); + if (provided.length !== expected.length) return false; + try { + return crypto.timingSafeEqual( + Buffer.from(provided, "hex"), + Buffer.from(expected, "hex") + ); + } catch { + return false; + } +} + +function assertConfigured({ baseURL, storeId, apiKey }) { + if (!baseURL || !storeId || !apiKey) { + throw new Error( + "BTCPay is not configured — set base URL, store ID, and API key via the StartOS 'Set BTCPay Connection' action" + ); + } +} + +function trimSlash(s) { + return (s || "").replace(/\/$/, ""); +} diff --git a/server/credits.js b/server/credits.js index c8625cb..600be0d 100644 --- a/server/credits.js +++ b/server/credits.js @@ -1,21 +1,40 @@ -// Credit ledger keyed by install-id. JSON-file backed (single file at -// /data/credits.json). Write throughput is low — at most one mutation -// per relay request — so a plain JSON file with mutex-style serial -// writes is plenty. Swap to SQLite if a single relay starts seeing -// dozens of req/sec sustained. +// Credit ledger. JSON-file backed (single file at /data/credits.json). +// Write throughput is low — at most one mutation per relay request — +// so a plain JSON file with mutex-style serial writes is plenty. Swap +// to SQLite if a single relay starts seeing dozens of req/sec sustained. // -// Per-install row shape: -// { -// install_id: "uuid", -// tier_snapshot: "core" | "pro" | "max", // last-seen tier -// lifetime_consumed: number, // total Core credits ever used -// lifetime_gemini_consumed: number, // Core credits served by Gemini -// last_renewal_at: ISO-8601 string, // start of current billing period -// monthly_consumed: number, // total this period (paid tiers) -// monthly_gemini_consumed: number, // Gemini-only this period -// last_active_at: ISO-8601 string, -// } +// ── Key model ──────────────────────────────────────────────────────── +// Free-tier (Core) rows are keyed by install_id. Paid-tier (Pro / Max) +// rows are keyed by a stable fingerprint of the license key — so a +// single Pro license activated on two installs (e.g. cloud account +// AND self-hosted instance) drains the SAME monthly pool instead of +// getting two independent budgets. // +// `getCreditKey({ installId, license })` resolves to: +// - `lic:` when license.tier is "pro" or "max" +// - `inst:` otherwise (anonymous, invalid, or Core) +// +// Rows still carry `install_id` (last-seen install that touched them) +// for diagnostics, but the LEDGER KEY is what determines pool identity. +// +// ── Migration / backwards compatibility ────────────────────────────── +// Existing pre-refactor rows are keyed by bare install_id. We leave +// them in place — they continue to serve correctly for Core users +// (whose key is now `inst:`, which still matches the legacy +// bare-installId row because getOrCreateRow looks up by the resolved +// key first and falls back to the legacy installId only when no +// `inst:<...>` row exists yet — see lookupRow() below). +// +// Existing Pro/Max installs keep using their legacy installId-keyed +// row until they next interact with the relay AFTER the new build is +// live. The first such interaction will create a fresh `lic:` +// row; the old installId row continues to exist as orphaned ledger +// state. Self-heals within one billing cycle for licensed users. +// NO retroactive migration — operator policy is "tolerate the value +// leak for one month rather than risk a buggy bulk-migration on real +// customer balances". +// +// ── Billing-period anchor ──────────────────────────────────────────── // Billing periods are CALENDAR-ANNIVERSARY, not calendar-month. A user // whose first paid request lands on the 17th of October renews on the // 17th of every subsequent month — not the 1st. This matches how typical @@ -27,12 +46,52 @@ import fs from "fs/promises"; import path from "path"; +import crypto from "crypto"; let dataDir = "/data"; let ledgerPath = "/data/credits.json"; let ledger = { rows: {} }; let writing = null; // serializes concurrent writes +// ── License fingerprint helpers ───────────────────────────────────── +// Centralized hash so every caller (credits.js, job-credits.js, +// audit-log entries) derives the SAME identifier from the SAME license. +// 16 hex chars = 64 bits — plenty against collision and short enough +// to stay readable in log lines and admin-dashboard tables. +// +// The "raw key" we hash is the licenseUuid resolved by keysat-client +// when available, otherwise a stable stringified form of the resolved +// license object. licenseUuid is the field set by the offline verifier +// and is stable across reactivations and across machines — exactly +// what we want for a per-user pool identifier. +export function licenseFingerprint(license) { + if (!license) return null; + const seed = license.licenseUuid || license.license_uuid || null; + if (!seed) return null; + return crypto + .createHash("sha256") + .update(String(seed)) + .digest("hex") + .slice(0, 16); +} + +// Resolve the ledger-key for a given (install, license) pair. Paid +// tiers route to `lic:` so a single license activated on multiple +// installs shares ONE monthly pool. Anonymous / invalid / Core (and +// any paid case missing a fingerprint, e.g. licenseUuid couldn't be +// extracted) fall back to the install-scoped key `inst:`. +export function getCreditKey({ installId, license }) { + const tier = license?.tier || "core"; + if (tier === "pro" || tier === "max") { + const fp = licenseFingerprint(license); + if (fp) return `lic:${fp}`; + } + if (!installId) { + throw new Error("getCreditKey: installId required (no license fingerprint either)"); + } + return `inst:${installId}`; +} + export async function initCredits({ dataDir: dd }) { if (dd) dataDir = dd; ledgerPath = path.join(dataDir, "credits.json"); @@ -149,10 +208,17 @@ function ensureRenewalRollover(row) { return rolled; } -function blankRow(installId) { +function blankRow({ installId, license }) { const now = new Date(); + const fp = licenseFingerprint(license); return { - install_id: installId, + // install_id captures the LAST install that touched this row. + // For `lic:` rows that's whichever install most recently + // committed against the license; for `inst:` rows it + // matches the key. Kept on the row for diagnostics / dashboard + // display — not used for ledger lookup. + install_id: installId || null, + license_fingerprint: fp, tier_snapshot: "core", lifetime_consumed: 0, lifetime_gemini_consumed: 0, @@ -161,9 +227,38 @@ function blankRow(installId) { monthly_consumed: 0, monthly_gemini_consumed: 0, last_active_at: now.toISOString(), + // Top-up credits the user bought via BTCPay. Never expire (per + // operator policy). Consumed AFTER the tier allotment so the + // user always gets their monthly/lifetime allowance first. + purchased_balance: 0, + purchased_total_ever: 0, }; } +// Look up a row by its resolved credit-key, with one back-compat fallback: +// when the key is `inst:` and no such row exists, check for +// a legacy row stored under bare `` (pre-refactor format). +// Returns { row, key } where `key` is the actual key under which the +// row lives in ledger.rows. Returns { row: null } when nothing was found. +// +// The fallback is one-directional only: we DO NOT promote a legacy +// install-keyed row to a `lic:` key just because the caller now +// has a license. That's intentional — see the migration note at the +// top of the file. A previously-Pro install that re-presents its +// license will silently start a fresh license-keyed pool; its old +// install-keyed row stays put with whatever monthly_consumed it had, +// usable again next time it falls back to Core. +function lookupRow(key) { + if (ledger.rows[key]) return { row: ledger.rows[key], key }; + if (key.startsWith("inst:")) { + const bareInstall = key.slice("inst:".length); + if (ledger.rows[bareInstall]) { + return { row: ledger.rows[bareInstall], key: bareInstall }; + } + } + return { row: null, key }; +} + async function persist() { // Coalesce concurrent writes — multiple in-flight mutations resolve // against the same persisted snapshot in fifo order. @@ -180,16 +275,94 @@ async function persist() { } } -// Returns the row for an install, creating + persisting a blank one -// if this is the first time we've seen it. -export async function getOrCreateRow(installId) { - if (!installId) throw new Error("getOrCreateRow: installId required"); - let row = ledger.rows[installId]; +// Returns the row for an (install, license) pair, creating + persisting +// a blank one if this is the first time we've seen its resolved +// credit-key. The credit-key is `lic:` for paid tiers and +// `inst:` otherwise — so a single Pro license activated on +// two installs shares one row. +// +// When the credit-key is `inst:` and no row exists under +// that key, we fall back to a legacy bare-installId row (pre-refactor +// format) before creating a new one — keeps existing Core users on +// their existing balances without any retroactive migration. See the +// "Migration / backwards compatibility" comment at the top of the file. +// +// `creditKey` is an optional explicit override. It bypasses +// getCreditKey() and looks up directly under the supplied key. Used by +// job-credits.refundJob to route a refund to the SAME row a charge +// landed on even when the original license object isn't in scope +// anymore (e.g. after a relay restart, refund time only knows the +// stored fingerprint, not the raw licenseUuid). +export async function getOrCreateRow({ + installId, + license, + creditKey = null, +} = {}) { + if (!installId && !license && !creditKey) { + throw new Error("getOrCreateRow: installId, license, or creditKey required"); + } + const key = creditKey || getCreditKey({ installId, license }); + let { row } = lookupRow(key); let dirty = false; if (!row) { - row = blankRow(installId); - ledger.rows[installId] = row; + row = blankRow({ installId, license }); + // For explicit-creditKey rows whose license object isn't available + // (refund-after-restart), stamp the fingerprint extracted from the + // key itself onto the row so the dashboard surfaces it. + if (creditKey && key.startsWith("lic:") && !row.license_fingerprint) { + row.license_fingerprint = key.slice("lic:".length); + } + // When creating a fresh `lic:` row but an install row already + // exists for this installId, seed the new row's lifetime_consumed + // from the install row. Why: applyTierPromotion below treats this + // moment as a Core → Paid upgrade and transfers `coreQuota.lifetime + // - lifetime_consumed` as leftover bonus credits. If we left + // lifetime_consumed at 0, the user would get the FULL Core lifetime + // cap as bonus on top of their Pro monthly allotment — effectively + // double-credited (they already burned some of those Core credits + // on the install row before upgrading). Carrying over + // lifetime_consumed lines the math up so the leftover transfer + // reflects the REAL unused-Core balance at the moment of upgrade. + // + // Special case: when the install row's tier_snapshot is ALREADY + // paid (Pro/Max), this is a legacy-Pro user landing on a fresh + // license row post-refactor. They already received any Core-leftover + // transfer on the install row when they first upgraded; doing it + // again here would double-issue. We flag the new row by pre-flipping + // its tier_snapshot to the install row's snapshot — applyTierPromotion + // bails out when tier_snapshot is already non-Core, so the transfer + // skips. Purchased balance carries forward so any top-up credits + // the user had stay accessible on the new license-keyed row. + if (key.startsWith("lic:") && installId) { + const { row: installRow } = lookupRow(`inst:${installId}`); + if (installRow) { + const installAlreadyPaid = + installRow.tier_snapshot === "pro" || + installRow.tier_snapshot === "max"; + row.lifetime_consumed = installRow.lifetime_consumed || 0; + row.lifetime_gemini_consumed = installRow.lifetime_gemini_consumed || 0; + if (installAlreadyPaid) { + row.tier_snapshot = installRow.tier_snapshot; + row.purchased_balance = installRow.purchased_balance || 0; + row.purchased_total_ever = installRow.purchased_total_ever || 0; + } + } + } + ledger.rows[key] = row; dirty = true; + } else { + // Keep the most recently seen install/fingerprint stamped on the + // row so the admin dashboard can show "which device of this user + // last touched this license pool" without trawling audit logs. + if (installId && row.install_id !== installId) { + row.install_id = installId; + dirty = true; + } + const fp = licenseFingerprint(license); + if (fp && row.license_fingerprint !== fp) { + row.license_fingerprint = fp; + dirty = true; + } } if (ensureRenewalRollover(row)) dirty = true; if (dirty) await persist(); @@ -198,16 +371,26 @@ export async function getOrCreateRow(installId) { // Compute the remaining balance for a row against its tier's quota. // Returns: -// { remaining: number | null, capped: "lifetime" | "monthly" | "none", gemini_remaining: number | null } -// `null` for remaining means "unlimited" (Max tier total). -// `null` for gemini_remaining means "no Gemini cap on this tier" — the -// router can always pick Gemini. +// { +// remaining: number | null, // tier portion only; null = unlimited +// capped: "lifetime" | "monthly" | "none", +// gemini_remaining: number | null, // null = no Gemini cap on this tier +// purchased: number, // top-up credits the user bought via BTCPay +// total: number | null, // remaining + purchased; null = unlimited +// } +// +// Spend order is implemented by callers: tier portion is debited +// first (commitCredit increments lifetime_consumed / monthly_consumed); +// only when that hits zero do we touch purchased_balance. This keeps +// the user's purchased credits as a true durable top-up rather than +// crowding out the monthly allotment they're already entitled to. export function computeRemaining(row, quota) { const tier = row.tier_snapshot; const tierQuota = quota[tier] || quota.core; + const purchased = Math.max(0, row.purchased_balance || 0); if (tierQuota.lifetime != null) { - const remaining = Math.max(0, tierQuota.lifetime - (row.lifetime_consumed || 0)); + const tierRemaining = Math.max(0, tierQuota.lifetime - (row.lifetime_consumed || 0)); // Core tier may carve out a portion of the lifetime budget for // Gemini specifically (geminiCapLifetime). When set, remaining // Gemini credits = cap - lifetime_gemini_consumed; the rest of @@ -222,17 +405,19 @@ export function computeRemaining(row, quota) { tierQuota.geminiCapLifetime - (row.lifetime_gemini_consumed || 0) ); return { - remaining, + remaining: tierRemaining, capped: "lifetime", gemini_remaining: geminiRemaining, + purchased, + total: tierRemaining + purchased, }; } - let remaining; + let tierRemaining; if (tierQuota.monthly == null) { - remaining = null; // unlimited + tierRemaining = null; // unlimited } else { - remaining = Math.max(0, tierQuota.monthly - (row.monthly_consumed || 0)); + tierRemaining = Math.max(0, tierQuota.monthly - (row.monthly_consumed || 0)); } const geminiRemaining = tierQuota.geminiCapMonthly == null @@ -240,9 +425,11 @@ export function computeRemaining(row, quota) { : Math.max(0, tierQuota.geminiCapMonthly - (row.monthly_gemini_consumed || 0)); return { - remaining, + remaining: tierRemaining, capped: "monthly", gemini_remaining: geminiRemaining, + purchased, + total: tierRemaining == null ? null : tierRemaining + purchased, }; } @@ -270,13 +457,22 @@ export function computeRemaining(row, quota) { export function planBackend(row, quota, { hasHardware, preference = "gemini_first" }) { const balance = computeRemaining(row, quota); - // Out of credits entirely? - if (balance.remaining === 0) { + // Out of credits entirely? Tier allotment exhausted AND no purchased + // top-up remaining. (balance.total === null means unlimited.) + if (balance.total === 0) { return { allowed: false, backend: null, reason: "out_of_credits" }; } + // Gemini availability has two paths: either the tier's Gemini-cap + // portion has headroom (gemini_remaining > 0 or null) OR the user + // has purchased top-up credits. Purchased credits bypass the per- + // tier Gemini cap because the operator has already been paid for + // those calls — the cap exists to bound free/comped Gemini spend, + // not paid-for spend. const geminiAvailable = - balance.gemini_remaining === null || balance.gemini_remaining > 0; + balance.gemini_remaining === null || + balance.gemini_remaining > 0 || + balance.purchased > 0; switch (preference) { case "hardware_only": @@ -339,34 +535,266 @@ export function planBackend(row, quota, { hasHardware, preference = "gemini_firs // upgrades on the 17th gets renewals on the 17th going forward, not // at some earlier date that happens to be when their install_id was // first seen. -export async function commitCredit(installId, { backend, tier }) { - const row = await getOrCreateRow(installId); - const wasCorePromotion = - tier !== "core" && row.tier_snapshot === "core"; - row.tier_snapshot = tier; - if (wasCorePromotion) { - const now = new Date(); - row.last_renewal_at = now.toISOString(); - row.anniversary_day = now.getUTCDate(); - row.monthly_consumed = 0; - row.monthly_gemini_consumed = 0; - } +// Inverse of commitCredit — returns one charged credit back to the +// install's ledger when the work that consumed it ended up failing. +// Mirrors commitCredit field-by-field so the same row that was +// incremented gets decremented; floors at 0 so we never accidentally +// hand a user negative consumption from a buggy refund sequence. +// +// Called from the route handlers via job-credits.refundJob when a +// backend call fails after the credit was already charged (typical +// case: transcribe succeeded + committed, analyze failed, so the +// job's credit needs to be returned because the summary didn't +// actually complete). +export async function refundCredit({ + installId, + license, + creditKey = null, + backend, + tier, +}) { + const row = await getOrCreateRow({ installId, license, creditKey }); + // Mirror commitCredit's spend order: tier bucket gets refunded + // first (which is where the credit was charged); only if the tier + // counter is already at zero do we credit back to purchased_balance + // (which means the original commit came out of the top-up bucket). if (tier === "core") { - row.lifetime_consumed = (row.lifetime_consumed || 0) + 1; - if (backend === "gemini") { - row.lifetime_gemini_consumed = (row.lifetime_gemini_consumed || 0) + 1; + if ((row.lifetime_consumed || 0) > 0) { + row.lifetime_consumed -= 1; + if (backend === "gemini" && (row.lifetime_gemini_consumed || 0) > 0) { + row.lifetime_gemini_consumed -= 1; + } + } else { + row.purchased_balance = (row.purchased_balance || 0) + 1; } } else { - row.monthly_consumed = (row.monthly_consumed || 0) + 1; - if (backend === "gemini") { - row.monthly_gemini_consumed = (row.monthly_gemini_consumed || 0) + 1; + if ((row.monthly_consumed || 0) > 0) { + row.monthly_consumed -= 1; + if (backend === "gemini" && (row.monthly_gemini_consumed || 0) > 0) { + row.monthly_gemini_consumed -= 1; + } + } else { + row.purchased_balance = (row.purchased_balance || 0) + 1; } } row.last_active_at = new Date().toISOString(); await persist(); } -// For the admin dashboard. -export function snapshotAll() { - return Object.values(ledger.rows).map((r) => ({ ...r })); +// Loads the quota for the install's tier so we can decide whether +// to debit the tier portion or the purchased top-up portion. Imported +// lazily to avoid a circular dep with config.js → credits.js. +async function getCommitQuota(tier) { + const { getTierQuotas } = await import("./config.js"); + const all = await getTierQuotas(); + return all[tier] || all.core; +} + +// Apply the Core → paid-tier promotion bookkeeping in a single place. +// Idempotent: only fires the FIRST time we see a paid tier on a row +// whose tier_snapshot is still "core". On promotion we: +// - Anchor the user's billing-anniversary to right now so monthly +// renewals line up with the upgrade moment (not their install +// creation date). +// - Zero out monthly counters so the user gets their full first +// month, even if they made it past the Core lifetime cap by +// burning some monthly counter earlier. +// - Transfer any UNUSED Core lifetime credits into purchased_balance. +// This way the 6 leftover credits a Core user had don't vanish on +// upgrade — they stack on top of the paid tier's monthly allotment +// as durable bonus credit. Total after upgrade = monthly cap + +// leftover Core credits + any prior top-up purchases. +// - Flip tier_snapshot to the new tier last so the spend-order check +// below routes the next debit to the right bucket. +// +// Mutates `row` in place AND persists the ledger when a promotion +// fires — so the leftover transfer survives a relay restart even if +// the calling route doesn't otherwise persist (the /relay/balance +// route, for example, mutates tier_snapshot in memory without a +// follow-up persist). +// +// Returns true if a promotion was applied, false otherwise. +export async function applyTierPromotion(row, newTier) { + if (newTier === "core") return false; + if (row.tier_snapshot !== "core") return false; + + // Compute leftover Core credits BEFORE we flip tier_snapshot. If + // Core's lifetime cap isn't set (unlimited), there's nothing to + // transfer — the user already had unlimited. + const coreQuota = await getCommitQuota("core"); + let transferred = 0; + if (typeof coreQuota.lifetime === "number" && coreQuota.lifetime > 0) { + transferred = Math.max( + 0, + coreQuota.lifetime - (row.lifetime_consumed || 0) + ); + if (transferred > 0) { + row.purchased_balance = (row.purchased_balance || 0) + transferred; + row.purchased_total_ever = + (row.purchased_total_ever || 0) + transferred; + } + } + + const now = new Date(); + row.last_renewal_at = now.toISOString(); + row.anniversary_day = now.getUTCDate(); + row.monthly_consumed = 0; + row.monthly_gemini_consumed = 0; + row.tier_snapshot = newTier; + row.last_active_at = now.toISOString(); + await persist(); + if (transferred > 0) { + console.log( + `[credits] tier promotion core → ${newTier} for ${row.install_id || row.license_fingerprint || "(unknown)"}: ` + + `transferred ${transferred} leftover Core credit(s) to purchased_balance ` + + `(now ${row.purchased_balance})` + ); + } + return true; +} + +export async function commitCredit({ installId, license, creditKey = null, backend, tier }) { + const row = await getOrCreateRow({ installId, license, creditKey }); + const promoted = await applyTierPromotion(row, tier); + // If no promotion fired, applyTierPromotion left tier_snapshot + // untouched (it only flips on Core → paid). Still want to keep the + // snapshot current for paid → paid moves (Pro → Max, etc.) so the + // ledger reflects the most recent license tier seen. + if (!promoted) { + row.tier_snapshot = tier; + } + + // Spend order: tier allotment first, purchased top-up second. + // Figure out whether THIS credit comes out of the tier bucket or + // the purchased bucket by checking remaining tier headroom against + // the current quota. + const tierQuota = await getCommitQuota(tier); + let tierHasRoom = false; + if (tier === "core") { + tierHasRoom = + tierQuota.lifetime == null || + (row.lifetime_consumed || 0) < tierQuota.lifetime; + } else { + tierHasRoom = + tierQuota.monthly == null || + (row.monthly_consumed || 0) < tierQuota.monthly; + } + + if (tierHasRoom) { + if (tier === "core") { + row.lifetime_consumed = (row.lifetime_consumed || 0) + 1; + if (backend === "gemini") { + row.lifetime_gemini_consumed = (row.lifetime_gemini_consumed || 0) + 1; + } + } else { + row.monthly_consumed = (row.monthly_consumed || 0) + 1; + if (backend === "gemini") { + row.monthly_gemini_consumed = (row.monthly_gemini_consumed || 0) + 1; + } + } + } else { + // Tier allotment exhausted — debit the purchased top-up. Capped + // at zero so a refundCredit miss can't bring this negative. + row.purchased_balance = Math.max(0, (row.purchased_balance || 0) - 1); + } + row.last_active_at = new Date().toISOString(); + await persist(); +} + +// Add purchased credits to the install's top-up bucket. Used by the +// BTCPay webhook after a successful invoice settlement. Idempotent +// at the webhook layer via processed-invoice tracking (the webhook +// handler dedupes by invoice_id before calling this). +// Purchased credits land on whichever row backs the buying install at +// the time of purchase. The caller passes (installId, license, creditKey), +// in priority order: an explicit creditKey wins, otherwise the resolved +// (installId, license) decides. +// +// Why creditKey is accepted as an explicit override: the BTCPay webhook +// re-enters this path AFTER a restart, with only invoice metadata in +// hand (install_id + license_fingerprint stashed at buy time, no live +// license object). The webhook constructs `lic:` from the stored +// fingerprint and passes it as creditKey so the credit lands on the +// SAME pool the buyer was looking at when they minted the invoice. +// +// Anonymous / Core buyers (no fingerprint stashed) fall through to the +// install-keyed row — the credit follows the install. Once they +// upgrade to Pro/Max later, applyTierPromotion transfers any leftover +// Core tier credits to purchased_balance — see commitCredit's path. +export async function addPurchasedCredits({ + installId, + license = null, + creditKey = null, + amount, +}) { + if (!Number.isFinite(amount) || amount <= 0) return null; + const row = await getOrCreateRow({ installId, license, creditKey }); + row.purchased_balance = (row.purchased_balance || 0) + amount; + row.purchased_total_ever = (row.purchased_total_ever || 0) + amount; + row.last_active_at = new Date().toISOString(); + await persist(); + return row.purchased_balance; +} + +// ── Cloud user tier (core-decoupling) ─────────────────────────────── +// The relay is the source of truth for a cloud user's Pro/Max tier, +// stored on the user's credit row (keyed `user:`). Set by the +// operator (today) and the self-serve purchase flow (later slice). + +// Operator-set a cloud user's tier. Resets the monthly counters and +// anchors the renewal to now (so the monthly cycle starts on the grant +// date), mirroring applyTierPromotion. `expiresAt` is stored for +// reporting / future self-serve billing but NOT auto-enforced in this +// slice — to revoke, set tier back to "core". +export async function setUserTier({ userId, tier, expiresAt = null }) { + if (!userId) throw new Error("setUserTier: userId required"); + const t = tier === "pro" || tier === "max" ? tier : "core"; + const row = await getOrCreateRow({ creditKey: `user:${userId}` }); + const now = new Date(); + row.tier_snapshot = t; + row.monthly_consumed = 0; + row.monthly_gemini_consumed = 0; + row.last_renewal_at = now.toISOString(); + row.anniversary_day = now.getUTCDate(); + row.subscription_expires_at = expiresAt || null; + row.last_active_at = now.toISOString(); + await persist(); + return row; +} + +// Buy / extend a PREPAID PERIOD of `tier` (self-serve subscriptions). The +// new expiry extends from whichever is later — now, or the user's current +// (still-active) expiry — so paying early ADDS time rather than resetting +// it. `periodDays` defaults to 30. Both payment rails (BTCPay + Zaprite) +// land here on a settled payment. Returns the updated row. +export async function extendUserTier({ userId, tier, periodDays = 30 }) { + if (!userId) throw new Error("extendUserTier: userId required"); + const t = tier === "pro" || tier === "max" ? tier : "core"; + const now = Date.now(); + const row = await getOrCreateRow({ creditKey: `user:${userId}` }); + const curExp = row.subscription_expires_at + ? new Date(row.subscription_expires_at).getTime() + : 0; + const base = Math.max(now, Number.isFinite(curExp) ? curExp : 0); + const expiresAt = new Date( + base + periodDays * 24 * 60 * 60 * 1000, + ).toISOString(); + return setUserTier({ userId, tier: t, expiresAt }); +} + +// Read a cloud user's credit row (creates a blank Core row if none yet). +export async function getUserCreditRow(userId) { + if (!userId) throw new Error("getUserCreditRow: userId required"); + return getOrCreateRow({ creditKey: `user:${userId}` }); +} + +// For the admin dashboard. Includes the ledger-key (`credit_key`) so +// the dashboard can render "license pool" vs "install pool" rows +// distinctly — license-keyed rows aggregate spend across every install +// that uses the same license, install-keyed rows aggregate one install. +export function snapshotAll() { + return Object.entries(ledger.rows).map(([credit_key, r]) => ({ + credit_key, + ...r, + })); } diff --git a/server/job-credits.js b/server/job-credits.js index 669b28a..e9c04c5 100644 --- a/server/job-credits.js +++ b/server/job-credits.js @@ -1,58 +1,186 @@ // Job-id deduplication. Recap mints a UUID per summarize job (the // transcribe + analyze pair) and sends it in X-Recap-Job-Id on every -// relay call. The first call with a given (install_id, job_id) tuple +// relay call. The first call with a given (creditKey, job_id) tuple // reserves a credit; subsequent calls with the same tuple are free // until the job_id expires (1 hour). // -// Stored in-memory only — not persisted across restarts because (a) -// a restart breaks all in-flight Recap streams anyway and (b) the -// worst-case outcome of a "lost reservation" is the user being -// charged for a single retry, which is acceptable. +// Keyed by creditKey (`lic:` for paid tiers, `inst:` +// otherwise) — the SAME key the credits.js ledger uses — so a +// transcribe that landed on the cloud account's install can be +// recognized + refunded by a follow-up analyze landing from the +// self-hosted install of the same license. The credit-key plumbing +// is what unifies them; the job-id is the dedup grain. +// +// Persisted to disk at /data/jobs.json so the refund logic survives +// container restarts. The earlier in-memory-only version had a bug: +// when transcribe charged a credit (marking the job in memory), the +// relay restarted, and then analyze tried to refund the failed call, +// `lookupJob` returned null (memory wiped) and refundJob did +// nothing. Credits stuck on the ledger. Disk persistence fixes that +// — a restart-and-resume operator-side never loses refund state. + +import fs from "fs/promises"; +import path from "path"; +import { refundCredit, getCreditKey } from "./credits.js"; const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour -// Map +// Map +// install_id + license_fingerprint are stored alongside the credit-key +// so refundCredit can route the refund to the SAME ledger row that +// commitCredit charged — getOrCreateRow needs license context to +// resolve a `lic:` row. const jobs = new Map(); +let dataDir = "/data"; +let jobsPath = "/data/jobs.json"; +let writing = null; // serializes concurrent writes -function key(installId, jobId) { - return `${installId}|${jobId}`; +function key(creditKey, jobId) { + return `${creditKey}|${jobId}`; } -// On a new request: returns { charged: true } if this is the first call -// for the job (caller must commit a credit), or { charged: false, -// backend, tier } if it's a retry/follow-up. -export function lookupJob(installId, jobId) { - if (!installId || !jobId) return null; +// Boot-time load. Called from server/index.js before any route hits. +// If the file is missing or corrupt, start empty — same effective +// state as a fresh install. Expired entries are pruned during load. +export async function initJobCredits({ dataDir: dd } = {}) { + if (dd) dataDir = dd; + jobsPath = path.join(dataDir, "jobs.json"); + try { + const raw = await fs.readFile(jobsPath, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && Array.isArray(parsed.entries)) { + const cutoff = Date.now() - JOB_TTL_MS; + for (const entry of parsed.entries) { + if ( + entry && + typeof entry.key === "string" && + typeof entry.charged_at === "number" && + entry.charged_at >= cutoff + ) { + const { key: k, ...rest } = entry; + jobs.set(k, rest); + } + } + console.log(`[job-credits] loaded ${jobs.size} jobs from ${jobsPath}`); + } + } catch (err) { + if (err.code !== "ENOENT") { + console.warn(`[job-credits] failed to read ${jobsPath}: ${err?.message || err}`); + } + } +} + +async function persist() { + // Coalesce concurrent writes — same pattern as credits.js. Each + // mutation triggers a serialized write; outstanding writes + // resolve against the latest in-memory snapshot. + if (writing) await writing; + writing = (async () => { + const entries = []; + pruneExpired(); + for (const [k, v] of jobs) { + entries.push({ key: k, ...v }); + } + const tmp = jobsPath + ".tmp"; + await fs.writeFile(tmp, JSON.stringify({ entries }), { mode: 0o600 }); + await fs.rename(tmp, jobsPath); + })(); + try { + await writing; + } finally { + writing = null; + } +} + +// On a new request: returns existing reservation (caller must NOT +// double-charge) or null (caller should commit a credit). The caller +// passes the same (installId, license) pair it would pass to credits.js +// — we resolve to the credit-key internally so the dedup grain matches +// the ledger key. +export function lookupJob({ installId, license, creditKey = null, jobId }) { + if (!jobId) return null; pruneExpired(); - const k = key(installId, jobId); + const ck = creditKey || getCreditKey({ installId, license }); + const k = key(ck, jobId); const existing = jobs.get(k); if (existing && !existing.refunded) return existing; return null; } // Mark a job as having been charged. Idempotent — second call for the -// same (install_id, job_id) is a no-op. -export function markJobCharged(installId, jobId, { backend, tier }) { - if (!installId || !jobId) return; +// same (creditKey, job_id) is a no-op. Stores enough context on the +// reservation that a later refundJob can reconstruct the same credit-key +// (and therefore find the same ledger row) without the caller needing +// to repeat the license. +export async function markJobCharged({ installId, license, creditKey = null, jobId, backend, tier }) { + if (!jobId) return; pruneExpired(); - const k = key(installId, jobId); + const ck = creditKey || getCreditKey({ installId, license }); + const k = key(ck, jobId); if (jobs.has(k) && !jobs.get(k).refunded) return; + // Pull the license fingerprint off the credit-key so refund time + // doesn't need to recompute it (we'd no longer have the license + // object in scope on a restart-resume refund). + const license_fingerprint = + ck.startsWith("lic:") ? ck.slice("lic:".length) : null; jobs.set(k, { backend, tier, + install_id: installId || null, + license_fingerprint, charged_at: Date.now(), refunded: false, }); + try { + await persist(); + } catch (err) { + console.error(`[job-credits] persist failed after mark: ${err?.message || err}`); + } } -// Refund a previously charged credit for a failed job. Future calls -// with the same job_id will be treated as new (since the reservation -// is no longer valid). -export function refundJob(installId, jobId) { - if (!installId || !jobId) return; - const k = key(installId, jobId); +// Refund a previously charged credit for a failed job. Returns the +// credit to the persistent ledger AND marks the in-memory job record +// as refunded so a subsequent same-job_id call is treated as new. +// +// Idempotent: refunding an already-refunded job is a no-op, so call +// sites can fire-and-forget on every error path without needing to +// track whether they were the FIRST error path to refund. +// +// The refund routes back to the SAME row that was charged because we +// stored the credit-key components (install_id + license_fingerprint) +// at mark time — refundCredit reconstructs the key via getOrCreateRow, +// which in turn calls getCreditKey on the same inputs. +export async function refundJob({ installId, license, creditKey = null, jobId }) { + if (!jobId) return; + const ck = creditKey || getCreditKey({ installId, license }); + const k = key(ck, jobId); const existing = jobs.get(k); - if (existing) existing.refunded = true; + if (!existing || existing.refunded) return; + existing.refunded = true; + try { + // Route the refund back to the SAME row that was charged. The + // credit-key was computed at mark time from the same (installId, + // license) the caller is passing now — `ck` here equals the + // mark-time key whenever the caller is consistent. Pass it as an + // explicit creditKey override so refundCredit doesn't need to + // re-derive it from a license object the caller might not have + // (e.g. some error paths refund without re-resolving the license). + await refundCredit({ + installId: existing.install_id || installId || null, + creditKey: ck, + backend: existing.backend, + tier: existing.tier, + }); + } catch (err) { + console.error( + `[job-credits] refundCredit failed for ${ck}|${jobId}: ${err?.message || err}` + ); + } + try { + await persist(); + } catch (err) { + console.error(`[job-credits] persist failed after refund: ${err?.message || err}`); + } } function pruneExpired() { diff --git a/server/pricing.js b/server/pricing.js index c0ebfd5..597dcd2 100644 --- a/server/pricing.js +++ b/server/pricing.js @@ -11,15 +11,20 @@ // margin math — Google has been known to adjust prices ~quarterly. export const GEMINI_PRICING = { - // Pro family — best for analysis. - "gemini-3.1-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 }, - "gemini-3-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 }, + // The five supported models, verified against Google's official + // docs (ai.google.dev/gemini-api/docs/models) on 2026-05-12. + // Retired model IDs (gemini-3-pro-preview shut down 2026-03-09, + // gemini-2.0-flash deprecated) intentionally omitted — they should + // never appear in cost calc here for current calls. - // Flash family — best speed/cost for transcription, common for - // analysis when sub-Pro quality is acceptable. + // Pro tier — best for analysis. + "gemini-3.1-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 }, + "gemini-2.5-pro": { input: 1.25, output: 10.0, thinking: 10.0 }, + + // Flash tier — best speed/cost for transcription + cheap analysis. "gemini-3-flash-preview": { input: 0.3, output: 2.5, thinking: 2.5 }, "gemini-2.5-flash": { input: 0.3, output: 2.5, thinking: 2.5 }, - "gemini-2.0-flash": { input: 0.1, output: 0.4, thinking: 0.4 }, + "gemini-3.1-flash-lite": { input: 0.1, output: 0.4, thinking: 0.4 }, // Fallback used when an unknown model id appears (e.g. operator // typed a custom model name in setBackendRouting). Conservative — diff --git a/server/routes/balance.js b/server/routes/balance.js index 079e22e..2a1210e 100644 --- a/server/routes/balance.js +++ b/server/routes/balance.js @@ -1,47 +1,64 @@ -// GET /relay/balance — peek at the current install's credit balance + -// tier WITHOUT charging anything. Recap clients call this to populate -// the "N credits remaining · Tier: X" banner before the user runs any -// transcribe/analyze, so the display is accurate on first load instead -// of saying "balance unknown — no relay calls yet". +// GET /relay/balance — peek at the caller's credit balance + tier WITHOUT +// charging anything. Recap clients call this to populate the +// "N credits remaining · Tier: X" banner. // -// Same auth surface as the metered endpoints: -// X-Recap-Install-Id (required) -// Authorization (optional Bearer LIC1-... — absent = Core) +// Two auth surfaces (see identity.js): +// - cloud: X-Recap-User-Id + X-Recap-Operator-Key → user: pool, +// tier is the relay's stored (operator-set) value. +// - license: X-Recap-Install-Id (+ optional Bearer license) → legacy +// license/install pool, tier from the license. // -// Returns the standard envelope shape with result=null and -// credit_charged=0. The license-resolution path is identical to -// /relay/transcribe and /relay/analyze, so the cached online check -// against keysat happens here too — but no row mutation, no job-id -// reservation, no upstream backend call. +// Returns the standard envelope (result=null, credit_charged=0). No row +// mutation beyond keeping tier_snapshot synced on the license path. import express from "express"; -import { resolveLicense } from "../keysat-client.js"; -import { getOrCreateRow } from "../credits.js"; +import { resolveIdentity, identityTier } from "../identity.js"; +import { getOrCreateRow, applyTierPromotion } from "../credits.js"; import { envelope, errorEnvelope } from "./envelope.js"; export function balanceRouter() { const router = express.Router(); router.get("/balance", async (req, res) => { - const installId = req.header("X-Recap-Install-Id"); - const auth = req.header("Authorization"); - if (!installId) { + let identity; + try { + identity = await resolveIdentity(req); + } catch (err) { + const e = await errorEnvelope({ + error: err?.message || "auth_error", + statusHint: err?.status || 401, + }); + return res.status(e.statusHint || 401).json(e.body); + } + if (identity.kind === "license" && !identity.installId) { const e = await errorEnvelope({ error: "missing X-Recap-Install-Id header", statusHint: 400, }); return res.status(400).json(e.body); } - const license = await resolveLicense(auth); - const tier = license.tier; - // Touch the row so tier_snapshot reflects the most recently seen - // license tier — same as the metered endpoints do — but commit - // nothing. - const row = await getOrCreateRow(installId); - row.tier_snapshot = tier; + + const row = await getOrCreateRow({ + creditKey: identity.creditKey, + installId: identity.installId, + license: identity.license, + }); + // License path: fire the Core→paid promotion bookkeeping (this is + // typically the FIRST relay call after a license activation) and keep + // tier_snapshot synced to the license. Cloud path: the tier is the + // relay's stored, operator-set value — leave it untouched. + if (identity.kind === "license") { + const tier = identity.license.tier; + const promoted = await applyTierPromotion(row, tier); + if (!promoted) row.tier_snapshot = tier; + } + + const tier = identityTier(identity, row); const body = await envelope({ result: null, - installId, + creditKey: identity.creditKey, + installId: identity.installId, + license: identity.license, tier, creditCharged: 0, }); diff --git a/server/routes/btcpay-setup.js b/server/routes/btcpay-setup.js new file mode 100644 index 0000000..9022cbd --- /dev/null +++ b/server/routes/btcpay-setup.js @@ -0,0 +1,626 @@ +// One-click BTCPay setup. Three admin endpoints + a tiny standalone +// HTML page that drives the flow: +// +// POST /admin/btcpay/start — generates an authorize URL +// the operator opens in a +// browser, with all the +// right scopes pre-checked. +// Body: { btcpay_url } +// Returns: { authorize_url } +// +// POST /admin/btcpay/callback — receives the API key from +// BTCPay after the operator +// approves. Body comes from +// BTCPay's POST redirect. +// Stores key in config, returns +// { stores: [...] } so the +// operator can pick which +// store to use. +// +// POST /admin/btcpay/finalize — sets the chosen store, then +// creates the webhook +// automatically. Body: +// { store_id, public_relay_url } +// Stores webhook secret in +// config too. Returns +// { ok: true, webhook_url }. +// +// This replaces the 4-step manual setup (generate API key in BTCPay, +// generate webhook + copy secret, copy store ID, paste all four into +// Set BTCPay Connection action) with: paste BTCPay URL → click +// approve → pick store → done. + +import express from "express"; +import crypto from "crypto"; +import fs from "fs/promises"; +import path from "path"; +import { getConfigSnapshot } from "../config.js"; +import { rescanSettledInvoices } from "./credits.js"; + +export function btcpaySetupRouter({ dataDir }) { + const router = express.Router(); + + // Step 1: build the authorize URL. The operator hits this from the + // setup page (or by calling it directly via curl) with their + // BTCPay base URL, and we return the URL they should open in a + // browser to grant Recap Relay the permissions it needs. + // + // BTCPay's API Key Authorization Flow: + // GET /api-keys/authorize + // ?permissions=btcpay.store.cancreateinvoice + // &permissions=btcpay.store.canviewinvoices + // &permissions=btcpay.store.webhooks.canmodifywebhooks + // &applicationName=Recap+Relay + // &applicationIdentifier=recap-relay + // &strict=true — buyer can't tweak the scopes we asked + // &selectiveStores=true — let buyer pick the target store + // &redirect= (POST redirect, not GET) + router.post("/start", express.json(), async (req, res) => { + const btcpayUrl = (req.body?.btcpay_url || "").trim(); + if (!btcpayUrl || !/^https?:\/\//i.test(btcpayUrl)) { + return res.status(400).json({ + error: "btcpay_url_required", + message: "Provide btcpay_url (e.g. https://btcpay.keysat.xyz).", + }); + } + // If BTCPay is co-installed on the same Start9 box, the setup + // discovery file gives us the internal hostname (resolvable from + // inside this container) — needed because the browser-facing + // URL (mDNS .local or clearnet) often isn't resolvable from + // inside the docker network. We persist BOTH so the callback + + // finalize phases know which URL to use for server-to-server + // BTCPay API calls. + const internalUrl = await readDiscoveredInternalUrl(dataDir); + const publicUrl = await readDiscoveredPublicUrl(dataDir); + const state = crypto.randomBytes(24).toString("hex"); + const baseRelayUrl = absoluteRelayUrl(req); + const callbackUrl = `${baseRelayUrl}/admin/btcpay/callback?state=${state}`; + // Permissions mirror Keysat's working set. Specifically: + // - canviewstoresettings: required to list stores (we call + // /api/v1/stores in the picker page) + // - canmodifystoresettings: required to register the webhook + // (BTCPay treats webhook management as a store setting, not + // a separate scope) + // - canviewinvoices / cancreateinvoice / canmodifyinvoices: + // the actual credit-purchase flow + const params = new URLSearchParams(); + params.append("permissions", "btcpay.store.canviewstoresettings"); + params.append("permissions", "btcpay.store.canmodifystoresettings"); + params.append("permissions", "btcpay.store.canviewinvoices"); + params.append("permissions", "btcpay.store.cancreateinvoice"); + params.append("permissions", "btcpay.store.canmodifyinvoices"); + params.append("applicationName", "Recap Relay"); + params.append("applicationIdentifier", "recap-relay"); + params.append("strict", "true"); + params.append("selectiveStores", "true"); + params.append("redirect", callbackUrl); + const authorizeUrl = + btcpayUrl.replace(/\/$/, "") + "/api-keys/authorize?" + params.toString(); + await stashSetupContext(dataDir, { + btcpay_url: btcpayUrl, + btcpay_internal_url: internalUrl, + btcpay_public_url: publicUrl, + state, + }); + res.json({ authorize_url: authorizeUrl, callback_url: callbackUrl }); + }); + + // Step 2: BTCPay POST-redirects the operator's browser back here + // with the API key + permissions in the body. Body shape: + // { apiKey: "...", permissions: ["btcpay.store.X:STOREID", ...] } + // + // We persist the key into config, look up the store list, and + // return it so the operator can pick which store to use. + router.post( + "/callback", + express.urlencoded({ extended: true }), + express.json(), + async (req, res) => { + const body = req.body || {}; + const apiKey = + body.apiKey || + body.api_key || + (typeof body === "string" ? body : null); + const permissions = Array.isArray(body.permissions) + ? body.permissions + : []; + if (!apiKey || typeof apiKey !== "string") { + // Show a plain HTML response since this is a browser redirect. + return res.status(400).send(html("Authorization failed", ` +

BTCPay didn't return an API key — likely you clicked Deny, + or your BTCPay version doesn't support the authorization flow. + You can still set up manually via the StartOS action.

+

Back to dashboard

+ `)); + } + const ctx = await readSetupContext(dataDir); + const btcpayUrl = ctx?.btcpay_url; + const expectedState = ctx?.state; + const providedState = (req.query?.state || "").toString(); + if (!btcpayUrl || !expectedState) { + return res.status(400).send(html("Setup context lost", ` +

No BTCPay URL is on file — re-start the setup from the + dashboard.

+

Back to dashboard

+ `)); + } + if ( + !providedState || + providedState.length !== expectedState.length || + !crypto.timingSafeEqual( + Buffer.from(providedState), + Buffer.from(expectedState) + ) + ) { + return res.status(403).send(html("Bad state token", ` +

The authorization callback didn't carry a matching state + token. This usually means someone else's setup is in flight, + or the link was tampered with. Re-start setup from the + dashboard.

+

Back to dashboard

+ `)); + } + // Preserve the fields /start stashed (especially + // btcpay_internal_url), don't overwrite the whole object. + // Without the merge, the picker page's /stores call falls back + // to btcpay_url (mDNS) which can't resolve inside the docker + // container → "getaddrinfo ENOTFOUND". + const mergedCtx = { + ...(ctx || {}), + btcpay_url: btcpayUrl, + api_key: apiKey, + permissions, + }; + await stashSetupContext(dataDir, mergedCtx); + + // ── Auto-finalize when the operator authorized for exactly + // one store ── + // BTCPay's authorize page already has a built-in store picker + // when we pass selectiveStores=true. The granted permissions + // encode the chosen store ID(s) in `btcpay.store.X:STOREID` + // suffixes. When only one store was selected, our second + // picker is redundant — skip straight to finalize. + const storeIds = extractStoreIdsFromPermissions(permissions); + if (storeIds.length === 1) { + const baseRelayUrl = absoluteRelayUrl(req); + const result = await finalizeBtcpay({ + ctx: mergedCtx, + storeId: storeIds[0], + dataDir, + baseRelayUrl, + }); + if (result.ok) { + return res.send(html("Connected", ` +

✓ BTCPay connected successfully.

+

You can close this tab and return to the Recap Relay + dashboard. The webhook is wired up automatically and + users can now top up their credit balance via Lightning.

+ `)); + } + // Auto-finalize failed (e.g. webhook creation hiccup). Fall + // through to the manual picker so the operator can retry. + console.warn( + `[btcpay/callback] auto-finalize failed for sole store ${storeIds[0]}: ${result.error} ${result.message?.slice(0, 200)}` + ); + } + // Hand the operator a tiny picker page that calls back into + // /admin/btcpay/finalize once they pick. + return res.send(html("Pick your store", ` +

Pick your Recap store

+

Authorization successful. One last step — pick which BTCPay + store invoices should be created against, and the relay will + finish wiring everything up (including the webhook) for you.

+
Loading stores…
+
+ + `)); + } + ); + + // Lists stores reachable with the API key from setup context. The + // store-picker page above calls this. + router.get("/stores", async (_req, res) => { + const ctx = await readSetupContext(dataDir); + if (!ctx?.btcpay_url || !ctx?.api_key) { + console.warn("[btcpay/stores] setup_context_missing — operator may need to restart connect flow"); + return res + .status(400) + .json({ error: "setup_context_missing" }); + } + // Server-to-server: prefer internal hostname when discovered + // (mDNS .local doesn't resolve inside docker; clearnet works but + // takes the long way round). + const apiBase = (ctx.btcpay_internal_url || ctx.btcpay_url).replace(/\/$/, ""); + const url = `${apiBase}/api/v1/stores`; + console.log(`[btcpay/stores] GET ${url}`); + try { + const r = await fetch(url, { + headers: { Authorization: `token ${ctx.api_key}` }, + }); + console.log(`[btcpay/stores] BTCPay responded ${r.status}`); + if (!r.ok) { + const text = await r.text(); + console.warn(`[btcpay/stores] non-ok body: ${text.slice(0, 200)}`); + return res.status(502).json({ + error: "btcpay_stores_failed", + message: text.slice(0, 200), + }); + } + const stores = await r.json(); + console.log(`[btcpay/stores] returned ${Array.isArray(stores) ? stores.length : "?"} stores`); + res.json({ stores }); + } catch (err) { + const msg = err?.message || String(err); + const cause = err?.cause?.message || err?.cause?.code || err?.cause; + console.error( + `[btcpay/stores] fetch threw: ${msg}${cause ? ` (cause: ${cause})` : ""} url=${url}` + ); + res.status(502).json({ + error: "btcpay_stores_failed", + message: msg.slice(0, 200) + (cause ? ` (cause: ${String(cause).slice(0, 120)})` : ""), + }); + } + }); + + // Step 3: finalize. Pin the chosen store, create a webhook + // pointing back at /relay/btcpay/webhook, and write all four + // BTCPay fields to the live config. + router.post("/finalize", express.json(), async (req, res) => { + const storeId = (req.body?.store_id || "").trim(); + if (!storeId) { + return res.status(400).json({ error: "store_id_required" }); + } + const ctx = await readSetupContext(dataDir); + if (!ctx?.btcpay_url || !ctx?.api_key) { + return res.status(400).json({ error: "setup_context_missing" }); + } + const baseRelayUrl = absoluteRelayUrl(req); + const result = await finalizeBtcpay({ ctx, storeId, dataDir, baseRelayUrl }); + if (!result.ok) { + return res.status(result.status).json({ + error: result.error, + message: result.message, + }); + } + res.json({ ok: true, webhook_url: result.webhookUrl }); + }); + + // Auto-discovery: does the StartOS init phase know about a local + // BTCPay install? Reads the file written by startos/init/setup.ts + // via sdk.serviceInterface.getAll. When present, the dashboard + // skips the URL-prompt step and goes straight to /start with the + // discovered URL. + router.get("/discover", async (_req, res) => { + const discoveryPath = path.join(dataDir, "discovered-services.json"); + try { + const raw = await fs.readFile(discoveryPath, "utf8"); + const parsed = JSON.parse(raw); + if (parsed?.btcpay?.browser_url) { + return res.json({ + found: true, + browser_url: parsed.btcpay.browser_url, + discovered_at: parsed.btcpay.discovered_at, + }); + } + return res.json({ found: false }); + } catch (err) { + // No discovery file yet (fresh install hadn't run setup, or + // BTCPay wasn't installed when setup ran). Treat as not found. + return res.json({ found: false }); + } + }); + + // Operator-initiated recovery: scan recent settled BTCPay invoices + // and grant credits for any that weren't processed via the webhook + // (typical cause: broken webhook URL pointing at unreachable + // mDNS). Idempotent. Returns a summary of what got credited. + router.post("/rescan-invoices", async (_req, res) => { + try { + const result = await rescanSettledInvoices(); + if (!result.ok) { + return res.status(502).json(result); + } + res.json(result); + } catch (err) { + res.status(500).json({ + error: "rescan_failed", + message: (err?.message || String(err)).slice(0, 200), + }); + } + }); + + // Convenience: tells the dashboard whether BTCPay is fully wired + // up, half-set-up, or empty — so the dashboard can show the right + // CTA ("Connect BTCPay" / "Reconnect" / "Connected"). + router.get("/status", async (_req, res) => { + const cfg = await getConfigSnapshot(); + res.json({ + configured: !!( + cfg.relay_btcpay_base_url && + cfg.relay_btcpay_store_id && + cfg.relay_btcpay_api_key && + cfg.relay_btcpay_webhook_secret + ), + base_url: cfg.relay_btcpay_base_url || null, + store_id: cfg.relay_btcpay_store_id || null, + has_api_key: !!cfg.relay_btcpay_api_key, + has_webhook_secret: !!cfg.relay_btcpay_webhook_secret, + }); + }); + + return router; +} + +// Setup-context is a transient JSON blob holding partial setup state +// (BTCPay URL + API key + permissions) between the authorize callback +// and the finalize step. Lives on disk only while a setup is in +// progress; finalize wipes it. +function setupContextPath(dataDir) { + return path.join(dataDir, "btcpay-setup.json"); +} +async function stashSetupContext(dataDir, ctx) { + const file = setupContextPath(dataDir); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(ctx), { mode: 0o600 }); +} +async function readSetupContext(dataDir) { + try { + const raw = await fs.readFile(setupContextPath(dataDir), "utf8"); + return JSON.parse(raw); + } catch { + return null; + } +} +async function clearSetupContext(dataDir) { + try { + await fs.unlink(setupContextPath(dataDir)); + } catch {} +} + +// Extract the set of unique store IDs the operator selected on +// BTCPay's authorize page. With `selectiveStores=true`, every granted +// permission is suffixed with `:STOREID` — same store ID on every +// entry when the operator picked just one. Returns an array of +// unique store ids (possibly empty if BTCPay didn't supply them). +function extractStoreIdsFromPermissions(permissions) { + if (!Array.isArray(permissions)) return []; + const ids = new Set(); + for (const p of permissions) { + if (typeof p !== "string") continue; + const m = p.match(/^btcpay\.store\.[^:]+:(\S+)$/); + if (m && m[1]) ids.add(m[1]); + } + return Array.from(ids); +} + +// Shared finalize: create the webhook on BTCPay, write the four +// BTCPay credentials into the live relay config, clear the setup +// context. Used by the explicit POST /finalize endpoint AND by the +// callback's auto-finalize path when only one store was authorized. +// +// Webhook URL needs to be a hostname BTCPay's container can reach +// over the network. Priority: +// 1. Relay's discovered clearnet URL (e.g. https://relay.keysat.xyz) +// — works because BTCPay container has standard internet DNS. +// 2. baseRelayUrl (the Host header from the operator's browser request) +// — fallback for operators with no clearnet URL set up; only works +// if BTCPay happens to be able to resolve that name. +// We never want to use mDNS .local here — BTCPay's container has no +// avahi so .local won't resolve and every webhook delivery fails +// silently, which is exactly the bug that caused paid invoices to +// not credit the install. +// +// Before creating the new webhook we GET + DELETE any existing +// webhooks on this store pointing at /relay/btcpay/webhook — keeps +// the dashboard's Reconnect button idempotent and prevents BTCPay +// from accumulating dead webhook entries that retry forever. +async function finalizeBtcpay({ ctx, storeId, dataDir, baseRelayUrl }) { + const selfPublicUrl = await readDiscoveredSelfPublicUrl(dataDir); + const webhookBase = (selfPublicUrl || baseRelayUrl).replace(/\/$/, ""); + const webhookUrl = `${webhookBase}/relay/btcpay/webhook`; + const webhookSecret = crypto.randomBytes(32).toString("hex"); + const apiBase = (ctx.btcpay_internal_url || ctx.btcpay_url).replace(/\/$/, ""); + const authHeader = `token ${ctx.api_key}`; + + // Delete any existing webhooks on this store that point at our + // webhook path. Otherwise Reconnect leaves the old (possibly + // broken) webhook in place AND adds a new one, and BTCPay sends + // each InvoiceSettled to both. + try { + const listRes = await fetch( + `${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks`, + { headers: { Authorization: authHeader } } + ); + if (listRes.ok) { + const existing = await listRes.json(); + if (Array.isArray(existing)) { + for (const w of existing) { + if (typeof w?.url === "string" && /\/relay\/btcpay\/webhook$/i.test(w.url)) { + try { + await fetch( + `${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks/${encodeURIComponent(w.id)}`, + { method: "DELETE", headers: { Authorization: authHeader } } + ); + console.log(`[btcpay/finalize] deleted stale webhook ${w.id} (url=${w.url})`); + } catch (err) { + console.warn(`[btcpay/finalize] failed to delete stale webhook ${w.id}: ${err?.message || err}`); + } + } + } + } + } + } catch (err) { + console.warn(`[btcpay/finalize] webhook cleanup skipped: ${err?.message || err}`); + } + + try { + const r = await fetch( + `${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks`, + { + method: "POST", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: webhookUrl, + secret: webhookSecret, + authorizedEvents: { everything: false, specificEvents: ["InvoiceSettled"] }, + automaticRedelivery: true, + enabled: true, + }), + } + ); + if (!r.ok) { + const text = await r.text(); + return { + ok: false, + status: 502, + error: "webhook_create_failed", + message: text.slice(0, 300), + }; + } + console.log(`[btcpay/finalize] webhook created: ${webhookUrl}`); + } catch (err) { + return { + ok: false, + status: 502, + error: "webhook_create_failed", + message: (err?.message || String(err)).slice(0, 200), + }; + } + + const configPath = path.join(dataDir, "config", "relay-config.json"); + let existing = {}; + try { + existing = JSON.parse(await fs.readFile(configPath, "utf8")); + } catch {} + existing.relay_btcpay_base_url = ctx.btcpay_url; + existing.relay_btcpay_internal_url = ctx.btcpay_internal_url || ""; + existing.relay_btcpay_public_url = ctx.btcpay_public_url || ""; + existing.relay_btcpay_store_id = storeId; + existing.relay_btcpay_api_key = ctx.api_key; + existing.relay_btcpay_webhook_secret = webhookSecret; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(existing), { mode: 0o600 }); + await clearSetupContext(dataDir); + return { ok: true, webhookUrl }; +} + +// Pull btcpay.internal_url from the discovered-services.json file +// written by the StartOS setup hook (init/setup.ts). Returns null if +// BTCPay isn't co-installed or discovery hasn't run. +async function readDiscoveredInternalUrl(dataDir) { + try { + const raw = await fs.readFile( + path.join(dataDir, "discovered-services.json"), + "utf8" + ); + const parsed = JSON.parse(raw); + return parsed?.btcpay?.internal_url || null; + } catch { + return null; + } +} + +// Companion to readDiscoveredInternalUrl: returns the public clearnet +// URL Spark Control discovered for BTCPay. Used to rewrite the +// buyer-facing checkout link. +async function readDiscoveredPublicUrl(dataDir) { + try { + const raw = await fs.readFile( + path.join(dataDir, "discovered-services.json"), + "utf8" + ); + const parsed = JSON.parse(raw); + return parsed?.btcpay?.public_url || null; + } catch { + return null; + } +} + +// The relay's OWN clearnet URL (e.g. https://relay.keysat.xyz) — +// captured at install time via sdk.serviceInterface.getAllOwn. +// Used to construct the webhook URL we register with BTCPay so +// BTCPay's container can actually reach it. +async function readDiscoveredSelfPublicUrl(dataDir) { + try { + const raw = await fs.readFile( + path.join(dataDir, "discovered-services.json"), + "utf8" + ); + const parsed = JSON.parse(raw); + return parsed?.self?.public_url || null; + } catch { + return null; + } +} + +// Reverse-engineer the public-facing URL of the relay from the +// incoming request. Used to build the callback + webhook URLs without +// having to ask the operator to type their own hostname in twice. +function absoluteRelayUrl(req) { + const proto = + (req.headers["x-forwarded-proto"] || "").split(",")[0].trim() || + req.protocol || + "http"; + const host = req.headers["x-forwarded-host"] || req.headers.host; + return `${proto}://${host}`; +} + +// Minimal HTML wrapper for the picker page. Matches the dashboard's +// dark palette so the operator doesn't feel like they've fallen off +// the edge of the world. +function html(title, body) { + return ` + +${title} +
${body}
`; +} diff --git a/server/routes/credits.js b/server/routes/credits.js new file mode 100644 index 0000000..f82143b --- /dev/null +++ b/server/routes/credits.js @@ -0,0 +1,748 @@ +// /relay/credits/* — buy + poll relay credit top-ups via BTCPay. +// +// Three endpoints: +// +// GET /relay/credits/packages (no auth) — operator's +// configured pricing menu. +// The Recap UI fetches this +// before showing the modal so +// it always reflects current +// pricing. +// +// POST /relay/credits/buy (X-Recap-Install-Id required) +// Body: { credits: number } +// Mints a BTCPay invoice tied +// to this install + the chosen +// credit pack. Returns +// { invoice_id, checkout_url, +// sats, credits, expires_at }. +// +// GET /relay/credits/invoice/:id (X-Recap-Install-Id required) +// Polls BTCPay for invoice +// status. Returns +// { status: "new"|"processing"| +// "settled"|"expired"|"invalid", +// credits_remaining }. +// +// POST /relay/btcpay/webhook (no auth — HMAC validated) +// BTCPay calls this on payment +// events. We dedupe by +// invoice_id and only credit +// the install once per invoice. + +import express from "express"; +import { resolveLicense } from "../keysat-client.js"; +import { + addPurchasedCredits, + getOrCreateRow, + computeRemaining, + licenseFingerprint, + extendUserTier, +} from "../credits.js"; +import { getConfigSnapshot, getTierQuotas, getCreditPackages } from "../config.js"; +import { + createInvoice, + getInvoice, + getInvoicePaymentMethods, + pickLightningFromPaymentMethods, + validateWebhookSignature, + BtcPayError, +} from "../btcpay-client.js"; +import { recordCall } from "../audit-log.js"; +import { envelope, errorEnvelope } from "./envelope.js"; + +// In-memory dedup of processed BTCPay invoice ids. BTCPay retries +// webhook deliveries on non-2xx responses or network errors, so the +// same InvoiceSettled event can land more than once. We don't want +// to grant credits twice for one paid invoice. +// +// Cleared on relay restart, which means an unlucky webhook duplicate +// straddling a restart would double-credit. Acceptable tradeoff for +// v1 (operator can manually adjust the ledger if it happens), but +// worth swapping for a persistent set once the relay sees real load. +const processedInvoices = new Set(); + +export function creditsRouter() { + const router = express.Router(); + + // ── GET /relay/credits/packages ────────────────────────────────────── + router.get("/credits/packages", async (_req, res) => { + const packages = await getCreditPackages(); + res.json({ packages }); + }); + + // ── POST /relay/credits/buy ────────────────────────────────────────── + router.post("/credits/buy", express.json(), async (req, res) => { + const installId = req.header("X-Recap-Install-Id"); + const auth = req.header("Authorization"); + if (!installId) { + const e = await errorEnvelope({ + error: "missing X-Recap-Install-Id header", + statusHint: 400, + }); + return res.status(400).json(e.body); + } + const requestedCredits = Number(req.body?.credits); + // Optional: where to send the buyer's browser after they pay. + // Frontend passes its own origin/page so the buyer lands back on + // Recap instead of stuck on BTCPay's "Paid ✓" screen. We trust + // the value blindly because Recap is the one calling us — but + // even so we only forward it to BTCPay's checkout, never use it + // in any sensitive context. + const returnUrl = + typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http") + ? req.body.return_url + : null; + if (!Number.isFinite(requestedCredits) || requestedCredits <= 0) { + const e = await errorEnvelope({ + error: "credits must be a positive number", + installId, + statusHint: 400, + }); + return res.status(400).json(e.body); + } + + // Match against the configured package list — buyers can't + // request arbitrary credit-for-sats ratios. + const packages = await getCreditPackages(); + const pack = packages.find((p) => p.credits === requestedCredits); + if (!pack) { + const e = await errorEnvelope({ + error: `unknown package: ${requestedCredits} credits`, + installId, + statusHint: 400, + }); + return res.status(400).json(e.body); + } + + const license = await resolveLicense(auth); + const cfg = await getConfigSnapshot(); + if ( + !cfg.relay_btcpay_base_url || + !cfg.relay_btcpay_store_id || + !cfg.relay_btcpay_api_key + ) { + const e = await errorEnvelope({ + error: "BTCPay is not configured on this relay — contact the operator", + installId, + tier: license.tier, + statusHint: 503, + }); + return res.status(503).json(e.body); + } + + try { + const buyerFp = licenseFingerprint(license); + const invoice = await createInvoice({ + baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, + storeId: cfg.relay_btcpay_store_id, + apiKey: cfg.relay_btcpay_api_key, + sats: pack.sats, + credits: pack.credits, + installId, + // Stash the buyer's license fingerprint (if any) on the + // invoice so the webhook handler grants credits to their + // license-keyed pool — survives restarts because BTCPay echoes + // the metadata back on every event. + licenseFingerprint: buyerFp, + packageLabel: `${pack.credits} relay credits`, + redirectURL: returnUrl || undefined, + redirectAutomatically: !!returnUrl, + }); + + await recordCall({ + install_id: installId, + license_fingerprint: buyerFp, + tier: license.tier, + pipeline: "credits_purchase", + backend: null, + model: null, + status: "invoice_created", + credit_charged: 0, + duration_ms: 0, + cost_usd: 0, + purchase_credits: pack.credits, + purchase_sats: pack.sats, + invoice_id: invoice.id, + }); + + // Fetch the per-payment-method breakdown so we can surface + // the raw BOLT11 invoice + Lightning deep-link to the Recap + // UI. Recap renders an inline QR + "Open in wallet" using + // these fields, removing the redirect-to-BTCPay-checkout + // step entirely (Phase 1 of the inline-payment migration). + // + // Best-effort: BTCPay sometimes generates the Lightning + // invoice asynchronously and the destination is empty on the + // first hit. We retry once with a short backoff. If still + // empty (LN not configured on the store, or slow LND), we + // fall through with null — Recap then falls back to the + // legacy "open checkout_url in another tab" path. Never + // fails the purchase because of this. + // + // Diagnostic capture: when bolt11 ends up null we surface + // enough info on the response (under `_debug`) to figure out + // why without operators having to comb through logs. Will be + // removed once Phase 1 is verified working everywhere. + let bolt11 = null; + let lightningPaymentLink = null; + let lnDebug = null; + try { + let methods = await getInvoicePaymentMethods({ + baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, + storeId: cfg.relay_btcpay_store_id, + apiKey: cfg.relay_btcpay_api_key, + invoiceId: invoice.id, + }); + let ln = pickLightningFromPaymentMethods(methods); + if (!ln) { + // Retry once after a short backoff — LN invoice generation + // is async on some BTCPay configurations (LND, c-lightning + // over Tor, etc.). + await new Promise((r) => setTimeout(r, 600)); + methods = await getInvoicePaymentMethods({ + baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, + storeId: cfg.relay_btcpay_store_id, + apiKey: cfg.relay_btcpay_api_key, + invoiceId: invoice.id, + }); + ln = pickLightningFromPaymentMethods(methods); + } + if (ln) { + bolt11 = ln.bolt11; + lightningPaymentLink = ln.paymentLink; + } else { + // Dump exactly what BTCPay returned so we can fix the + // pick-function heuristic for whatever version / + // shape this store uses. Sample first ~3 methods, + // exclude the per-method 'payments' history because + // those can be large. + const sample = (Array.isArray(methods) ? methods : []) + .slice(0, 3) + .map((m) => { + const out = {}; + for (const k of Object.keys(m || {})) { + if (k === "payments") continue; + const v = m[k]; + if (v === null || ["string", "number", "boolean"].includes(typeof v)) { + out[k] = v; + } else { + out[k] = `<${typeof v}>`; + } + } + return out; + }); + lnDebug = { + reason: "no_lightning_method", + methods_count: Array.isArray(methods) ? methods.length : 0, + sample, + }; + console.warn( + `[credits/buy] invoice ${invoice.id}: no Lightning method on payment-methods response — sample=${JSON.stringify(sample)}` + ); + } + } catch (err) { + lnDebug = { + reason: "fetch_failed", + message: (err?.message || String(err)).slice(0, 300), + status: + err && typeof err === "object" && "status" in err + ? err.status + : null, + }; + console.warn( + `[credits/buy] invoice ${invoice.id}: payment-methods fetch failed (${err?.message || err}) — Recap will fall back to checkout URL` + ); + } + + const rawCheckout = + invoice.checkoutLink || + (invoice.checkout && invoice.checkout.redirectURL) || + null; + const body = await envelope({ + result: { + invoice_id: invoice.id, + // BTCPay returns the checkout URL with the internal + // hostname we called it on (`btcpayserver.startos:23000`). + // Rewrite to the operator's browser-facing URL so the + // buyer's browser can actually reach it. + // Buyer-facing: prefer the clearnet public URL over the + // LAN base URL so buyers from anywhere on the internet + // can pay. Falls back to base URL when no clearnet is set. + checkout_url: rewriteCheckoutUrl( + rawCheckout, + cfg.relay_btcpay_public_url || cfg.relay_btcpay_base_url + ), + sats: pack.sats, + credits: pack.credits, + expires_at: invoice.expirationTime || null, + status: invoice.status || "New", + // Inline-payment fields (added in Phase 1). May be null + // if LN isn't configured on the store, or if BTCPay + // hasn't finished generating the LN invoice — Recap is + // built to fall back to checkout_url in those cases. + bolt11, + lightning_payment_link: lightningPaymentLink, + // Mirror BTCPay's invoice-level expiration as the + // canonical "this invoice is no longer payable" deadline. + // The Lightning invoice itself has its own expiry encoded + // in the BOLT11, but BTCPay aligns the two by default and + // surfacing the BTCPay number keeps things consistent + // with the existing expires_at field above. + lightning_expires_at: invoice.expirationTime || null, + // Diagnostic — only set when bolt11 came back null. Lets + // the Recap UI explain WHY the inline path didn't light + // up without operators tailing relay logs. Will be + // removed in a follow-up once Phase 1 is verified. + _ln_debug: lnDebug, + }, + installId, + license, + tier: license.tier, + }); + res.json(body); + } catch (err) { + console.error(`[credits/buy] failed: ${err?.message || err}`); + const status = + err instanceof BtcPayError && err.status === 401 ? 502 : 502; + const e = await errorEnvelope({ + error: err?.message || "btcpay_create_invoice_failed", + installId, + tier: license.tier, + statusHint: status, + }); + return res.status(status).json(e.body); + } + }); + + // ── GET /relay/credits/invoice/:id ─────────────────────────────────── + // Poll loop's friend. Returns the BTCPay status PLUS the install's + // updated credit balance so the UI can refresh both in one round- + // trip after settlement. + router.get("/credits/invoice/:id", async (req, res) => { + const installId = req.header("X-Recap-Install-Id"); + const auth = req.header("Authorization"); + if (!installId) { + const e = await errorEnvelope({ + error: "missing X-Recap-Install-Id header", + statusHint: 400, + }); + return res.status(400).json(e.body); + } + const invoiceId = (req.params.id || "").trim(); + if (!invoiceId) { + const e = await errorEnvelope({ + error: "missing invoice id", + installId, + statusHint: 400, + }); + return res.status(400).json(e.body); + } + const cfg = await getConfigSnapshot(); + const license = await resolveLicense(auth); + try { + const invoice = await getInvoice({ + baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, + storeId: cfg.relay_btcpay_store_id, + apiKey: cfg.relay_btcpay_api_key, + invoiceId, + }); + // Guard: only the install that minted this invoice can poll it. + // BTCPay metadata may carry install_id from createInvoice; if + // present, refuse cross-install lookups. + const meta = invoice.metadata || {}; + if (meta.install_id && meta.install_id !== installId) { + const e = await errorEnvelope({ + error: "invoice belongs to a different install", + installId, + tier: license.tier, + statusHint: 403, + }); + return res.status(403).json(e.body); + } + const row = await getOrCreateRow({ installId, license }); + const quota = await getTierQuotas(); + const balance = computeRemaining(row, quota); + const body = await envelope({ + result: { + invoice_id: invoice.id, + status: normalizeStatus(invoice.status), + status_raw: invoice.status, + credits: Number(meta.credits) || null, + sats: invoice.amount ? Number(invoice.amount) : null, + checkout_url: rewriteCheckoutUrl( + invoice.checkoutLink || null, + cfg.relay_btcpay_public_url || cfg.relay_btcpay_base_url + ), + balance: { + tier_remaining: balance.remaining, + purchased: balance.purchased, + total: balance.total, + }, + }, + installId, + license, + tier: license.tier, + }); + res.json(body); + } catch (err) { + console.error(`[credits/invoice] failed: ${err?.message || err}`); + const e = await errorEnvelope({ + error: err?.message || "btcpay_get_invoice_failed", + installId, + tier: license.tier, + statusHint: 502, + }); + return res.status(502).json(e.body); + } + }); + + // ── POST /relay/btcpay/webhook ─────────────────────────────────────── + // BTCPay calls this on every invoice event. We: + // 1. Validate the HMAC-SHA256 signature against the operator-set + // webhook secret. + // 2. Skip anything that isn't an "InvoiceSettled" event. + // 3. Dedupe by invoice id (BTCPay may retry on failures). + // 4. Look up the invoice metadata, then credit the install. + // + // Uses express.raw() so we have the EXACT bytes BTCPay signed — + // any whitespace canonicalization after JSON-parse would break the + // HMAC check. + router.post( + "/btcpay/webhook", + express.raw({ type: "*/*", limit: "1mb" }), + async (req, res) => { + const cfg = await getConfigSnapshot(); + const secret = cfg.relay_btcpay_webhook_secret; + if (!secret) { + console.warn("[btcpay/webhook] secret not configured — rejecting"); + return res.status(503).json({ error: "webhook_secret_not_configured" }); + } + const sig = req.header("BTCPay-Sig") || ""; + const ok = validateWebhookSignature({ + rawBody: req.body, + signatureHeader: sig, + secret, + }); + if (!ok) { + console.warn( + `[btcpay/webhook] signature mismatch (header=${sig.slice(0, 20)}...)` + ); + return res.status(401).json({ error: "bad_signature" }); + } + let payload; + try { + payload = JSON.parse(req.body.toString("utf8")); + } catch (err) { + return res.status(400).json({ error: "bad_json" }); + } + + // BTCPay event types we care about. "InvoiceSettled" fires once + // the invoice is fully paid + confirmed. We deliberately ignore + // "InvoiceProcessing" (in-mempool, not yet confirmed) and + // "InvoiceExpired" (timed out without payment). + const type = payload?.type || ""; + const invoiceId = payload?.invoiceId || payload?.invoice_id || null; + if (!invoiceId) { + return res.status(200).json({ ok: true, ignored: "no_invoice_id" }); + } + if (type !== "InvoiceSettled") { + // Acknowledge to stop BTCPay from retrying, but do nothing. + return res.status(200).json({ ok: true, ignored: `type=${type}` }); + } + + const dedupKey = `${cfg.relay_btcpay_store_id}|${invoiceId}`; + if (processedInvoices.has(dedupKey)) { + return res + .status(200) + .json({ ok: true, ignored: "already_processed", invoiceId }); + } + + // Pull the invoice from BTCPay to read its metadata (the webhook + // payload doesn't carry our embedded install_id + credits + // fields directly — they live on the invoice object). + let invoice; + try { + invoice = await getInvoice({ + baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, + storeId: cfg.relay_btcpay_store_id, + apiKey: cfg.relay_btcpay_api_key, + invoiceId, + }); + } catch (err) { + console.error( + `[btcpay/webhook] getInvoice failed for ${invoiceId}: ${err?.message || err}` + ); + // Return 5xx so BTCPay retries — this is likely a transient + // network error against BTCPay's own API. + return res.status(502).json({ error: "btcpay_lookup_failed" }); + } + + const meta = invoice.metadata || {}; + + // Self-serve subscription purchase (Pro/Max) — different metadata shape + // than credit purchases. Extend the buyer's prepaid period and stop. + if (meta.product === "recap_tier_subscription") { + const subUserId = typeof meta.user_id === "string" ? meta.user_id : ""; + const subTier = meta.tier; + const periodDays = Number(meta.period_days) || 30; + if (!subUserId || (subTier !== "pro" && subTier !== "max")) { + processedInvoices.add(dedupKey); + return res + .status(200) + .json({ ok: true, ignored: "bad_tier_metadata", invoiceId }); + } + try { + const row = await extendUserTier({ + userId: subUserId, + tier: subTier, + periodDays, + }); + processedInvoices.add(dedupKey); + console.log( + `[btcpay/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (invoice ${invoiceId}) → expires ${row.subscription_expires_at}`, + ); + return res.status(200).json({ + ok: true, + tier: subTier, + user: subUserId, + expires_at: row.subscription_expires_at, + }); + } catch (err) { + console.error( + `[btcpay/webhook] extendUserTier failed: ${err?.message || err}`, + ); + return res.status(500).json({ error: "tier_grant_failed" }); + } + } + + const installId = meta.install_id; + const credits = Number(meta.credits); + // license_fingerprint was stashed at buy time when the buyer + // had an active paid license. Used here to route the credit to + // their license-keyed pool instead of an install-keyed pool. + // Missing (older invoices, or Core buyers) → falls back to + // install-keyed. + const buyerFp = meta.license_fingerprint || null; + const creditKey = buyerFp ? `lic:${buyerFp}` : null; + if (!installId || !Number.isFinite(credits) || credits <= 0) { + // Not ours — could be an invoice from another product on the + // same store. Mark processed so we don't keep retrying, and + // 200 so BTCPay stops calling. + processedInvoices.add(dedupKey); + return res + .status(200) + .json({ ok: true, ignored: "no_recap_metadata" }); + } + + try { + const newBalance = await addPurchasedCredits({ + installId, + creditKey, + amount: credits, + }); + processedInvoices.add(dedupKey); + await recordCall({ + install_id: installId, + license_fingerprint: buyerFp, + tier: null, + pipeline: "credits_purchase", + backend: null, + model: null, + status: "settled", + credit_charged: 0, + duration_ms: 0, + cost_usd: 0, + purchase_credits: credits, + purchase_sats: Number(invoice.amount) || null, + invoice_id: invoiceId, + purchased_balance_after: newBalance, + }); + console.log( + `[btcpay/webhook] credited install ${installId.slice(0, 8)}… with ${credits} credits (invoice ${invoiceId})${creditKey ? ` → pool ${creditKey}` : ""}` + ); + return res + .status(200) + .json({ ok: true, credited: credits, install: installId }); + } catch (err) { + console.error( + `[btcpay/webhook] addPurchasedCredits failed: ${err?.message || err}` + ); + return res + .status(500) + .json({ error: "credit_grant_failed" }); + } + } + ); + + return router; +} + +// Rewrite a BTCPay checkout URL's host to the operator's browser- +// facing URL. BTCPay builds the checkoutLink using whatever host the +// invoice-create API call came in on — and since we hit it via +// `http://btcpayserver.startos:23000` (the internal docker network +// hostname), the returned URL is `btcpayserver.startos:23000/i/...` +// which the buyer's browser can't resolve. +// +// Fix: parse the URL, swap origin (scheme + host + port) with the +// operator's `relay_btcpay_base_url` (set by the one-click setup to +// the discovered mDNS / clearnet URL). Path + query are preserved. +// If parsing fails for any reason, return the URL unchanged — better +// to ship a broken link than crash the buy flow. +function rewriteCheckoutUrl(url, browserBase) { + if (!url || !browserBase) return url; + try { + const u = new URL(url); + const b = new URL(browserBase); + u.protocol = b.protocol; + u.hostname = b.hostname; + // Explicitly assign port from the base — including the empty + // string when the base has no port (HTTPS on default 443). + // Using `u.host = b.host` looks equivalent but Node's URL keeps + // the original port when the new host string has none, so a + // rewrite from `btcpayserver.startos:23000` → `btcpay.keysat.xyz` + // ended up as `btcpay.keysat.xyz:23000`. Assigning port directly + // clears it. + u.port = b.port || ""; + return u.toString(); + } catch { + return url; + } +} + +// Recovery: when the BTCPay webhook URL was broken and paid +// invoices never got credited, this scans BTCPay's recent settled +// invoices and grants credits for ones the relay hasn't processed. +// Idempotent via the same processedInvoices dedup the webhook uses, +// so re-running is safe. +// +// Exported so the admin route in btcpay-setup.js can call it. Not +// exposed via /relay/* because it's operator-initiated, not buyer. +export async function rescanSettledInvoices() { + const cfg = await getConfigSnapshot(); + if ( + !cfg.relay_btcpay_base_url || + !cfg.relay_btcpay_store_id || + !cfg.relay_btcpay_api_key + ) { + return { ok: false, error: "btcpay_not_configured" }; + } + const apiBase = ( + cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url + ).replace(/\/$/, ""); + const sinceMs = Date.now() - 30 * 24 * 3600 * 1000; + let invoices; + try { + const r = await fetch( + `${apiBase}/api/v1/stores/${encodeURIComponent(cfg.relay_btcpay_store_id)}/invoices?take=1000&status=Settled&startDate=${Math.floor(sinceMs / 1000)}`, + { headers: { Authorization: `token ${cfg.relay_btcpay_api_key}` } } + ); + if (!r.ok) { + const text = await r.text(); + return { + ok: false, + error: "btcpay_list_invoices_failed", + message: text.slice(0, 300), + }; + } + invoices = await r.json(); + } catch (err) { + return { + ok: false, + error: "rescan_failed", + message: (err?.message || String(err)).slice(0, 200), + }; + } + let credited = 0; + let alreadyProcessed = 0; + let skipped = 0; + const details = []; + for (const invoice of invoices || []) { + const dedupKey = `${cfg.relay_btcpay_store_id}|${invoice.id}`; + if (processedInvoices.has(dedupKey)) { + alreadyProcessed++; + continue; + } + const meta = invoice.metadata || {}; + const installId = meta.install_id; + const credits = Number(meta.credits); + const buyerFp = meta.license_fingerprint || null; + const creditKey = buyerFp ? `lic:${buyerFp}` : null; + if ( + !installId || + !Number.isFinite(credits) || + credits <= 0 || + meta.product !== "recap_credits" + ) { + skipped++; + continue; + } + try { + const newBalance = await addPurchasedCredits({ + installId, + creditKey, + amount: credits, + }); + processedInvoices.add(dedupKey); + await recordCall({ + install_id: installId, + license_fingerprint: buyerFp, + tier: null, + pipeline: "credits_purchase", + backend: null, + model: null, + status: "settled", + credit_charged: 0, + duration_ms: 0, + cost_usd: 0, + purchase_credits: credits, + purchase_sats: Number(invoice.amount) || null, + invoice_id: invoice.id, + purchased_balance_after: newBalance, + recovery_rescan: true, + }); + credited++; + details.push({ + invoice_id: invoice.id, + install: installId.slice(0, 8), + credits, + new_balance: newBalance, + }); + } catch (err) { + console.error( + `[credits/rescan] addPurchasedCredits failed for ${invoice.id}: ${err?.message || err}` + ); + skipped++; + } + } + console.log( + `[credits/rescan] credited=${credited} already=${alreadyProcessed} skipped=${skipped}` + ); + return { + ok: true, + credited, + already_processed: alreadyProcessed, + skipped, + details, + }; +} + +// BTCPay's status enum → a coarser one Recap can render off of. +function normalizeStatus(s) { + switch ((s || "").toLowerCase()) { + case "new": + return "new"; + case "processing": + return "processing"; + case "settled": + case "complete": + return "settled"; + case "expired": + return "expired"; + case "invalid": + return "invalid"; + default: + return (s || "unknown").toLowerCase(); + } +} diff --git a/server/routes/user-tier.js b/server/routes/user-tier.js new file mode 100644 index 0000000..acf31f0 --- /dev/null +++ b/server/routes/user-tier.js @@ -0,0 +1,394 @@ +// POST /relay/user-tier + GET /relay/user-tier/:userId (core-decoupling) +// +// Operator-only. The cloud Recaps server (recaps.cc) calls these to SET and +// READ a cloud user's Pro/Max tier — the relay is the source of truth for +// cloud tiers, so granting Pro/Max means writing the user's credit row +// here. Authenticated by the shared operator key (X-Recap-Operator-Key); +// no per-user Keysat license is involved. +// +// POST body: { user_id, tier: "core"|"pro"|"max", expires_at?: ISO } +// (self-serve subscription purchase is a later slice; for now tiers are +// operator-set, and "revoke" = set tier back to "core".) + +import express from "express"; +import { verifyOperatorKey, isSubscriptionExpired } from "../identity.js"; +import { + setUserTier, + getUserCreditRow, + computeRemaining, + snapshotAll, +} from "../credits.js"; +import { + getTierQuotas, + getTierPricesSats, + getTierPricesFiatCents, + getSubscriptionPeriodDays, + getZapriteConfig, + getConfigSnapshot, +} from "../config.js"; +import { + createTierInvoice, + getInvoicePaymentMethods, + pickLightningFromPaymentMethods, +} from "../btcpay-client.js"; +import { createOrder as createZapriteOrder } from "../zaprite-client.js"; + +const TIERS = new Set(["core", "pro", "max"]); +const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; + +async function reportRow(userId, row) { + const quota = await getTierQuotas(); + const balance = computeRemaining(row, quota); + // `tier` is the EFFECTIVE tier (expiry-enforced) — what callers gate on + // and what Recaps caches. `tier_snapshot` is the raw stored value, so the + // operator can still see "paid but lapsed". + const expired = isSubscriptionExpired(row); + const effectiveTier = expired ? "core" : row.tier_snapshot || "core"; + return { + ok: true, + user_id: userId, + tier: effectiveTier, + tier_snapshot: row.tier_snapshot || "core", + subscription_expired: expired, + subscription_expires_at: row.subscription_expires_at || null, + credits_remaining: balance.total, // null = unlimited (Max) + tier_remaining: balance.remaining, + purchased_balance: balance.purchased, + }; +} + +export function userTierRouter() { + const router = express.Router(); + + router.post("/user-tier", express.json({ limit: "16kb" }), async (req, res) => { + if (!(await verifyOperatorKey(req))) { + return res.status(401).json({ error: "invalid_operator_key" }); + } + const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : ""; + const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : ""; + const expiresAt = + typeof req.body?.expires_at === "string" && req.body.expires_at.trim() + ? req.body.expires_at.trim() + : null; + if (!USER_ID_RE.test(userId)) { + return res.status(400).json({ error: "invalid_user_id" }); + } + if (!TIERS.has(tier)) { + return res.status(400).json({ error: "tier_must_be_core_pro_or_max" }); + } + const row = await setUserTier({ userId, tier, expiresAt }); + console.log(`[user-tier] set ${userId} → ${tier}${expiresAt ? ` (expires ${expiresAt})` : ""}`); + res.json(await reportRow(userId, row)); + }); + + // Create a BTCPay invoice to buy a prepaid period of pro/max for a user. + // The Recaps server calls this (operator-key authed) when a signed-in user + // hits "Pay with Bitcoin"; it returns the checkout URL + invoice id. On + // settlement the BTCPay webhook (routes/credits.js) calls extendUserTier. + router.post("/tier-invoice", express.json({ limit: "16kb" }), async (req, res) => { + if (!(await verifyOperatorKey(req))) { + return res.status(401).json({ error: "invalid_operator_key" }); + } + const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : ""; + const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : ""; + const returnUrl = + typeof req.body?.return_url === "string" ? req.body.return_url.trim() : ""; + if (!USER_ID_RE.test(userId)) { + return res.status(400).json({ error: "invalid_user_id" }); + } + if (tier !== "pro" && tier !== "max") { + return res.status(400).json({ error: "tier_must_be_pro_or_max" }); + } + const prices = await getTierPricesSats(); + const sats = prices[tier]; + if (!Number.isFinite(sats) || sats <= 0) { + return res.status(400).json({ error: "tier_not_priced" }); + } + const periodDays = await getSubscriptionPeriodDays(); + + const cfg = await getConfigSnapshot(); + if ( + !cfg.relay_btcpay_base_url || + !cfg.relay_btcpay_store_id || + !cfg.relay_btcpay_api_key + ) { + return res.status(503).json({ error: "btcpay_not_configured" }); + } + try { + const invoice = await createTierInvoice({ + baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, + storeId: cfg.relay_btcpay_store_id, + apiKey: cfg.relay_btcpay_api_key, + sats, + userId, + tier, + periodDays, + redirectURL: returnUrl || undefined, + redirectAutomatically: !!returnUrl, + }); + // Prefer the operator's public BTCPay host for the buyer-facing link + // (the invoice may have been created against an internal URL). + let checkoutUrl = invoice.checkoutLink || null; + if (checkoutUrl && cfg.relay_btcpay_public_url) { + try { + const u = new URL(checkoutUrl); + const pub = new URL(cfg.relay_btcpay_public_url); + u.protocol = pub.protocol; + u.host = pub.host; + checkoutUrl = u.toString(); + } catch {} + } + // Fetch the Lightning BOLT11 so the Recaps app can render an INLINE + // QR/invoice (no redirect to the hosted BTCPay page) — same as the + // credit-pack flow. BTCPay generates the LN invoice asynchronously on + // some configs, so retry once after a short backoff. On total failure + // bolt11 stays null and the app falls back to the hosted checkout_url. + let bolt11 = null; + let lightningPaymentLink = null; + let lnDebug = null; + try { + const pmArgs = { + baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, + storeId: cfg.relay_btcpay_store_id, + apiKey: cfg.relay_btcpay_api_key, + invoiceId: invoice.id, + }; + let methods = await getInvoicePaymentMethods(pmArgs); + let ln = pickLightningFromPaymentMethods(methods); + if (!ln) { + await new Promise((r) => setTimeout(r, 600)); + methods = await getInvoicePaymentMethods(pmArgs); + ln = pickLightningFromPaymentMethods(methods); + } + if (ln) { + bolt11 = ln.bolt11; + lightningPaymentLink = ln.paymentLink; + } else { + // Capture a sanitized sample of the payment-methods response so + // operators can diagnose why no BOLT11 came back (BTCPay version + // differences, LN not configured, etc.) — same diagnostic the + // credit-pack flow records. + const sample = (Array.isArray(methods) ? methods : []) + .slice(0, 3) + .map((m) => { + const out = {}; + for (const k of Object.keys(m || {})) { + if (k === "payments") continue; + const v = m[k]; + out[k] = + v === null || ["string", "number", "boolean"].includes(typeof v) + ? v + : `<${typeof v}>`; + } + return out; + }); + lnDebug = { + reason: "no_lightning_method", + methods_count: Array.isArray(methods) ? methods.length : 0, + sample, + }; + console.warn( + `[tier-invoice] invoice ${invoice.id}: no Lightning method on payment-methods — falling back to hosted checkout. sample=${JSON.stringify(sample)}`, + ); + } + } catch (err) { + lnDebug = { + reason: "fetch_failed", + message: (err?.message || String(err)).slice(0, 300), + }; + console.warn( + `[tier-invoice] invoice ${invoice.id}: payment-methods fetch failed (${err?.message || err}) — falling back to hosted checkout`, + ); + } + console.log( + `[tier-invoice] ${tier} ${sats} sats / ${periodDays}d for ${userId.slice(0, 8)}… (invoice ${invoice.id}, ln=${bolt11 ? "yes" : "no"})`, + ); + res.json({ + ok: true, + invoice_id: invoice.id, + checkout_url: checkoutUrl, + sats, + tier, + period_days: periodDays, + bolt11, + lightning_payment_link: lightningPaymentLink, + lightning_expires_at: invoice.expirationTime || null, + expires_at: invoice.expirationTime || null, + _ln_debug: lnDebug, + }); + } catch (err) { + console.error(`[tier-invoice] createTierInvoice failed: ${err?.message || err}`); + res + .status(502) + .json({ error: "invoice_create_failed", message: err?.message || String(err) }); + } + }); + + // Create a Zaprite hosted-checkout order to buy a prepaid Pro/Max period + // for a user with a CARD. Operator-key authed (the Recaps server proxies + // this when a signed-in user clicks "Pay by card"). Returns the checkout + // URL + order id. On settlement the Zaprite webhook (below) re-fetches + // the order and calls extendUserTier — the same landing point as the + // BTCPay rail. + router.post("/tier-zaprite-order", express.json({ limit: "16kb" }), async (req, res) => { + if (!(await verifyOperatorKey(req))) { + return res.status(401).json({ error: "invalid_operator_key" }); + } + const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : ""; + const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : ""; + const returnUrl = + typeof req.body?.return_url === "string" ? req.body.return_url.trim() : ""; + if (!USER_ID_RE.test(userId)) { + return res.status(400).json({ error: "invalid_user_id" }); + } + if (tier !== "pro" && tier !== "max") { + return res.status(400).json({ error: "tier_must_be_pro_or_max" }); + } + const prices = await getTierPricesFiatCents(); + const amount = prices[tier]; + if (!Number.isFinite(amount) || amount <= 0) { + return res.status(400).json({ error: "tier_not_priced" }); + } + const periodDays = await getSubscriptionPeriodDays(); + const zaprite = await getZapriteConfig(); + if (!zaprite.apiKey) { + return res.status(503).json({ error: "zaprite_not_configured" }); + } + try { + const order = await createZapriteOrder({ + baseURL: zaprite.baseUrl, + apiKey: zaprite.apiKey, + amount, + currency: zaprite.currency, + label: `Recaps ${tier.toUpperCase()} — ${periodDays} days`, + metadata: { + product: "recap_tier_subscription", + user_id: userId, + tier, + period_days: periodDays, + }, + redirectUrl: returnUrl || undefined, + }); + const checkoutUrl = order?.checkoutUrl || null; + if (!order?.id || !checkoutUrl) { + throw new Error("Zaprite order missing id/checkoutUrl"); + } + console.log( + `[tier-zaprite] ${tier} ${amount} ${zaprite.currency} / ${periodDays}d for ${userId.slice(0, 8)}… (order ${order.id})`, + ); + res.json({ + ok: true, + order_id: order.id, + checkout_url: checkoutUrl, + amount, + currency: zaprite.currency, + tier, + period_days: periodDays, + }); + } catch (err) { + console.error(`[tier-zaprite] createOrder failed: ${err?.message || err}`); + res + .status(502) + .json({ error: "zaprite_order_failed", message: err?.message || String(err) }); + } + }); + + // List the buyable subscription plans + their sats prices. Operator-key + // authed (the Recaps server proxies this to its purchase UI so the tier + // prices stay sourced from the relay's config, never hardcoded in the + // app). Returns { ok, period_days, plans: [{tier, sats}] }. + router.get("/tier-plans", async (req, res) => { + if (!(await verifyOperatorKey(req))) { + return res.status(401).json({ error: "invalid_operator_key" }); + } + const prices = await getTierPricesSats(); + const fiat = await getTierPricesFiatCents(); + const periodDays = await getSubscriptionPeriodDays(); + const zaprite = await getZapriteConfig(); + const quotas = await getTierQuotas(); + const cardAvailable = !!zaprite.apiKey; + const plans = ["pro", "max"] + .map((tier) => ({ + tier, + sats: prices[tier], + // Card-rail price in the currency's smallest unit (cents for USD). + fiat_amount: fiat[tier], + fiat_currency: zaprite.currency, + // Monthly relay-credit allotment for this tier, sourced from the + // operator's Adjust-Tier-Quotas config. A number is the real + // per-period credit count (e.g. Pro 50); null means unlimited. + // The card shows whichever — so it always reflects the live config. + credits_per_period: + typeof quotas?.[tier]?.monthly === "number" + ? quotas[tier].monthly + : null, + })) + .filter((p) => Number.isFinite(p.sats) && p.sats > 0); + res.json({ + ok: true, + period_days: periodDays, + plans, + // The UI hides the "Pay by card" link when the operator hasn't + // configured Zaprite (so it never offers a rail that 503s). + card_available: cardAvailable, + }); + }); + + // List cloud users whose prepaid period expires within the next + // `within_days` OR lapsed within the last `lapsed_days`. Operator-key + // authed. The relay owns the expiry (it's the subscription source of + // truth), but not the email — so the Recaps server calls this to decide + // who to send expiry-reminder emails to, then maps user_id → email on + // its side. Returns { ok, now, subscriptions: [{user_id, tier, + // expires_at, expired, days_left}] }, paid tiers only. + router.get("/expiring-subscriptions", async (req, res) => { + if (!(await verifyOperatorKey(req))) { + return res.status(401).json({ error: "invalid_operator_key" }); + } + const clampInt = (v, def, lo, hi) => { + const n = parseInt(v, 10); + if (!Number.isFinite(n)) return def; + return Math.max(lo, Math.min(hi, n)); + }; + const withinDays = clampInt(req.query.within_days, 7, 0, 120); + const lapsedDays = clampInt(req.query.lapsed_days, 3, 0, 120); + const now = Date.now(); + const DAY = 86_400_000; + const upperMs = now + withinDays * DAY; + const lowerMs = now - lapsedDays * DAY; + const out = []; + for (const row of snapshotAll()) { + const key = row.credit_key || ""; + if (!key.startsWith("user:")) continue; + const tier = row.tier_snapshot || "core"; + if (tier !== "pro" && tier !== "max") continue; + const exp = row.subscription_expires_at; + if (!exp) continue; // open-ended grant (operator comp) — never expires + const t = new Date(exp).getTime(); + if (!Number.isFinite(t)) continue; + if (t > upperMs || t < lowerMs) continue; // outside the reminder window + out.push({ + user_id: key.slice("user:".length), + tier, + expires_at: exp, + expired: t < now, + days_left: Math.ceil((t - now) / DAY), + }); + } + res.json({ ok: true, now: new Date(now).toISOString(), subscriptions: out }); + }); + + router.get("/user-tier/:userId", async (req, res) => { + if (!(await verifyOperatorKey(req))) { + return res.status(401).json({ error: "invalid_operator_key" }); + } + const userId = (req.params.userId || "").trim(); + if (!USER_ID_RE.test(userId)) { + return res.status(400).json({ error: "invalid_user_id" }); + } + const row = await getUserCreditRow(userId); + res.json(await reportRow(userId, row)); + }); + + return router; +} diff --git a/server/routes/zaprite-webhook.js b/server/routes/zaprite-webhook.js new file mode 100644 index 0000000..154926e --- /dev/null +++ b/server/routes/zaprite-webhook.js @@ -0,0 +1,135 @@ +// POST /relay/zaprite/webhook — card-rail settlement handler. +// +// Zaprite calls this when an order's activity changes. We do NOT trust the +// webhook body to decide whether money landed (Zaprite's webhook-signing +// mechanism isn't publicly documented). Instead we VERIFY by re-fetching +// the order from Zaprite's authenticated API and checking its status — +// the same re-fetch-to-verify pattern the BTCPay handler uses. The body is +// only a nudge that carries the order id. +// +// On a paid order tagged product:"recap_tier_subscription", we extend the +// buyer's prepaid period via extendUserTier — the SAME landing point as +// the BTCPay (Bitcoin) rail, so both rails converge on one tier-grant path. + +import express from "express"; +import { extendUserTier } from "../credits.js"; +import { getZapriteConfig } from "../config.js"; +import { getOrder, isOrderPaid, orderIdFromWebhook } from "../zaprite-client.js"; + +// In-memory dedup of fully-processed orders (mirrors the BTCPay handler's +// processedInvoices). Zaprite retries on non-200, and may fire multiple +// events per order, so we guard the grant. Cleared on restart — a +// re-delivered webhook after restart re-fetches + re-grants, but +// extendUserTier is keyed per order via this set within a process; a +// duplicate grant across a restart is the same harmless "extend by one +// period" the operator-comp path already tolerates. (Acceptable: card +// double-fires across a restart are vanishingly rare.) +const processedZaprite = new Set(); + +export function zapriteWebhookRouter() { + const router = express.Router(); + + // express.raw so a malformed/empty body can't 400 the webhook into an + // infinite Zaprite retry loop — we parse defensively and always 200 + // unless we genuinely want a retry (transient lookup failure → 5xx). + router.post( + "/zaprite/webhook", + express.raw({ type: "*/*", limit: "1mb" }), + async (req, res) => { + let payload = {}; + try { + const txt = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : ""; + payload = txt ? JSON.parse(txt) : {}; + } catch { + return res.status(200).json({ ok: true, ignored: "bad_json" }); + } + + const orderId = orderIdFromWebhook(payload); + if (!orderId) { + return res.status(200).json({ ok: true, ignored: "no_order_id" }); + } + const dedupKey = `zaprite:${orderId}`; + if (processedZaprite.has(dedupKey)) { + return res + .status(200) + .json({ ok: true, ignored: "already_processed", orderId }); + } + + const zaprite = await getZapriteConfig(); + if (!zaprite.apiKey) { + // Can't verify without the API key. 200 so Zaprite stops retrying. + console.warn("[zaprite/webhook] received but Zaprite not configured"); + return res.status(200).json({ ok: true, ignored: "not_configured" }); + } + + // Re-fetch the order — this is the authoritative status + metadata. + let order; + try { + order = await getOrder({ + baseURL: zaprite.baseUrl, + apiKey: zaprite.apiKey, + orderId, + }); + } catch (err) { + console.error( + `[zaprite/webhook] getOrder failed for ${orderId}: ${err?.message || err}`, + ); + // 5xx → Zaprite retries; likely a transient network/API blip. + return res.status(502).json({ error: "order_lookup_failed" }); + } + + if (!isOrderPaid(order)) { + // PENDING / PROCESSING / UNDERPAID — ack but don't grant, and DON'T + // mark processed: a later settle webhook for this order should be + // allowed to grant once it's actually paid. + return res.status(200).json({ + ok: true, + ignored: `status=${order?.status || "unknown"}`, + orderId, + }); + } + + const meta = order.metadata || {}; + if (meta.product !== "recap_tier_subscription") { + processedZaprite.add(dedupKey); + return res + .status(200) + .json({ ok: true, ignored: "not_a_tier_order", orderId }); + } + const subUserId = typeof meta.user_id === "string" ? meta.user_id : ""; + const subTier = meta.tier; + const periodDays = Number(meta.period_days) || 30; + if (!subUserId || (subTier !== "pro" && subTier !== "max")) { + processedZaprite.add(dedupKey); + return res + .status(200) + .json({ ok: true, ignored: "bad_tier_metadata", orderId }); + } + + try { + const row = await extendUserTier({ + userId: subUserId, + tier: subTier, + periodDays, + }); + processedZaprite.add(dedupKey); + console.log( + `[zaprite/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (order ${orderId}) → expires ${row.subscription_expires_at}`, + ); + return res.status(200).json({ + ok: true, + tier: subTier, + user: subUserId, + expires_at: row.subscription_expires_at, + }); + } catch (err) { + console.error( + `[zaprite/webhook] extendUserTier failed: ${err?.message || err}`, + ); + return res.status(500).json({ error: "tier_grant_failed" }); + } + }, + ); + + return router; +} diff --git a/server/test/credits.test.js b/server/test/credits.test.js new file mode 100644 index 0000000..8282000 --- /dev/null +++ b/server/test/credits.test.js @@ -0,0 +1,136 @@ +// Node-built-in test runner. Run with `node --test server/test/`. +// +// Targets the credit-key resolver added in the license-keyed-credits +// refactor. The headline guarantee: same license + different +// install_ids resolve to the SAME credit key. Plus a handful of +// adjacent cases worth pinning so the contract doesn't drift. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { getCreditKey, licenseFingerprint } from "../credits.js"; + +test("licenseFingerprint: stable hash from licenseUuid", () => { + const fp1 = licenseFingerprint({ + tier: "pro", + licenseUuid: "11111111-2222-3333-4444-555555555555", + }); + const fp2 = licenseFingerprint({ + tier: "pro", + licenseUuid: "11111111-2222-3333-4444-555555555555", + }); + assert.equal(typeof fp1, "string"); + assert.equal(fp1.length, 16); + assert.equal(fp1, fp2, "same licenseUuid should yield same fingerprint"); +}); + +test("licenseFingerprint: different UUIDs yield different fingerprints", () => { + const fp1 = licenseFingerprint({ + tier: "pro", + licenseUuid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + }); + const fp2 = licenseFingerprint({ + tier: "pro", + licenseUuid: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + }); + assert.notEqual(fp1, fp2); +}); + +test("licenseFingerprint: missing licenseUuid returns null", () => { + assert.equal(licenseFingerprint(null), null); + assert.equal(licenseFingerprint({ tier: "pro" }), null); + assert.equal(licenseFingerprint({ tier: "pro", licenseUuid: null }), null); +}); + +test("getCreditKey: Core falls back to inst:", () => { + const k = getCreditKey({ + installId: "abc-123", + license: { tier: "core" }, + }); + assert.equal(k, "inst:abc-123"); +}); + +test("getCreditKey: anonymous (no license) uses inst:", () => { + assert.equal(getCreditKey({ installId: "xyz" }), "inst:xyz"); + assert.equal(getCreditKey({ installId: "xyz", license: null }), "inst:xyz"); +}); + +test("getCreditKey: Pro license routes to lic:", () => { + const k = getCreditKey({ + installId: "any-install", + license: { + tier: "pro", + licenseUuid: "11111111-2222-3333-4444-555555555555", + }, + }); + assert.match(k, /^lic:[0-9a-f]{16}$/); +}); + +// Headline guarantee for the refactor: one license activated on two +// different installs MUST resolve to the same credit-key, so both +// devices share one Pro monthly pool. +test("getCreditKey: same license + different installs → same key", () => { + const license = { + tier: "pro", + licenseUuid: "11111111-2222-3333-4444-555555555555", + }; + const k1 = getCreditKey({ installId: "install-A", license }); + const k2 = getCreditKey({ installId: "install-B", license }); + assert.equal(k1, k2); + assert.match(k1, /^lic:/); +}); + +test("getCreditKey: same install + different licenses → different keys", () => { + const installId = "shared-install"; + const k1 = getCreditKey({ + installId, + license: { + tier: "pro", + licenseUuid: "11111111-2222-3333-4444-555555555555", + }, + }); + const k2 = getCreditKey({ + installId, + license: { + tier: "pro", + licenseUuid: "99999999-9999-9999-9999-999999999999", + }, + }); + assert.notEqual(k1, k2); +}); + +test("getCreditKey: Max tier also routes to lic:", () => { + const k = getCreditKey({ + installId: "any-install", + license: { + tier: "max", + licenseUuid: "11111111-2222-3333-4444-555555555555", + }, + }); + assert.match(k, /^lic:[0-9a-f]{16}$/); +}); + +// Paid tier with no resolvable fingerprint (license object missing +// licenseUuid) should defensively fall back to install-keyed rather +// than throwing or producing a phantom "lic:" key — keeps the ledger +// behaving correctly when the keysat verifier returns degraded data. +test("getCreditKey: paid tier without licenseUuid falls back to inst:", () => { + const k = getCreditKey({ + installId: "ins-77", + license: { tier: "pro" }, // no licenseUuid + }); + assert.equal(k, "inst:ins-77"); +}); + +test("getCreditKey: throws when neither installId nor a usable license is present", () => { + assert.throws( + () => getCreditKey({}), + /installId required/i, + "should throw on empty input" + ); + assert.throws( + () => getCreditKey({ license: { tier: "core" } }), + /installId required/i, + "should throw on Core license with no installId" + ); +}); diff --git a/server/test/tier-expiry.test.js b/server/test/tier-expiry.test.js new file mode 100644 index 0000000..cf08b31 --- /dev/null +++ b/server/test/tier-expiry.test.js @@ -0,0 +1,79 @@ +// Phase 1 of self-serve subscriptions: prepaid-period expiry enforcement + +// the extend-from-current-expiry logic both payment rails will call. + +import { test, describe, before } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { identityTier, isSubscriptionExpired } from "../identity.js"; +import { initCredits, extendUserTier, getUserCreditRow } from "../credits.js"; + +const DAY = 24 * 60 * 60 * 1000; + +describe("subscription expiry enforcement (pure)", () => { + test("isSubscriptionExpired: null / future / past / invalid", () => { + assert.equal(isSubscriptionExpired({}), false); + assert.equal(isSubscriptionExpired({ subscription_expires_at: null }), false); + assert.equal( + isSubscriptionExpired({ subscription_expires_at: new Date(Date.now() + DAY).toISOString() }), + false, + ); + assert.equal( + isSubscriptionExpired({ subscription_expires_at: new Date(Date.now() - DAY).toISOString() }), + true, + ); + assert.equal(isSubscriptionExpired({ subscription_expires_at: "not-a-date" }), false); + }); + + test("identityTier: cloud tier honored until expiry, then Core", () => { + const cloud = { kind: "cloud" }; + // No expiry = operator comp grant → never expires. + assert.equal(identityTier(cloud, { tier_snapshot: "max" }), "max"); + // Future expiry → tier honored. + assert.equal( + identityTier(cloud, { tier_snapshot: "max", subscription_expires_at: new Date(Date.now() + DAY).toISOString() }), + "max", + ); + // Past expiry → effectively Core. + assert.equal( + identityTier(cloud, { tier_snapshot: "max", subscription_expires_at: new Date(Date.now() - DAY).toISOString() }), + "core", + ); + }); + + test("identityTier: license path is unaffected by expiry", () => { + assert.equal(identityTier({ kind: "license", license: { tier: "pro" } }, null), "pro"); + }); +}); + +describe("extendUserTier (prepaid periods)", () => { + before(async () => { + await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-tier-")) }); + }); + + test("first purchase sets the tier + ~periodDays out", async () => { + const row = await extendUserTier({ userId: "u1", tier: "pro", periodDays: 30 }); + assert.equal(row.tier_snapshot, "pro"); + const days = (new Date(row.subscription_expires_at).getTime() - Date.now()) / DAY; + assert.ok(days > 29.9 && days < 30.1, `~30 days, got ${days}`); + }); + + test("early renewal EXTENDS from the current expiry (adds time)", async () => { + const first = await extendUserTier({ userId: "u2", tier: "max", periodDays: 30 }); + const firstExp = new Date(first.subscription_expires_at).getTime(); + const second = await extendUserTier({ userId: "u2", tier: "max", periodDays: 30 }); + const addedDays = (new Date(second.subscription_expires_at).getTime() - firstExp) / DAY; + assert.ok(addedDays > 29.9 && addedDays < 30.1, `added ~30 days, got ${addedDays}`); + }); + + test("renewing AFTER expiry starts fresh from now", async () => { + const row = await getUserCreditRow("u3"); + row.tier_snapshot = "pro"; + row.subscription_expires_at = new Date(Date.now() - 5 * DAY).toISOString(); // expired 5d ago + const renewed = await extendUserTier({ userId: "u3", tier: "pro", periodDays: 30 }); + const days = (new Date(renewed.subscription_expires_at).getTime() - Date.now()) / DAY; + assert.ok(days > 29.9 && days < 30.1, `fresh ~30 days from now, got ${days}`); + }); +}); diff --git a/server/test/zaprite.test.js b/server/test/zaprite.test.js new file mode 100644 index 0000000..5130cc6 --- /dev/null +++ b/server/test/zaprite.test.js @@ -0,0 +1,52 @@ +// Zaprite (card rail) pure-logic tests: the paid-status gate that decides +// whether a webhook should grant a tier, and the order-id extraction that +// tolerates Zaprite's loosely-documented webhook payload shapes. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +import { isOrderPaid, orderIdFromWebhook } from "../zaprite-client.js"; + +describe("zaprite isOrderPaid", () => { + test("paid statuses grant; everything else does not", () => { + for (const status of ["PAID", "COMPLETE", "OVERPAID"]) { + assert.equal(isOrderPaid({ status }), true, `${status} should be paid`); + } + for (const status of ["PENDING", "PROCESSING", "UNDERPAID", "", "WAT"]) { + assert.equal(isOrderPaid({ status }), false, `${status} should NOT be paid`); + } + }); + + test("case-insensitive + null-safe", () => { + assert.equal(isOrderPaid({ status: "paid" }), true); + assert.equal(isOrderPaid({ status: "complete" }), true); + assert.equal(isOrderPaid(null), false); + assert.equal(isOrderPaid({}), false); + assert.equal(isOrderPaid(undefined), false); + }); +}); + +describe("zaprite orderIdFromWebhook", () => { + test("extracts id from the common payload shapes", () => { + assert.equal(orderIdFromWebhook({ id: "o1" }), "o1"); + assert.equal(orderIdFromWebhook({ orderId: "o2" }), "o2"); + assert.equal(orderIdFromWebhook({ order: { id: "o3" } }), "o3"); + assert.equal(orderIdFromWebhook({ data: { id: "o4" } }), "o4"); + assert.equal(orderIdFromWebhook({ data: { orderId: "o5" } }), "o5"); + assert.equal(orderIdFromWebhook({ data: { order: { id: "o6" } } }), "o6"); + }); + + test("prefers explicit orderId over a nested id", () => { + assert.equal( + orderIdFromWebhook({ orderId: "explicit", order: { id: "nested" } }), + "explicit", + ); + }); + + test("returns null when no id is present", () => { + assert.equal(orderIdFromWebhook({}), null); + assert.equal(orderIdFromWebhook(null), null); + assert.equal(orderIdFromWebhook("nope"), null); + assert.equal(orderIdFromWebhook({ foo: "bar" }), null); + }); +}); diff --git a/server/zaprite-client.js b/server/zaprite-client.js new file mode 100644 index 0000000..f4ba997 --- /dev/null +++ b/server/zaprite-client.js @@ -0,0 +1,160 @@ +// Thin wrapper around Zaprite's hosted-checkout API for the card rail of +// the self-serve subscription purchase. Zaprite is the "Pay by card" +// counterpart to BTCPay (the Bitcoin rail) — both end at extendUserTier. +// +// What this module owns: +// 1. createOrder() — POST /v1/orders to mint a hosted checkout priced +// in the operator's fiat currency. Embeds {product, user_id, tier, +// period_days} in order metadata so the webhook can grant the tier. +// 2. getOrder() — GET /v1/orders/{id} to re-fetch an order and +// confirm its paid status server-side. This is how we VERIFY a +// webhook: rather than trust the webhook body (Zaprite's signing +// mechanism isn't publicly documented), we re-fetch the order from +// the authenticated API and check its status — the same +// re-fetch-to-verify pattern the BTCPay handler uses. +// 3. isOrderPaid() — true once an order's status means the buyer's +// money has landed. +// +// Operator config (StartOS "Set Zaprite Connection" action): +// relay_zaprite_base_url default https://api.zaprite.com +// relay_zaprite_api_key Zaprite API key (Settings > API in Zaprite) +// relay_zaprite_currency fiat the card is charged in (default USD) +// +// API shape (api.zaprite.com): +// POST /v1/orders Bearer auth → { id, checkoutUrl, status, metadata } +// body: { amount, currency, label?, redirectUrl?, metadata?, externalUniqId? } +// amount is an integer in the currency's smallest unit (cents for USD). +// GET /v1/orders/{id} Bearer auth → the order object +// status ∈ PENDING | PROCESSING | PAID | OVERPAID | UNDERPAID | COMPLETE + +export class ZapriteError extends Error { + constructor(status, message, body) { + super(message); + this.name = "ZapriteError"; + this.status = status; + this.body = body; + } +} + +// Statuses that mean the buyer has paid in full (or more). PAID = paid +// but awaiting manual fulfillment; COMPLETE = paid and fulfilled; +// OVERPAID = paid more than due. All three are "grant the tier". +// UNDERPAID / PENDING / PROCESSING are NOT sufficient. +const PAID_STATUSES = new Set(["PAID", "COMPLETE", "OVERPAID"]); + +export function isOrderPaid(order) { + const status = String(order?.status || "").toUpperCase(); + return PAID_STATUSES.has(status); +} + +// Create a Zaprite hosted-checkout order for a prepaid Pro/Max period. +// `amount` is in the smallest unit of `currency` (cents for USD). +// `metadata` values are coerced to strings (Zaprite requires string +// values). Returns the raw order object — caller reads id + checkoutUrl. +export async function createOrder({ + baseURL, + apiKey, + amount, + currency, + label = "", + metadata = {}, + redirectUrl = null, + externalUniqId = null, +}) { + assertConfigured({ baseURL, apiKey }); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error("Zaprite createOrder: amount must be a positive integer"); + } + // Zaprite metadata values must be strings (<=1000 chars). Coerce. + const safeMeta = {}; + for (const [k, v] of Object.entries(metadata || {})) { + if (v == null) continue; + safeMeta[k] = String(v).slice(0, 1000); + } + const body = { + amount: Math.round(amount), + currency: String(currency || "USD").toUpperCase(), + metadata: safeMeta, + }; + if (label) body.label = label; + if (redirectUrl) body.redirectUrl = redirectUrl; + if (externalUniqId) body.externalUniqId = externalUniqId; + + const url = `${trimSlash(baseURL)}/v1/orders`; + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch {} + if (!res.ok) { + throw new ZapriteError( + res.status, + `Zaprite createOrder ${res.status}: ${text?.slice(0, 300) || res.statusText}`, + parsed, + ); + } + return parsed; +} + +// Re-fetch a single order by its Zaprite id (or externalUniqId). Used by +// the webhook to confirm a paid status before granting the tier. +export async function getOrder({ baseURL, apiKey, orderId }) { + assertConfigured({ baseURL, apiKey }); + if (!orderId) throw new Error("Zaprite getOrder: orderId required"); + const url = `${trimSlash(baseURL)}/v1/orders/${encodeURIComponent(orderId)}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10_000), + }); + const text = await res.text(); + let parsed = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch {} + if (!res.ok) { + throw new ZapriteError( + res.status, + `Zaprite getOrder ${res.status}: ${text?.slice(0, 300) || res.statusText}`, + parsed, + ); + } + return parsed; +} + +// Pull the order id out of a webhook payload. Zaprite's webhook body +// shape isn't strongly documented, so check the common locations rather +// than assume one — we only need the id (the authoritative status comes +// from re-fetching the order). +export function orderIdFromWebhook(payload) { + if (!payload || typeof payload !== "object") return null; + return ( + payload.orderId || + payload.id || + payload.order?.id || + payload.data?.id || + payload.data?.orderId || + payload.data?.order?.id || + null + ); +} + +function assertConfigured({ baseURL, apiKey }) { + if (!baseURL || !apiKey) { + throw new Error( + "Zaprite is not configured — set the API key via the StartOS 'Set Zaprite Connection' action", + ); + } +} + +function trimSlash(s) { + return (s || "").replace(/\/$/, ""); +} diff --git a/startos/actions/setBtcpayConnection.ts b/startos/actions/setBtcpayConnection.ts new file mode 100644 index 0000000..32da6b2 --- /dev/null +++ b/startos/actions/setBtcpayConnection.ts @@ -0,0 +1,108 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +// Connects the relay to the operator's BTCPay store so users can top +// up their credit balance via Lightning / on-chain payments. All +// four fields must be set together — leaving any blank disables the +// credit-purchase flow (the rest of the relay keeps working). +// +// To generate the API key in BTCPay: +// 1. Account → Manage Account → API Keys +// 2. "Generate Key" with scopes: +// - btcpay.store.cancreateinvoice +// - btcpay.store.canviewinvoices +// 3. Restrict to your Recap store +// +// To set up the webhook in BTCPay: +// 1. Open your "Recap" store → Settings → Webhooks +// 2. URL: https:///relay/btcpay/webhook +// 3. Subscribe to: "An invoice has been settled" +// 4. Set "Automatic redelivery" on +// 5. Copy the auto-generated secret into the field below +const inputSpec = InputSpec.of({ + relay_btcpay_base_url: Value.text({ + name: 'BTCPay Base URL', + description: + 'Public URL of your BTCPay server. The relay POSTs invoice-create requests here, and BTCPay POSTs webhooks back to /relay/btcpay/webhook on your relay host. Example: https://btcpay.keysat.xyz', + required: false, + default: null, + minLength: 0, + maxLength: 256, + patterns: [ + { + regex: '^(https?://.+)?$', + description: 'Must be empty or start with http:// or https://', + }, + ], + }), + relay_btcpay_store_id: Value.text({ + name: 'BTCPay Store ID', + description: + 'UUID of the BTCPay store invoices should be created against. Find it in BTCPay → Store Settings → General → Store ID.', + required: false, + default: null, + minLength: 0, + maxLength: 64, + }), + relay_btcpay_api_key: Value.text({ + name: 'BTCPay API Key', + description: + 'Greenfield API token with the canCreateInvoice + canViewInvoices scopes restricted to your Recap store. Generated under Account → Manage Account → API Keys.', + required: false, + default: null, + masked: true, + minLength: 0, + maxLength: 256, + }), + relay_btcpay_webhook_secret: Value.text({ + name: 'BTCPay Webhook Secret', + description: + 'Shared secret BTCPay uses to HMAC-sign webhook deliveries to /relay/btcpay/webhook. Get this from BTCPay → Store Settings → Webhooks after creating the webhook entry.', + required: false, + default: null, + masked: true, + minLength: 0, + maxLength: 256, + }), +}) + +export const setBtcpayConnection = sdk.Action.withInput( + 'set-btcpay-connection', + + async ({ effects }) => ({ + name: 'Set BTCPay Connection (credit purchases)', + description: + 'Wire the relay to your BTCPay store so users can buy credit top-ups via Lightning. Leave any field blank to disable the credit-purchase flow — the rest of the relay keeps working without it.', + warning: null, + allowedStatuses: 'any', + group: 'Tiers', + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + return { + relay_btcpay_base_url: config?.relay_btcpay_base_url || undefined, + relay_btcpay_store_id: config?.relay_btcpay_store_id || undefined, + relay_btcpay_api_key: config?.relay_btcpay_api_key || undefined, + relay_btcpay_webhook_secret: + config?.relay_btcpay_webhook_secret || undefined, + } + }, + + async ({ effects, input }) => { + await configFile.merge(effects, { + relay_btcpay_base_url: (input.relay_btcpay_base_url || '').trim(), + relay_btcpay_store_id: (input.relay_btcpay_store_id || '').trim(), + relay_btcpay_api_key: (input.relay_btcpay_api_key || '').trim(), + relay_btcpay_webhook_secret: ( + input.relay_btcpay_webhook_secret || '' + ).trim(), + }) + return null + }, +) diff --git a/startos/actions/setCreditPackages.ts b/startos/actions/setCreditPackages.ts new file mode 100644 index 0000000..fa7ae3c --- /dev/null +++ b/startos/actions/setCreditPackages.ts @@ -0,0 +1,110 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +// Operator-editable bundle pricing for credit purchases. The bundle +// SIZES are fixed (5, 10, 20 credits — three bundles fit cleanly in +// the buyer modal's single row). Only their sats prices move via +// this action. If you need different bundle sizes, edit +// relay_credit_packages_json directly on the relay's data volume. +const inputSpec = InputSpec.of({ + pkg_5_sats: Value.number({ + name: '5 credit bundle — price (sats)', + description: 'Price the buyer pays for a 5-credit top-up.', + required: true, + default: 4000, + min: 1, + max: 100_000_000, + integer: true, + step: 1, + units: 'sats', + placeholder: null, + }), + pkg_10_sats: Value.number({ + name: '10 credit bundle — price (sats)', + description: 'Price the buyer pays for a 10-credit top-up.', + required: true, + default: 6000, + min: 1, + max: 100_000_000, + integer: true, + step: 1, + units: 'sats', + placeholder: null, + }), + pkg_20_sats: Value.number({ + name: '20 credit bundle — price (sats)', + description: 'Price the buyer pays for a 20-credit top-up.', + required: true, + default: 10000, + min: 1, + max: 100_000_000, + integer: true, + step: 1, + units: 'sats', + placeholder: null, + }), +}) + +const FIXED_CREDIT_SIZES = [5, 10, 20] as const + +export const setCreditPackages = sdk.Action.withInput( + 'set-credit-packages', + + async ({ effects }) => ({ + name: 'Set Credit Bundle Prices', + description: + 'Per-bundle sats prices shown to buyers in the Recap credit-purchase modal. Bundle sizes (5, 10, 20 credits) are fixed by this action; edit relay_credit_packages_json directly if you need different sizes. Changes apply to the next buyer immediately — no daemon restart.', + warning: null, + allowedStatuses: 'any', + group: 'Tiers', + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + // Translate the stored JSON array back into per-bundle inputs. + // We only care about the four fixed sizes; anything else in the + // stored array is preserved on save (see merge step below) but + // not exposed in the form. + let parsed: Array<{ credits: number; sats: number }> = [] + try { + const raw = JSON.parse(config?.relay_credit_packages_json || '[]') + if (Array.isArray(raw)) { + parsed = raw + .map((p: any) => ({ + credits: Number(p?.credits), + sats: Number(p?.sats), + })) + .filter((p) => Number.isFinite(p.credits) && Number.isFinite(p.sats)) + } + } catch { + // ignored — fall back to defaults + } + const lookup = (n: number, fallback: number) => + parsed.find((p) => p.credits === n)?.sats ?? fallback + return { + pkg_5_sats: lookup(5, 4000), + pkg_10_sats: lookup(10, 6000), + pkg_20_sats: lookup(20, 10000), + } + }, + + async ({ effects, input }) => { + const packages = [ + { credits: 5, sats: Number(input.pkg_5_sats) }, + { credits: 10, sats: Number(input.pkg_10_sats) }, + { credits: 20, sats: Number(input.pkg_20_sats) }, + ].filter( + (p) => + Number.isFinite(p.sats) && p.sats > 0 && FIXED_CREDIT_SIZES.includes(p.credits as 5 | 10 | 20) + ) + await configFile.merge(effects, { + relay_credit_packages_json: JSON.stringify(packages), + }) + return null + }, +) diff --git a/startos/actions/setTierPrices.ts b/startos/actions/setTierPrices.ts new file mode 100644 index 0000000..80694aa --- /dev/null +++ b/startos/actions/setTierPrices.ts @@ -0,0 +1,93 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +// Operator-set per-tier monthly subscription prices, in USD. Used by +// the dashboard to compute revenue and operating margin (Gemini cost +// already comes out of the audit log). Pure accounting — the relay +// itself does no billing. +const inputSpec = InputSpec.of({ + core_price: Value.number({ + name: 'Core (Free) — Monthly Price', + description: + 'Monthly subscription price for the Core tier in USD. Typically $0 since Core is the free entry tier. Used by the dashboard to compute total revenue; leave at 0 unless you actually charge for Core.', + required: true, + default: 0, + min: 0, + max: 10_000, + integer: false, + step: 0.01, + units: 'USD', + placeholder: null, + }), + pro_price: Value.number({ + name: 'Pro — Monthly Price', + description: + 'Monthly subscription price for the Pro tier in USD. Should match what you actually charge Pro customers on the licensing side.', + required: true, + default: 5, + min: 0, + max: 10_000, + integer: false, + step: 0.01, + units: 'USD', + placeholder: null, + }), + max_price: Value.number({ + name: 'Max — Monthly Price', + description: + 'Monthly subscription price for the Max tier in USD. Should match what you actually charge Max customers on the licensing side.', + required: true, + default: 15, + min: 0, + max: 10_000, + integer: false, + step: 0.01, + units: 'USD', + placeholder: null, + }), +}) + +export const setTierPrices = sdk.Action.withInput( + 'set-tier-prices', + + async ({ effects }) => ({ + name: 'Set Tier Prices (USD)', + description: + 'Configure the monthly USD price you charge per tier. The dashboard uses these numbers to compute revenue and operating margin against Gemini API cost. Has no effect on actual billing — it is for the operator’s accounting view only.', + warning: null, + allowedStatuses: 'any', + group: 'Tiers', + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + let parsed: any = {} + try { + parsed = JSON.parse(config?.relay_tier_prices_usd_json || '{}') + } catch { + parsed = {} + } + return { + core_price: typeof parsed?.core === 'number' ? parsed.core : 0, + pro_price: typeof parsed?.pro === 'number' ? parsed.pro : 5, + max_price: typeof parsed?.max === 'number' ? parsed.max : 15, + } + }, + + async ({ effects, input }) => { + const prices = { + core: Number(input.core_price ?? 0), + pro: Number(input.pro_price ?? 5), + max: Number(input.max_price ?? 15), + } + await configFile.merge(effects, { + relay_tier_prices_usd_json: JSON.stringify(prices), + }) + return null + }, +) diff --git a/startos/actions/setZapriteConnection.ts b/startos/actions/setZapriteConnection.ts new file mode 100644 index 0000000..3b3dca2 --- /dev/null +++ b/startos/actions/setZapriteConnection.ts @@ -0,0 +1,141 @@ +import { sdk } from '../sdk' +import { configFile } from '../file-models/config.json' + +const { InputSpec, Value } = sdk + +// Connects the relay to the operator's Zaprite account so users can buy a +// prepaid Pro/Max period with a CARD (the "Pay by card" rail alongside the +// Bitcoin/BTCPay rail). Leave the API key blank to disable the card rail — +// the app hides "Pay by card" and the Bitcoin rail keeps working. +// +// To get your Zaprite API key: +// 1. Zaprite → Settings → API → create a key +// 2. Paste it below (stored masked) +// +// To set up the webhook in Zaprite: +// 1. Zaprite → Settings → Webhooks → add endpoint +// 2. URL: https:///relay/zaprite/webhook +// 3. Subscribe to order paid / completed events +// (No webhook secret is needed — the relay re-fetches each order from +// Zaprite's authenticated API to confirm payment before granting.) +// +// Card prices are charged in the currency below (cents for USD). They're +// separate from the dashboard's "Set Tier Prices (USD)" accounting figure; +// these are the real amounts a card buyer pays. Default ≈ parity with the +// sat prices ($21 / $42) — raise them to add a premium for card fees. +const inputSpec = InputSpec.of({ + relay_zaprite_api_key: Value.text({ + name: 'Zaprite API Key', + description: + 'API key from Zaprite → Settings → API. Used to create hosted card checkouts and to re-fetch orders for webhook verification. Leave blank to disable the card rail.', + required: false, + default: null, + masked: true, + minLength: 0, + maxLength: 256, + }), + relay_zaprite_base_url: Value.text({ + name: 'Zaprite Base URL', + description: + 'Zaprite API base URL. Leave as the default unless Zaprite tells you otherwise.', + required: false, + default: 'https://api.zaprite.com', + minLength: 0, + maxLength: 256, + patterns: [ + { + regex: '^(https?://.+)?$', + description: 'Must be empty or start with http:// or https://', + }, + ], + }), + relay_zaprite_currency: Value.text({ + name: 'Card Currency', + description: + 'Fiat currency the card is charged in (ISO code, e.g. USD, EUR). The card prices below are in this currency.', + required: false, + default: 'USD', + minLength: 0, + maxLength: 8, + }), + pro_card_price: Value.number({ + name: 'Pro — Card Price', + description: + 'Amount a card buyer pays for one prepaid Pro period, in the card currency. Default ≈ parity with the 21,000-sat Bitcoin price.', + required: true, + default: 21, + min: 0, + max: 100_000, + integer: false, + step: 0.01, + placeholder: null, + }), + max_card_price: Value.number({ + name: 'Max — Card Price', + description: + 'Amount a card buyer pays for one prepaid Max period, in the card currency. Default ≈ parity with the 42,000-sat Bitcoin price.', + required: true, + default: 42, + min: 0, + max: 100_000, + integer: false, + step: 0.01, + placeholder: null, + }), +}) + +export const setZapriteConnection = sdk.Action.withInput( + 'set-zaprite-connection', + + async ({ effects }) => ({ + name: 'Set Zaprite Connection (card purchases)', + description: + 'Wire the relay to your Zaprite account so users can buy Pro/Max with a card. Leave the API key blank to disable the card rail — the Bitcoin rail keeps working without it. Remember to add the webhook in Zaprite pointing at https:///relay/zaprite/webhook', + warning: null, + allowedStatuses: 'any', + group: 'Tiers', + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const config = await configFile.read().once() + let cents: any = {} + try { + cents = JSON.parse(config?.relay_tier_prices_fiat_cents_json || '{}') + } catch { + cents = {} + } + const toMajor = (c: any, fallback: number) => + typeof c === 'number' && Number.isFinite(c) ? c / 100 : fallback + return { + relay_zaprite_api_key: config?.relay_zaprite_api_key || undefined, + relay_zaprite_base_url: + config?.relay_zaprite_base_url || 'https://api.zaprite.com', + relay_zaprite_currency: config?.relay_zaprite_currency || 'USD', + pro_card_price: toMajor(cents?.pro, 21), + max_card_price: toMajor(cents?.max, 42), + } + }, + + async ({ effects, input }) => { + // Card prices are entered in major units (dollars) but Zaprite + the + // relay charge in the currency's smallest unit (cents), so ×100. + const fiatCents = { + pro: Math.round(Number(input.pro_card_price ?? 21) * 100), + max: Math.round(Number(input.max_card_price ?? 42) * 100), + } + await configFile.merge(effects, { + relay_zaprite_api_key: (input.relay_zaprite_api_key || '').trim(), + relay_zaprite_base_url: ( + input.relay_zaprite_base_url || 'https://api.zaprite.com' + ).trim(), + relay_zaprite_currency: (input.relay_zaprite_currency || 'USD') + .trim() + .toUpperCase(), + relay_tier_prices_fiat_cents_json: JSON.stringify(fiatCents), + }) + return null + }, +)