// Standard response envelope. Every /relay/* response (success and // error both) goes through this so Recap clients can keep their // credit-balance display accurate regardless of what happened. // // Shape: { result, credits_remaining, tier, credit_charged } import { getOrCreateRow, computeRemaining } from "../credits.js"; import { getTierQuotas } from "../config.js"; // Build the envelope around a result object. export async function envelope({ result = null, installId, // License is optional but recommended — without it, balance lookups // route to the install-keyed row even for paid users, which would // briefly underreport their balance after a commitCredit landed on // their license-keyed row. Routes pass it through from resolveLicense. license = null, // Explicit ledger key override (cloud `user:` path). Takes // precedence over (installId, license) when present. creditKey = null, tier, creditCharged = 0, }) { const quota = await getTierQuotas(); const row = await getOrCreateRow({ installId, license, creditKey }); // tier_snapshot on the row was just updated by commitCredit; if no // credit was committed (free reuse via job_id) it still reflects // the last-known tier for this install, which is fine. const balance = computeRemaining(row, quota); return { result, // `total` = tier allotment + purchased top-up. Recap renders this // as the headline number on its credits pill. `remaining` alone // wouldn't reflect purchased credits at all — so a buyer who // just bought 5 credits and had 0 tier credits left would still // see "0 relay credits" until their tier renewed. credits_remaining: balance.total, // null = unlimited (Max) // Breakdown for clients that want to display it. tier_remaining: balance.remaining, purchased_balance: balance.purchased, tier, credit_charged: creditCharged, }; } // Same shape but for error responses. The error reason goes in `error` // alongside `result: null`. Clients should still update their balance // display from `credits_remaining` so failed calls (which were // refunded) reflect the unchanged balance. export async function errorEnvelope({ error, installId, license = null, creditKey = null, tier = "core", statusHint = 500, }) { let creditsRemaining = null; let tierRemaining = null; let purchased = 0; try { const quota = await getTierQuotas(); const row = await getOrCreateRow({ installId: creditKey ? null : installId || "unknown", license, creditKey, }); const balance = computeRemaining(row, quota); creditsRemaining = balance.total; tierRemaining = balance.remaining; purchased = balance.purchased; } catch {} return { statusHint, body: { result: null, error: typeof error === "string" ? error : error?.message || "unknown_error", credits_remaining: creditsRemaining, tier_remaining: tierRemaining, purchased_balance: purchased, tier, credit_charged: 0, }, }; }