Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
This commit is contained in:
@@ -0,0 +1,671 @@
|
||||
// Recap-side proxy to the relay's credit-purchase endpoints.
|
||||
//
|
||||
// Architecture is identical to license-purchase.js: Recap doesn't
|
||||
// hold the BTCPay credentials, the relay does. Recap just forwards
|
||||
// the buyer's pick to the relay and proxies the polling. The relay
|
||||
// returns the BTCPay checkout URL which the Recap UI displays in a
|
||||
// modal styled to match the license-purchase modal.
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /api/credits/packages → relay GET /relay/credits/packages
|
||||
// POST /api/credits/buy → relay POST /relay/credits/buy
|
||||
// GET /api/credits/invoice/:id → relay GET /relay/credits/invoice/:id
|
||||
//
|
||||
// Auth headers (X-Recap-Install-Id + Authorization Bearer LIC1-...)
|
||||
// are added by this proxy, not by the buyer-side JS — keeping the
|
||||
// install identity + license key out of any client-side code.
|
||||
|
||||
import { getRelayBaseURL } from "./relay-default.js";
|
||||
import { getInstallId } from "./install-id.js";
|
||||
import { getRawLicenseKey } from "./license.js";
|
||||
|
||||
// Multi-mode toggle. In multi mode every credit purchase is recorded
|
||||
// in pending_purchases so we know WHO (signed-in user vs. anon trial
|
||||
// cookie) to credit locally when the invoice settles. Single mode is
|
||||
// the legacy "operator-pool only" flow — no local accounting layer,
|
||||
// the relay's credits.json IS the source of truth.
|
||||
const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single";
|
||||
|
||||
function relayHeaders({ json = false, req = null } = {}) {
|
||||
const h = {};
|
||||
// Identity routing for the credit-purchase + credit-poll flow:
|
||||
//
|
||||
// Pro/Max signed-in tenant (req.user.keysat_license set)
|
||||
// → use THEIR install ID + license. The buy invoice gets
|
||||
// stashed with THEIR license_fingerprint so the BTCPay
|
||||
// settle-webhook credits THEIR license-keyed pool — the
|
||||
// same pool /api/relay/status reads when displaying their
|
||||
// balance. Without this, credits land on the operator's
|
||||
// pool and the buyer sees their balance unchanged after
|
||||
// paying (the bug Grant hit on 2026-05-18).
|
||||
//
|
||||
// Anon visitor (trial cookie only) / free signed-in tenant
|
||||
// (no license) / single-mode operator
|
||||
// → fall back to operator identity. The operator's pool is
|
||||
// what's being topped up; Recaps' own accounting layer
|
||||
// (anon_trials / tenant_credits) handles the per-user
|
||||
// attribution locally via pending_purchases.
|
||||
let installId = null;
|
||||
let licenseKey = null;
|
||||
if (req?.user?.keysat_license && req.user.synthetic_install_id) {
|
||||
installId = req.user.synthetic_install_id;
|
||||
licenseKey = req.user.keysat_license;
|
||||
}
|
||||
if (!installId) {
|
||||
try {
|
||||
const id = getInstallId();
|
||||
if (id) installId = id;
|
||||
} catch {}
|
||||
}
|
||||
if (!licenseKey) {
|
||||
try {
|
||||
const key = getRawLicenseKey();
|
||||
if (key) licenseKey = key;
|
||||
} catch {}
|
||||
}
|
||||
if (installId) h["X-Recap-Install-Id"] = installId;
|
||||
if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`;
|
||||
if (json) h["Content-Type"] = "application/json";
|
||||
return h;
|
||||
}
|
||||
|
||||
export function setupCreditsPurchaseRoutes(app) {
|
||||
// List bundles the operator has configured. Cheap, no auth gating —
|
||||
// the buyer needs the price menu before they decide whether to pay.
|
||||
app.get("/api/credits/packages", async (_req, res) => {
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) {
|
||||
return res.status(503).json({
|
||||
error: "relay_not_configured",
|
||||
message: "Relay base URL not set on this Recaps install.",
|
||||
});
|
||||
}
|
||||
try {
|
||||
// 10s timeout — was 5s, but a cold relay request from mobile
|
||||
// cellular can take 6-8s, and Safari iOS surfaces the abort as
|
||||
// a generic "Load failed" with no other info, so the buyer
|
||||
// sees an error and has to manually retry. 10s is still snappy
|
||||
// enough that a legit failure doesn't hang the UI for long.
|
||||
const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/packages`, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
if (!r.ok) {
|
||||
return res.status(r.status).json(body || { error: "relay_packages_failed" });
|
||||
}
|
||||
res.json(body || { packages: [] });
|
||||
} catch (err) {
|
||||
console.error(`[credits/packages] failed: ${err?.message || err}`);
|
||||
res.status(502).json({
|
||||
error: "packages_fetch_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initiate a purchase. Body: { credits: 1|5|10|20 }. Returns the
|
||||
// raw relay envelope (so the UI sees credits_remaining + tier +
|
||||
// result.checkout_url + result.invoice_id).
|
||||
//
|
||||
// Multi-mode: identifies the buyer (signed-in user or anon trial
|
||||
// cookie), records a pending_purchases row keyed by the invoice_id
|
||||
// the relay returns. The settle-handler (in /api/credits/invoice/:id
|
||||
// below) uses that row to know WHERE to apply the credits locally.
|
||||
//
|
||||
// Single-mode: skips the pending_purchases bookkeeping entirely;
|
||||
// the operator IS the buyer and the relay's credits.json directly
|
||||
// tracks their pool.
|
||||
app.post("/api/credits/buy", async (req, res) => {
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "relay_not_configured" });
|
||||
}
|
||||
const credits = Number(req.body?.credits);
|
||||
const returnUrl =
|
||||
typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http")
|
||||
? req.body.return_url
|
||||
: null;
|
||||
if (!Number.isFinite(credits) || credits <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "credits_required" });
|
||||
}
|
||||
|
||||
// Identify the buyer for multi-mode. Either a signed-in user OR
|
||||
// an anon trial cookie. If neither, attempt to auto-mint a trial
|
||||
// cookie — anon visitors who click "Buy more" from the toolbar
|
||||
// (before they've spent their pre-trial allowance) shouldn't be
|
||||
// dead-ended into a sign-in nag. Same auto-mint pattern as
|
||||
// /api/process for pre-trial visitors. Only refuse if trials are
|
||||
// disabled or the IP is over its lifetime cap.
|
||||
let buyerType = null;
|
||||
let buyerId = null;
|
||||
if (RECAP_MODE === "multi") {
|
||||
if (req.user && req.user.id) {
|
||||
buyerType = "user";
|
||||
buyerId = req.user.id;
|
||||
} else if (req.trial && req.trial.cookie_id) {
|
||||
buyerType = "anon";
|
||||
buyerId = req.trial.cookie_id;
|
||||
} else {
|
||||
// Try to mint a fresh trial cookie so the purchase has
|
||||
// somewhere to land. forceMint=true bypasses the lifetime
|
||||
// IP cap and the trials-disabled config — a paying buyer is
|
||||
// by definition not abusing a free quota, and without a
|
||||
// tracking cookie the settle handler has nowhere to credit
|
||||
// the purchase locally (the relay still credits the operator
|
||||
// pool; we just lose the visibility to apply it to this
|
||||
// specific buyer).
|
||||
try {
|
||||
const { issueIfEligible } = await import("./anon-trial.js");
|
||||
const trial = await issueIfEligible({ req, res, forceMint: true });
|
||||
if (trial) {
|
||||
buyerType = "anon";
|
||||
buyerId = trial.cookie_id;
|
||||
// Stash on req for downstream code paths
|
||||
req.trial = trial;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[credits/buy] anon-trial mint failed:",
|
||||
err?.message || err,
|
||||
);
|
||||
}
|
||||
if (!buyerId) {
|
||||
return res.status(401).json({
|
||||
error: "buyer_unknown",
|
||||
message:
|
||||
"Couldn't create a buyer record for this purchase. Sign up for a free account so we have somewhere to credit it.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/buy`, {
|
||||
method: "POST",
|
||||
headers: relayHeaders({ json: true, req }),
|
||||
body: JSON.stringify({ credits, return_url: returnUrl || undefined }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
if (!r.ok) {
|
||||
return res
|
||||
.status(r.status)
|
||||
.json(body || { error: "relay_buy_failed" });
|
||||
}
|
||||
|
||||
// Record the pending purchase BEFORE we respond, so even if the
|
||||
// browser refreshes / crashes between buy + settle, the next
|
||||
// poll for this invoice id will still know who to credit.
|
||||
// Invoice id lives under result.invoice_id per the relay's
|
||||
// envelope contract (same shape license-purchase uses).
|
||||
const invoiceId =
|
||||
body?.result?.invoice_id ||
|
||||
body?.invoice_id ||
|
||||
body?.btcpay_invoice_id ||
|
||||
null;
|
||||
if (RECAP_MODE === "multi") {
|
||||
if (!invoiceId) {
|
||||
// Loud warning — without an invoice id we can't reconcile
|
||||
// on settle. Surface the response shape so we can see what
|
||||
// the relay actually returned and fix the field-name
|
||||
// assumption if this fires.
|
||||
console.warn(
|
||||
`[credits/buy] NO invoice_id in relay response — skipping pending_purchases. Top-level keys: ${Object.keys(body || {}).join(", ")} | result keys: ${Object.keys(body?.result || {}).join(", ")}`,
|
||||
);
|
||||
} else if (!buyerType || !buyerId) {
|
||||
console.warn(
|
||||
`[credits/buy] invoice ${invoiceId}: buyer identity missing — won't auto-apply on settle.`,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const { getDb } = await import("./db.js");
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO pending_purchases
|
||||
(invoice_id, buyer_type, buyer_id, credits, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(invoiceId, buyerType, buyerId, credits, Date.now());
|
||||
console.log(
|
||||
`[credits/buy] tracked pending purchase invoice=${invoiceId} buyer=${buyerType}:${buyerId} credits=${credits} rowsInserted=${result.changes}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[credits/buy] failed to record pending purchase ${invoiceId}: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json(body || {});
|
||||
} catch (err) {
|
||||
console.error(`[credits/buy] failed: ${err?.message || err}`);
|
||||
res.status(502).json({
|
||||
error: "purchase_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Poll an invoice's status. Returns the relay envelope; the UI
|
||||
// reads `result.status` ("new" | "processing" | "settled" |
|
||||
// "expired" | "invalid") and refreshes when settled.
|
||||
//
|
||||
// Multi-mode side effect: when the relay reports settled, we look
|
||||
// up the matching pending_purchases row and apply the credits to
|
||||
// the right local balance. Idempotent via applied_at — if the same
|
||||
// invoice is polled multiple times after settle, only the first
|
||||
// application takes effect.
|
||||
app.get("/api/credits/invoice/:id", async (req, res) => {
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) {
|
||||
return res.status(503).json({ error: "relay_not_configured" });
|
||||
}
|
||||
const id = (req.params.id || "").trim();
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: "missing_invoice_id" });
|
||||
}
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(id)}`,
|
||||
{
|
||||
headers: relayHeaders({ req }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}
|
||||
);
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
if (!r.ok) {
|
||||
return res
|
||||
.status(r.status)
|
||||
.json(body || { error: "relay_poll_failed" });
|
||||
}
|
||||
|
||||
// Multi-mode: settle-and-apply. Status path mirrors the
|
||||
// license-purchase poll-settle handler.
|
||||
const status =
|
||||
body?.result?.status || body?.status || null;
|
||||
if (RECAP_MODE === "multi" && status === "settled") {
|
||||
try {
|
||||
await applyPendingPurchase(id);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[credits/invoice] apply failed for ${id}: ${err?.message || err}`,
|
||||
);
|
||||
// Don't fail the response — the relay reported settled and
|
||||
// the operator pool has the credits. Local apply can be
|
||||
// retried by hitting this endpoint again, or by a future
|
||||
// reconciliation tool.
|
||||
}
|
||||
}
|
||||
|
||||
res.json(body || {});
|
||||
} catch (err) {
|
||||
console.error(`[credits/invoice] failed: ${err?.message || err}`);
|
||||
res.status(502).json({
|
||||
error: "poll_failed",
|
||||
message: (err?.message || String(err)).slice(0, 300),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/credits/claim { invoice_id }
|
||||
// Manual self-service recovery: a signed-in user pastes the BTCPay
|
||||
// invoice ID of a purchase they made anonymously (e.g., Safari
|
||||
// Private mode where the trial cookie didn't survive the magic-
|
||||
// link click). We verify the invoice is settled at the relay AND
|
||||
// the pending_purchases row is anon-buyer + unapplied, then credit
|
||||
// their account.
|
||||
//
|
||||
// Safety:
|
||||
// - Requires authenticated user (req.user.id must be set)
|
||||
// - Only claims buyer_type='anon' rows (no user-to-user takeover)
|
||||
// - applied_at idempotency guard prevents double-credit
|
||||
// - BTCPay invoice IDs are 30+ char random — not enumerable
|
||||
// - User-buyer rows are never claimable here, regardless of
|
||||
// ownership — those are the cookie sweep's job
|
||||
app.post("/api/credits/claim", async (req, res) => {
|
||||
if (RECAP_MODE !== "multi") {
|
||||
return res.status(404).json({ error: "not_available" });
|
||||
}
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({
|
||||
error: "auth_required",
|
||||
message: "Sign in first to claim a purchase to your account.",
|
||||
});
|
||||
}
|
||||
const invoiceId = String(req.body?.invoice_id || "").trim();
|
||||
if (!invoiceId) {
|
||||
return res.status(400).json({
|
||||
error: "missing_invoice_id",
|
||||
message: "Paste the invoice ID from your purchase email.",
|
||||
});
|
||||
}
|
||||
|
||||
const { getDb } = await import("./db.js");
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
|
||||
FROM pending_purchases WHERE invoice_id = ?`,
|
||||
)
|
||||
.get(invoiceId);
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({
|
||||
error: "invoice_not_found",
|
||||
message:
|
||||
"We don't have a record of that invoice ID. Double-check it — the ID is shown in your BTCPay payment confirmation.",
|
||||
});
|
||||
}
|
||||
if (row.buyer_type !== "anon") {
|
||||
// user-buyer rows are claimable only by their original buyer
|
||||
// (cookie sweep) — refusing this avoids user-to-user takeover.
|
||||
return res.status(403).json({
|
||||
error: "not_anon_purchase",
|
||||
message:
|
||||
"This invoice was bought from a signed-in account and can only be claimed by that account.",
|
||||
});
|
||||
}
|
||||
if (row.applied_at) {
|
||||
return res.status(409).json({
|
||||
error: "already_applied",
|
||||
message:
|
||||
"Those credits were already applied. Check your balance — they may have transferred automatically.",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify settled at the relay before crediting. We do NOT trust
|
||||
// the local row alone — the buyer could have initiated the
|
||||
// invoice and never paid; without this check, anyone could
|
||||
// claim N credits just by knowing an invoice ID.
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) {
|
||||
return res.status(503).json({ error: "relay_not_configured" });
|
||||
}
|
||||
let status = null;
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
|
||||
{ headers: relayHeaders({ req }), signal: AbortSignal.timeout(10_000) },
|
||||
);
|
||||
if (r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
status = body?.result?.status || body?.status || null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[credits/claim] relay status check failed for ${invoiceId}: ${err?.message || err}`,
|
||||
);
|
||||
return res.status(502).json({
|
||||
error: "relay_unreachable",
|
||||
message:
|
||||
"Couldn't verify the invoice with the payment server. Try again in a minute.",
|
||||
});
|
||||
}
|
||||
if (status !== "settled") {
|
||||
return res.status(409).json({
|
||||
error: "not_settled",
|
||||
message: `That invoice is not settled (status: ${status || "unknown"}). If you just paid, wait a minute and try again.`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await applyPendingPurchase(invoiceId, { forceUserId: req.user.id });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[credits/claim] apply failed for ${invoiceId}: ${err?.message || err}`,
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "apply_failed",
|
||||
message: "Something went wrong applying the credits. Try again.",
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`[credits/claim] user ${req.user.id} claimed invoice ${invoiceId} (${row.credits} credits)`,
|
||||
);
|
||||
res.json({ ok: true, credits: row.credits });
|
||||
});
|
||||
}
|
||||
|
||||
// applyPendingPurchase(invoiceId, opts?) — credit the buyer's local
|
||||
// balance for a settled invoice. Idempotent: bails if the row is
|
||||
// already marked applied. If the buyer was an anon trial that has
|
||||
// since been converted to a real user, credits route to the user
|
||||
// instead.
|
||||
//
|
||||
// opts.forceUserId (optional) — route credits to this user instead
|
||||
// of the row's recorded buyer. Used by the manual-claim endpoint:
|
||||
// when a signed-in user pastes a BTCPay invoice ID for an anon
|
||||
// purchase whose trial cookie was lost (e.g., Safari Private mode
|
||||
// where the magic-link click landed in a different cookie jar), we
|
||||
// trust the invoice ID as proof-of-ownership and direct the credits
|
||||
// to their tenant_credits.
|
||||
//
|
||||
// Exported so the sweep helper below — and any future server-side
|
||||
// flow that wants to reconcile a known-settled invoice — can call it
|
||||
// without going through the /api/credits/invoice/:id route.
|
||||
export async function applyPendingPurchase(invoiceId, opts = {}) {
|
||||
const { getDb } = await import("./db.js");
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT invoice_id, buyer_type, buyer_id, credits, applied_at
|
||||
FROM pending_purchases WHERE invoice_id = ?`,
|
||||
)
|
||||
.get(invoiceId);
|
||||
if (!row) {
|
||||
// Either the buy came from a different Recap instance, or the
|
||||
// bookkeeping insert in /api/credits/buy failed earlier. Nothing
|
||||
// to do; the operator pool still has the credits from BTCPay.
|
||||
// Log so operator can reconcile manually if this fires.
|
||||
console.warn(
|
||||
`[credits/invoice] settled invoice ${invoiceId} has NO matching pending_purchases row — local balance NOT auto-applied. The credits ARE in the operator pool at the relay; operator should grant manually to the buyer.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (row.applied_at) {
|
||||
return; // already applied, idempotent no-op
|
||||
}
|
||||
|
||||
// Resolve buyer → target user_id (for tenant_credits) or trial
|
||||
// cookie_id (for anon_trials.credits_total). Anon-buyers who have
|
||||
// since converted to a real user get their credits routed to the
|
||||
// user's tenant_credits — that's the cleaner outcome and matches
|
||||
// the "credits transfer on signup" semantics the design promises.
|
||||
let targetUserId = null;
|
||||
let targetCookieId = null;
|
||||
if (opts.forceUserId) {
|
||||
targetUserId = opts.forceUserId;
|
||||
} else if (row.buyer_type === "user") {
|
||||
targetUserId = row.buyer_id;
|
||||
} else if (row.buyer_type === "anon") {
|
||||
const trial = db
|
||||
.prepare(
|
||||
"SELECT cookie_id, converted_to_user_id FROM anon_trials WHERE cookie_id = ?",
|
||||
)
|
||||
.get(row.buyer_id);
|
||||
if (trial?.converted_to_user_id) {
|
||||
targetUserId = trial.converted_to_user_id;
|
||||
} else {
|
||||
targetCookieId = row.buyer_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply + mark applied in one transaction so a crash mid-way
|
||||
// doesn't leave a half-credited buyer. Purchased credits land in
|
||||
// the PERMANENT bucket (purchased_balance) so they're not wiped on
|
||||
// the next replenishment refresh.
|
||||
const tx = db.transaction(() => {
|
||||
if (targetUserId) {
|
||||
const existing = db
|
||||
.prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?")
|
||||
.get(targetUserId);
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE tenant_credits
|
||||
SET purchased_balance = purchased_balance + ?,
|
||||
lifetime_granted = lifetime_granted + ?
|
||||
WHERE user_id = ?`,
|
||||
).run(row.credits, row.credits, targetUserId);
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO tenant_credits
|
||||
(user_id, purchased_balance, replenish_balance, last_replenish_at,
|
||||
lifetime_granted, lifetime_consumed)
|
||||
VALUES (?, ?, 0, ?, ?, 0)`,
|
||||
).run(targetUserId, row.credits, Date.now(), row.credits);
|
||||
}
|
||||
} else if (targetCookieId) {
|
||||
// Anon trial: credits go into the trial's credits_total (single
|
||||
// bucket — anons don't have the purchased/replenish split).
|
||||
// They'll move to purchased_balance on signup via linkToUser.
|
||||
db.prepare(
|
||||
`UPDATE anon_trials
|
||||
SET credits_total = credits_total + ?
|
||||
WHERE cookie_id = ?`,
|
||||
).run(row.credits, targetCookieId);
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE pending_purchases SET applied_at = ? WHERE invoice_id = ?",
|
||||
).run(Date.now(), invoiceId);
|
||||
});
|
||||
tx();
|
||||
console.log(
|
||||
`[credits/invoice] applied ${row.credits} credits for ${row.buyer_type}:${row.buyer_id} → ${
|
||||
targetUserId ? "user " + targetUserId : "anon " + targetCookieId
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// sweepUnappliedPurchases({ buyerType, buyerId, cookieIds }) — catch
|
||||
// up on settled-but-unapplied purchases for a buyer.
|
||||
//
|
||||
// Why this exists: the buy → BTCPay → settle → apply pipeline depends
|
||||
// on the buyer's browser tab polling /api/credits/invoice/:id after
|
||||
// BTCPay redirects back. But BTCPay redirects in the SAME tab (the
|
||||
// poll loop dies before it gets a chance to see "settled"), and even
|
||||
// when the redirect lands back on Recap the buyer might close it
|
||||
// before the next poll tick. Result: the relay knows the invoice is
|
||||
// settled and the operator pool has the credits, but the LOCAL
|
||||
// pending_purchases row never flips to applied — so the buyer's
|
||||
// balance stays stale until they manually re-poll, which they have no
|
||||
// way to do.
|
||||
//
|
||||
// Fix: opportunistically sweep on every /api/account/whoami and
|
||||
// /api/relay/status. Cheap (small bounded query + a few relay HTTP
|
||||
// calls), idempotent (applyPendingPurchase no-ops on already-applied
|
||||
// rows), and self-healing.
|
||||
//
|
||||
// Also called from anon-trial.js linkToUser BEFORE the transfer, so
|
||||
// any anon-bought credits that hadn't yet been applied locally are
|
||||
// rolled into anon_trials.credits_total before we copy them over to
|
||||
// the new user's tenant_credits.
|
||||
//
|
||||
// Scope: only sweeps the buyer's OWN pending rows. cookieIds is an
|
||||
// optional list of additional anon cookie_ids the caller wants
|
||||
// swept on this buyer's behalf (used by /whoami for the new-signup
|
||||
// case where the just-converted cookie may still have unapplied
|
||||
// purchases). Cap at 5 invoices per sweep + 30-minute lookback so a
|
||||
// degenerate case can't fan out into hundreds of relay calls per
|
||||
// request.
|
||||
export async function sweepUnappliedPurchases({
|
||||
buyerType,
|
||||
buyerId,
|
||||
cookieIds = [],
|
||||
req = null,
|
||||
} = {}) {
|
||||
if (RECAP_MODE !== "multi") return;
|
||||
if (!buyerType && (!cookieIds || cookieIds.length === 0)) return;
|
||||
const base = getRelayBaseURL();
|
||||
if (!base) return; // no relay configured, nothing to sweep against
|
||||
|
||||
const { getDb } = await import("./db.js");
|
||||
const db = getDb();
|
||||
|
||||
// 30-minute lookback. Older unapplied purchases probably failed for
|
||||
// a reason we don't want to keep retrying every page-load (relay
|
||||
// unreachable, invoice expired, etc.). Operator can reconcile
|
||||
// manually if they fire.
|
||||
const since = Date.now() - 30 * 60 * 1000;
|
||||
|
||||
// Build the WHERE clause. Always include the primary buyer; OR in
|
||||
// any extra cookieIds the caller passed.
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (buyerType && buyerId) {
|
||||
conditions.push("(buyer_type = ? AND buyer_id = ?)");
|
||||
params.push(buyerType, buyerId);
|
||||
}
|
||||
for (const cid of cookieIds) {
|
||||
if (typeof cid === "string" && cid) {
|
||||
conditions.push("(buyer_type = 'anon' AND buyer_id = ?)");
|
||||
params.push(cid);
|
||||
}
|
||||
}
|
||||
if (conditions.length === 0) return;
|
||||
params.push(since);
|
||||
|
||||
let rows = [];
|
||||
try {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT invoice_id FROM pending_purchases
|
||||
WHERE (${conditions.join(" OR ")})
|
||||
AND applied_at IS NULL
|
||||
AND created_at >= ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5`,
|
||||
)
|
||||
.all(...params);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[credits/sweep] query failed: ${err?.message || err}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
|
||||
for (const { invoice_id: invoiceId } of rows) {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`,
|
||||
{
|
||||
headers: relayHeaders({ req }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
},
|
||||
);
|
||||
if (!r.ok) continue;
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
const status = body?.result?.status || body?.status || null;
|
||||
if (status === "settled") {
|
||||
await applyPendingPurchase(invoiceId);
|
||||
}
|
||||
} catch (err) {
|
||||
// Best-effort; swallow per-invoice errors so one bad invoice
|
||||
// doesn't block the others (or the page-load).
|
||||
console.warn(
|
||||
`[credits/sweep] invoice ${invoiceId} check failed: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user