395 lines
16 KiB
JavaScript
395 lines
16 KiB
JavaScript
// 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;
|
|
}
|