{"record":{"id":"2c8a8b77fa575154","repo":"Stirling-Tools/Stirling-PDF","slug":"no-team-resolved-yet","errorCode":null,"errorMessage":"No team resolved yet","messagePattern":"No team resolved yet","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"frontend/editor/src/cloud/hooks/useWallet.ts","lineNumber":331,"sourceCode":"    },\n    [devPreview, refetch],\n  );\n\n  const openPortal = useCallback(async () => {\n    if (devPreview) {\n      // No real Stripe in dev preview — open a placeholder so the click still\n      // feels alive. Routed through the openExternal seam to stay portable.\n      await openExternal(\"https://billing.stripe.com/p/login/mock\");\n      return;\n    }\n    // Mint the portal session through the billing seam, passing teamId: the\n    // PAYG portal edge function needs it to resolve the caller's team outside\n    // Spring Security (its RPC enforces team membership). Then hand the URL to\n    // the openExternal seam so each platform routes it appropriately. The seam\n    // throws on error (e.g. 404 team_not_subscribed) so callers can toast.\n    const teamId = wallet?.teamId;\n    if (teamId == null) {\n      throw new Error(\"No team resolved yet\");\n    }\n    const { url } = await createPortalSession({ teamId });\n    await openExternal(url);\n  }, [devPreview, wallet?.teamId]);\n\n  return {\n    wallet,\n    loading,\n    error,\n    refetch,\n    markSubscribed,\n    updateCap,\n    openPortal,\n  };\n}\n","sourceCodeStart":313,"sourceCodeEnd":347,"githubUrl":"https://github.com/Stirling-Tools/Stirling-PDF/blob/9ef20dcab80b85041912f045e17a6aea1d08f969/frontend/editor/src/cloud/hooks/useWallet.ts#L313-L347","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Gate the portal button on `wallet?.teamId` being present (and `loading===false`) so the click is only enabled once a team is resolved.","Before throwing, call `refetch()` once and re-read teamId to recover from a stale cache.","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.","Toast a user-facing 'Your team is still being set up' message in the catch so the failure is not silent."],"exampleFix":"// before\nconst openPortal = useCallback(async () => {\n  if (devPreview) { await openExternal(\"https://billing.stripe.com/p/login/mock\"); return; }\n  const teamId = wallet?.teamId;\n  if (teamId == null) throw new Error(\"No team resolved yet\");\n  const { url } = await createPortalSession({ teamId });\n  await openExternal(url);\n}, [devPreview, wallet?.teamId]);\n\n// after — gate the UI and refetch once before giving up\nconst openPortal = useCallback(async () => {\n  if (devPreview) { await openExternal(\"https://billing.stripe.com/p/login/mock\"); return; }\n  let teamId = wallet?.teamId;\n  if (teamId == null) { await refetch(); teamId = walletRef.current?.teamId; }\n  if (teamId == null) throw new Error(\"No team resolved yet\");\n  const { url } = await createPortalSession({ teamId });\n  await openExternal(url);\n}, [devPreview, wallet?.teamId, refetch]);","handlingStrategy":"validation","validationCode":"// Before calling openPortal, confirm a team is resolvable\nconst canOpenPortal = !devPreview && wallet?.teamId != null && !loading;\nif (!canOpenPortal) {\n  // optionally refetch once, then re-check\n  await refetch();\n}\nif (wallet?.teamId == null) {\n  // do not call openPortal; show 'team not ready' UI\n}","typeGuard":"function hasTeamId(w: { teamId?: string | null } | null | undefined): w is { teamId: string } {\n  return !!w && typeof w.teamId === \"string\" && w.teamId.length > 0;\n}","tryCatchPattern":"try {\n  await openPortal();\n} catch (e) {\n  if (e instanceof Error && e.message === \"No team resolved yet\") {\n    toast.info(\"Your team is still being set up. Try again in a moment.\");\n    await refetch();\n  } else {\n    toast.error(\"Could not open the billing portal.\");\n  }\n}","preventionTips":["Disable the 'Open portal' button until wallet?.teamId is non-null and loading is false.","For SaaS-only users without a PAYG team, hide the portal action entirely instead of letting them click into an error.","Refetch the wallet once before surfacing the error to recover from a stale cache."],"tags":["billing","wallet","team","react","validation"],"backgroundTag":null,"analyzedSha":"9ef20dcab80b85041912f045e17a6aea1d08f969","analyzedAt":"2026-08-13T22:11:39.827Z","schemaVersion":2},"datasetVersion":"2026-08-14T00:17:13.853Z"}