Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code c840275bd5 Billing page crashes when subscription's Stripe price is not in active plan catalog
https://sonarly.com/issue/7806?type=bug

`useCurrentMetered` hook throws "Element not found" when the user's subscription item references a Stripe price ID that no longer appears in the active plan catalog, crashing the entire /settings/billing page.

Fix: ## Root Cause Category: D — Data Integrity Issue

The subscription item retains the `stripePriceId` that was assigned when the subscription was created. The plan catalog (`listPlans`) filters to `active: true` prices only. When a Stripe price is archived and the `price.updated` webhook sets `active = false` in the database, the catalog no longer includes that price — but the subscription item still references it. The strict `findOrThrow` at line 46 (using the **default** error message `"Element not found"`) fails, crashing the entire `/settings/billing` page.

## The Fix

**File:** `packages/twenty-front/src/modules/billing/hooks/useCurrentMetered.ts`

Replace the bare `findOrThrow` (which crashes with a generic error) with a `find` + warn + fallback pattern:

```typescript file=packages/twenty-front/src/modules/billing/hooks/useCurrentMetered.ts lines=45-61
  const meteredPrices = getCurrentMeteredPricesByInterval();
  const foundMeteredBillingPrice = meteredPrices.find(
    (price) =>
      price.stripePriceId ===
      currentMeteredBillingSubscriptionItem.stripePriceId,
  );
  if (!foundMeteredBillingPrice) {
    // eslint-disable-next-line no-console
    console.warn(
      `[Billing] Subscription item references Stripe price "${currentMeteredBillingSubscriptionItem.stripePriceId}" which is absent from the active plan catalog. The price may have been archived or rotated in Stripe. Falling back to the first active metered price.`,
    );
  }
  const currentMeteredBillingPrice = (foundMeteredBillingPrice ??
    meteredPrices[0]) as MeteredBillingPrice;
  if (!currentMeteredBillingPrice) {
    throw new Error('[Billing] No active metered prices found in the plan catalog.');
  }
```

### What changed and why

| Before | After |
|--------|-------|
| `findOrThrow(meteredPrices, predicate)` — crashes with `"Element not found"` when the subscription's price is not in the active catalog | `find(predicate)` — returns `undefined` gracefully when the price has been archived |
| No logging — the mismatch is invisible | `console.warn` with the specific `stripePriceId` — makes the data divergence visible in the browser console so on-call engineers can correlate it with the affected workspace |
| Page crashes entirely, blocking the user | Falls back to `meteredPrices[0]` (first active metered price in the catalog) — the page loads and the user can manage/update their billing |
| — | Final guard throws `"No active metered prices found"` if the catalog is truly empty — a real misconfiguration, not a stale-price scenario |

### Why the first `findOrThrow` (line 37) is left unchanged

The lookup for `WORKFLOW_NODE_EXECUTION` on the subscription items themselves is a valid invariant — those items come from the user's live Stripe subscription data (not from the filtered catalog), so a missing item there would indicate a genuinely broken subscription state, not a price-rotation edge case.

### Follow-up recommendation

A server-side improvement worth considering: the `listPlans` GraphQL query could optionally include prices that are `active = false` but are referenced by at least one active subscription item. This would let the UI display the actual price tier the user is on (rather than the fallback) without risking catalog "pollution" for new subscribers.
2026-03-02 14:15:35 +00:00
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
@@ -43,12 +43,22 @@ export const useCurrentMetered = () => {
);
const meteredPrices = getCurrentMeteredPricesByInterval();
const currentMeteredBillingPrice = findOrThrow(
meteredPrices,
const foundMeteredBillingPrice = meteredPrices.find(
(price) =>
price.stripePriceId ===
currentMeteredBillingSubscriptionItem.stripePriceId,
) as MeteredBillingPrice;
);
if (!foundMeteredBillingPrice) {
// eslint-disable-next-line no-console
console.warn(
`[Billing] Subscription item references Stripe price "${currentMeteredBillingSubscriptionItem.stripePriceId}" which is absent from the active plan catalog. The price may have been archived or rotated in Stripe. Falling back to the first active metered price.`,
);
}
const currentMeteredBillingPrice = (foundMeteredBillingPrice ??
meteredPrices[0]) as MeteredBillingPrice;
if (!currentMeteredBillingPrice) {
throw new Error('[Billing] No active metered prices found in the plan catalog.');
}
return {
currentMeteredBillingSubscriptionItem,