Add self-serve billing: tiers, credits, BTCPay and Zaprite

This commit is contained in:
Keysat
2026-06-13 13:36:05 -05:00
parent 84d56c94c9
commit 0aa648706e
17 changed files with 3781 additions and 116 deletions
+394
View File
@@ -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;
}