Add self-serve billing: tiers, credits, BTCPay and Zaprite
This commit is contained in:
@@ -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=<hex>`
|
||||
// where <hex> 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(/\/$/, "");
|
||||
}
|
||||
+487
-59
@@ -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:<fingerprint>` when license.tier is "pro" or "max"
|
||||
// - `inst:<installId>` 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:<installId>`, 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:<fp>`
|
||||
// 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:<fp>` 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:<installId>`.
|
||||
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:<fp>` rows that's whichever install most recently
|
||||
// committed against the license; for `inst:<installId>` 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:<installId>` and no such row exists, check for
|
||||
// a legacy row stored under bare `<installId>` (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:<fp>` 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:<fingerprint>` for paid tiers and
|
||||
// `inst:<installId>` otherwise — so a single Pro license activated on
|
||||
// two installs shares one row.
|
||||
//
|
||||
// When the credit-key is `inst:<installId>` 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:<fp>` 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:<fp>` 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:<id>`). 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,
|
||||
}));
|
||||
}
|
||||
|
||||
+153
-25
@@ -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:<fp>` for paid tiers, `inst:<installId>`
|
||||
// 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<install_id|job_id, { backend, tier, charged_at, refunded }>
|
||||
// Map<creditKey|job_id, { backend, tier, install_id, license_fingerprint, charged_at, refunded }>
|
||||
// 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:<fp>` 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() {
|
||||
|
||||
+11
-6
@@ -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 —
|
||||
|
||||
+43
-26
@@ -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:<id> 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,
|
||||
});
|
||||
|
||||
@@ -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=<our callback> (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", `
|
||||
<p>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.</p>
|
||||
<p><a href="/dashboard.html">Back to dashboard</a></p>
|
||||
`));
|
||||
}
|
||||
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", `
|
||||
<p>No BTCPay URL is on file — re-start the setup from the
|
||||
dashboard.</p>
|
||||
<p><a href="/dashboard.html">Back to dashboard</a></p>
|
||||
`));
|
||||
}
|
||||
if (
|
||||
!providedState ||
|
||||
providedState.length !== expectedState.length ||
|
||||
!crypto.timingSafeEqual(
|
||||
Buffer.from(providedState),
|
||||
Buffer.from(expectedState)
|
||||
)
|
||||
) {
|
||||
return res.status(403).send(html("Bad state token", `
|
||||
<p>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.</p>
|
||||
<p><a href="/dashboard.html">Back to dashboard</a></p>
|
||||
`));
|
||||
}
|
||||
// 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", `
|
||||
<h1 style="color:#86efac;">✓ BTCPay connected successfully.</h1>
|
||||
<p>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.</p>
|
||||
`));
|
||||
}
|
||||
// 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", `
|
||||
<h1>Pick your Recap store</h1>
|
||||
<p>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.</p>
|
||||
<div id="status">Loading stores…</div>
|
||||
<div id="stores"></div>
|
||||
<script>
|
||||
(async () => {
|
||||
const r = await fetch("/admin/btcpay/stores");
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !Array.isArray(data.stores)) {
|
||||
document.getElementById("status").innerText =
|
||||
"Couldn't list stores: " + (data.message || data.error || r.status);
|
||||
return;
|
||||
}
|
||||
const list = document.getElementById("stores");
|
||||
data.stores.forEach((s) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = s.name + " (" + s.id.slice(0, 8) + "…)";
|
||||
btn.style.cssText =
|
||||
"display:block;margin:8px 0;padding:10px 14px;background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:8px;cursor:pointer;font-size:13px;width:100%;text-align:left;";
|
||||
btn.onclick = async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Setting up webhook…";
|
||||
const r2 = await fetch("/admin/btcpay/finalize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ store_id: s.id }),
|
||||
});
|
||||
const data2 = await r2.json().catch(() => ({}));
|
||||
if (!r2.ok) {
|
||||
document.getElementById("status").innerHTML =
|
||||
'<span style="color:#fca5a5;">Setup failed: ' +
|
||||
(data2.message || data2.error || r2.status) + '</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = s.name + " (" + s.id.slice(0, 8) + "…)";
|
||||
return;
|
||||
}
|
||||
document.getElementById("status").innerHTML =
|
||||
'<span style="color:#86efac;">✓ Connected to ' +
|
||||
s.name + ' and webhook auto-created. You can close this tab.</span>';
|
||||
document.getElementById("stores").style.display = "none";
|
||||
};
|
||||
list.appendChild(btn);
|
||||
});
|
||||
document.getElementById("status").innerText = "Pick a store:";
|
||||
})();
|
||||
</script>
|
||||
`));
|
||||
}
|
||||
);
|
||||
|
||||
// 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 `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { background:#0f172a; color:#e2e8f0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; margin:0; padding:40px; }
|
||||
.wrap { max-width:560px; margin:0 auto; }
|
||||
h1 { font-size:22px; margin:0 0 16px; }
|
||||
a { color:#a5b4fc; }
|
||||
button { font-family:inherit; }
|
||||
</style></head><body><div class="wrap">${body}</div></body></html>`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:<installId>", () => {
|
||||
const k = getCreditKey({
|
||||
installId: "abc-123",
|
||||
license: { tier: "core" },
|
||||
});
|
||||
assert.equal(k, "inst:abc-123");
|
||||
});
|
||||
|
||||
test("getCreditKey: anonymous (no license) uses inst:<installId>", () => {
|
||||
assert.equal(getCreditKey({ installId: "xyz" }), "inst:xyz");
|
||||
assert.equal(getCreditKey({ installId: "xyz", license: null }), "inst:xyz");
|
||||
});
|
||||
|
||||
test("getCreditKey: Pro license routes to lic:<fingerprint>", () => {
|
||||
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:<fingerprint>", () => {
|
||||
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"
|
||||
);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(/\/$/, "");
|
||||
}
|
||||
@@ -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://<your-relay-host>/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
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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://<your-relay-host>/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://<your-relay-host>/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
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user