Stirling-Tools/Stirling-PDF · error · Error

No team resolved yet

Error message

No team resolved yet

What it means

Thrown by the `openPortal` callback in useWallet when the Stripe customer-portal session cannot be minted because `wallet.teamId` is null/undefined. The PAYG portal edge function (an RPC enforced outside Spring Security) requires a concrete teamId to resolve the caller's team and authorize the session, so refusing to call it without one prevents a guaranteed 400/403 from the backend.

Source

Thrown at frontend/editor/src/cloud/hooks/useWallet.ts:331

    },
    [devPreview, refetch],
  );

  const openPortal = useCallback(async () => {
    if (devPreview) {
      // No real Stripe in dev preview — open a placeholder so the click still
      // feels alive. Routed through the openExternal seam to stay portable.
      await openExternal("https://billing.stripe.com/p/login/mock");
      return;
    }
    // Mint the portal session through the billing seam, passing teamId: the
    // PAYG portal edge function needs it to resolve the caller's team outside
    // Spring Security (its RPC enforces team membership). Then hand the URL to
    // the openExternal seam so each platform routes it appropriately. The seam
    // throws on error (e.g. 404 team_not_subscribed) so callers can toast.
    const teamId = wallet?.teamId;
    if (teamId == null) {
      throw new Error("No team resolved yet");
    }
    const { url } = await createPortalSession({ teamId });
    await openExternal(url);
  }, [devPreview, wallet?.teamId]);

  return {
    wallet,
    loading,
    error,
    refetch,
    markSubscribed,
    updateCap,
    openPortal,
  };
}

View on GitHub (pinned to 9ef20dcab8)

Solutions

  1. Gate the portal button on `wallet?.teamId` being present (and `loading===false`) so the click is only enabled once a team is resolved.
  2. Before throwing, call `refetch()` once and re-read teamId to recover from a stale cache.
  3. If this is a SaaS-only user with no PAYG team, hide the portal action entirely behind a feature check rather than letting the user click into an error.
  4. Toast a user-facing 'Your team is still being set up' message in the catch so the failure is not silent.

Example fix

// before
const openPortal = useCallback(async () => {
  if (devPreview) { await openExternal("https://billing.stripe.com/p/login/mock"); return; }
  const teamId = wallet?.teamId;
  if (teamId == null) throw new Error("No team resolved yet");
  const { url } = await createPortalSession({ teamId });
  await openExternal(url);
}, [devPreview, wallet?.teamId]);

// after — gate the UI and refetch once before giving up
const openPortal = useCallback(async () => {
  if (devPreview) { await openExternal("https://billing.stripe.com/p/login/mock"); return; }
  let teamId = wallet?.teamId;
  if (teamId == null) { await refetch(); teamId = walletRef.current?.teamId; }
  if (teamId == null) throw new Error("No team resolved yet");
  const { url } = await createPortalSession({ teamId });
  await openExternal(url);
}, [devPreview, wallet?.teamId, refetch]);
Defensive patterns

Strategy: validation

Validate before calling

// Before calling openPortal, confirm a team is resolvable
const canOpenPortal = !devPreview && wallet?.teamId != null && !loading;
if (!canOpenPortal) {
  // optionally refetch once, then re-check
  await refetch();
}
if (wallet?.teamId == null) {
  // do not call openPortal; show 'team not ready' UI
}

Type guard

function hasTeamId(w: { teamId?: string | null } | null | undefined): w is { teamId: string } {
  return !!w && typeof w.teamId === "string" && w.teamId.length > 0;
}

Try / catch

try {
  await openPortal();
} catch (e) {
  if (e instanceof Error && e.message === "No team resolved yet") {
    toast.info("Your team is still being set up. Try again in a moment.");
    await refetch();
  } else {
    toast.error("Could not open the billing portal.");
  }
}

Prevention

When it happens

Trigger: Calling openPortal() before the wallet query has resolved (loading===true); the wallet endpoint returns a record with no teamId (user not yet on a team / not provisioned in PAYG); the wallet query errored and wallet is still its initial null state; devPreview is false but the user is in a non-PAYG (SaaS) flavor where teamId is never populated.

Common situations: User clicks the 'Manage billing / Open portal' button immediately on first wallet render; user belongs to a SaaS team that has no PAYG subscription so teamId is absent; race where refetch has not repopulated wallet after a team switch.

Related errors


AI-assisted analysis of Stirling-Tools/Stirling-PDF@9ef20dcab8 (2026-08-13). Data as JSON: /api/errors/2c8a8b77fa575154. Report an issue: GitHub.