Add self-serve billing: tiers, credits, BTCPay and Zaprite
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user