Brand Studio docs
Engineering manualAPI route source

/api/credits/topup

Exact current route handler: hosted-app/app/api/credits/topup/route.js

Methods: POST. Input signals: none detected. Literal response-status signals: 400, 502, 503.

Source: hosted-app/app/api/credits/topup/route.js. Imports: ../../../../lib/all-rest/alpha-parking.mjs, ../../../../lib/all-rest/pull-tier-catalog.mjs, ../../../../lib/bounded-http.mjs, ../../../../lib/comprehensive-pull.mjs, ../../../../lib/credits.mjs, ../../../../lib/ledger-snapshot.mjs, ../../../../lib/pricing.mjs, ../../../../lib/pull-tier.mjs, ../../../../lib/sandbox-mode.mjs, ../../../../lib/stripe-checkout-attempts.mjs, ../../../../lib/stripe-credit-checkout.mjs, ../../../../lib/supabase-rest.mjs, next/server. Exact source is shown for inspection; its presence does not prove authentication, validation or live behavior.

All API routes · API and execution contract

import { readBoundedJsonRequest } from "../../../../lib/bounded-http.mjs";
import { creditBillingSessionRefusal } from "../../../../lib/stripe-credit-checkout.mjs";
import { startCreditCheckout } from "../../../../lib/stripe-checkout-attempts.mjs";
import { readCompleteCreditLedger } from "../../../../lib/ledger-snapshot.mjs";
// POST /api/credits/topup — local sandbox grant or hosted Stripe Checkout.
//
// Explicit local sandbox grants remain here. Other environments delegate to
// Stripe Checkout and grant nothing until a verified paid webhook arrives.
//
// The free grant branch is available only where lib/sandbox-mode.mjs permits
// explicit development opt-in. Production delegates to Stripe and never uses
// the sandbox grant branch, even with a sandbox flag set.
//
// WHAT IT WRITES. One `topup` row in brand_studio_credit_ledger, with the
// pack's credit count and a reason that names itself as a sandbox grant. The
// row is written with the SERVICE ROLE key for the same reason the spend row
// is (comprehensive-pull-handler.mjs L449): RLS reserves ledger writes to
// service_role precisely so a customer cannot mint their own credits. The
// deployment therefore refuses this route without that key rather than
// appearing to work and failing at the database.
//
// THE PACK IS LOOKED UP, NEVER ACCEPTED. The body names a pack id; the server
// reads the credits and the dollars out of lib/pricing.mjs. A client that can
// post an amount is a client that can post 10,000, and "it's only the sandbox"
// is exactly how that ships to production one day.

import { NextResponse } from "next/server";
import { supabaseConfigFromEnv, supabaseRestRequest } from "../../../../lib/supabase-rest.mjs";
import { resolveAuthContext, bearerTokenFromHeader } from "../../../../lib/comprehensive-pull.mjs";
import { describeCredits, creditCostForPull, LEDGER_ACTOR_SYSTEM } from "../../../../lib/credits.mjs";
import { packById, CREDIT_PACKS, CREDIT_PACK_NOTE } from "../../../../lib/pricing.mjs";
import { unitsByTier } from "../../../../lib/all-rest/pull-tier-catalog.mjs";
import { applyAlphaParkingToTierCatalog, applyAlphaParkingToTierDescriptions } from "../../../../lib/all-rest/alpha-parking.mjs";
import { describeTiers } from "../../../../lib/pull-tier.mjs";
import { resolveSandboxMode } from "../../../../lib/sandbox-mode.mjs";

