Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 6c0a0f18da Missing plan metadata in subscription causes billing page crash
https://sonarly.com/issue/7805?type=bug

The `useCurrentPlan` hook throws "Current plan not found" when a workspace's Stripe subscription metadata lacks a `plan` key, crashing the entire billing settings page.

Fix: ## Classification: Category D — Data Integrity Issue

Legacy subscriptions created before the Sept 2025 billing refactoring (`43e0cd5d05`) have `metadata: {}` or `metadata: { workspaceId: '...' }` — no `plan` key. The server already handles this gracefully in `getPlanKeyFromSubscription` by defaulting to `BillingPlanKey.PRO`. The frontend hook was introduced in the same refactoring without this fallback.

## Fix

Extract the plan key resolution into a single variable with a `?? BillingPlanKey.PRO` fallback, matching server-side behavior. `findOrThrow` is preserved so it still throws for genuinely unknown plan key strings in metadata.

```typescript file=packages/twenty-front/src/modules/billing/hooks/useCurrentPlan.ts lines=14-28
  const planKeyFromMetadata =
    (currentWorkspace.currentBillingSubscription?.metadata?.['plan'] as
      | BillingPlanKey
      | undefined) ?? BillingPlanKey.PRO;

  const currentPlan = findOrThrow(
    listPlans(),
    (plan) => plan.planKey === planKeyFromMetadata,
    new Error('Current plan not found'),
  );

  const oppositPlan =
    planKeyFromMetadata === BillingPlanKey.ENTERPRISE
      ? BillingPlanKey.PRO
      : BillingPlanKey.ENTERPRISE;
```

**Why this is correct and not a banned pattern:** This is not silencing an error — it applies the same business rule that already exists server-side: a subscription without a `plan` metadata key is treated as `PRO`. The fallback is deliberate and mirrors the canonical server logic. `findOrThrow` still guards against a completely unrecognized plan key value in the metadata.

**Secondary benefit:** The original code read `metadata?.['plan']` twice (once in `findOrThrow`, once in the `oppositPlan` ternary), with both being independent raw accesses. Now both use the single resolved `planKeyFromMetadata`, making the logic consistent.

**Note for the team:** Consider a backfill script/command under `billing/commands/` to populate `metadata.plan` for all active subscriptions that are missing it, so the fallback becomes unreachable over time.
2026-03-02 14:14:57 +00:00
@@ -11,19 +11,19 @@ export const useCurrentPlan = () => {
assertIsDefinedOrThrow(currentWorkspace);
const planKeyFromMetadata =
(currentWorkspace.currentBillingSubscription?.metadata?.['plan'] as
| BillingPlanKey
| undefined) ?? BillingPlanKey.PRO;
const currentPlan = findOrThrow(
listPlans(),
(plan) =>
plan.planKey ===
(currentWorkspace.currentBillingSubscription?.metadata?.['plan'] as
| BillingPlanKey
| undefined),
(plan) => plan.planKey === planKeyFromMetadata,
new Error('Current plan not found'),
);
const oppositPlan =
currentWorkspace?.currentBillingSubscription?.metadata?.['plan'] ===
BillingPlanKey.ENTERPRISE
planKeyFromMetadata === BillingPlanKey.ENTERPRISE
? BillingPlanKey.PRO
: BillingPlanKey.ENTERPRISE;