0ae59f3550
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
672 lines
26 KiB
JavaScript
672 lines
26 KiB
JavaScript
// 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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|