const PULL_TIER_CATALOG = applyAlphaParkingToTierDescriptions(
  describeTiers(applyAlphaParkingToTierCatalog(unitsByTier(), { env: process.env }))
);

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function POST(request) {
  const sandbox = resolveSandboxMode(process.env);
  const config = supabaseConfigFromEnv(process.env);
  const token = bearerTokenFromHeader(request.headers.get("authorization"));
  const authContext = await resolveAuthContext({ token, ...config });

  // Auth first, so a signed-out caller on a production deployment is told to
  // sign in rather than told about a sandbox that would not have served them
  // anyway.
  const refusal = creditBillingSessionRefusal(authContext);
  if (refusal) {
    const { http, ...body } = refusal;
    return NextResponse.json({ ...body, cost_per_pull: creditCostForPull({ route: "hosted", depth: "standard" }) },
      { status: http, headers: { "cache-control": "no-store" } });
  }

  if (!sandbox.enabled) {
    const body = await readBoundedJsonRequest(request, 4096).catch(() => ({}));
    const result = await startCreditCheckout({ config, userId: authContext.user.id,
      packId: body?.pack, attemptId: body?.attempt_id, afterAttemptId: body?.after_attempt_id });
    const { http, ...response } = result;
    return NextResponse.json(response, { status: http, headers: { "cache-control": "no-store" } });
  }

  if (!config.supabaseServiceRoleKey) {
    // Same posture as the pull gate: refuse up front rather than accept a
    // purchase the database will reject at the last step.
    return NextResponse.json({
      status: "refused",
      reason: "billing_not_configured",
      detail: "SUPABASE_SERVICE_ROLE_KEY is not set, so a credit grant could not be written."
    }, { status: 503 });
  }

  const body = await readBoundedJsonRequest(request, 4096).catch(() => ({}));
  const pack = packById(body?.pack);
  if (!pack) {
    return NextResponse.json({
      status: "refused",
      reason: "unknown_pack",
      detail: `Choose one of: ${CREDIT_PACKS.map((entry) => entry.id).join(", ")}.`
    }, { status: 400 });
  }

  const insert = await supabaseRestRequest({
    ...config,
    pathName: "brand_studio_credit_ledger",
    method: "POST",
    body: [{
      user_id: authContext.user.id,
      kind: "topup",
      amount: pack.credits,
      // The row says what it is, forever. A sandbox grant that reads like a
      // purchase in the ledger is a reconciliation bug waiting to happen.
      reason: `sandbox checkout — ${pack.label} (${pack.credits} credit${pack.credits === 1 ? "" : "s"}, $${pack.price_usd} not charged)`,
      // WHO. A sandbox checkout is self-serve — a customer pressed a button
      // that spends no money — so the actor is the machine, not a named
      // human. `admin:<email>` is reserved for the /admin top-up, which is
      // the only path where a person grants credits to an account that is
      // not their own. See the actors block in lib/credits.mjs.
      actor: LEDGER_ACTOR_SYSTEM
    }],
    prefer: "return=representation"
  });
  if (insert.status !== "passed") {
    return NextResponse.json({
      status: "failed",
      reason: `topup_write_failed: ${insert.reason || "unknown"}`
    }, { status: 502 });
  }

  // Read the balance back from the ledger rather than adding the pack to a
  // number the client already had. The balance is a fold over rows; anything
  // else is a second implementation of it that can drift.
  const ledgerResponse = await readCompleteCreditLedger({ config, userId: authContext.user.id, authToken: authContext.token });
  if (ledgerResponse.status !== "passed") return NextResponse.json({ status: "failed", reason: "grant_recorded_balance_unavailable", granted: pack.credits, retry_topup: false }, { status: 502 });
  const entries = Array.isArray(ledgerResponse.body) ? ledgerResponse.body : [];

  // The SAME SHAPE /api/credits answers with, deliberately. The client keeps
  // one credits object; a purchase response that omitted `packs` replaced that
  // object with a partial one and the price list vanished until the next page
  // load — the checkout dialog reopened with no packs in it. A response that
  // is "the new state" has to actually be the whole state.
  return NextResponse.json({
    status: "passed",
    email: authContext.user.email || "",
    sandbox: true,
    sandbox_detail: sandbox.detail,
    packs: CREDIT_PACKS.map((entry) => ({ ...entry })),
    // `tiers` and `pack_note` ride here for the SAME reason `packs` does, and
    // the reason is in the comment above: the client keeps one credits object
    // and this response replaces it. A purchase that answered without the tier
    // catalog would blank the tier chips until the next page load — the exact
    // failure that made the checkout dialog reopen with no packs in it.
    pack_note: CREDIT_PACK_NOTE,
    tiers: PULL_TIER_CATALOG,
    granted: pack.credits,
    pack: { id: pack.id, label: pack.label, credits: pack.credits, price_usd: pack.price_usd },
    cost_per_pull: creditCostForPull({ route: "hosted", depth: "standard" }),
    ...describeCredits({ entries })
  });
}