Skip to content

fix(billing): show truthful storage caps on /settings/billing - #224

Merged
maakle merged 1 commit into
mainfrom
claude/storage-caps-ui-truthful-ROx9U
May 21, 2026
Merged

fix(billing): show truthful storage caps on /settings/billing#224
maakle merged 1 commit into
mainfrom
claude/storage-caps-ui-truthful-ROx9U

Conversation

@maakle

@maakle maakle commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

The dashboard /settings/billing PlanGrid was advertising "Unlimited indexed items" for every plan — Free through Business. The landing page got it right (10K / 100K / 1M / 10M) because those values are hardcoded, but the dashboard read from billing_plans.features.maxStoredArtifacts in the DB and fell through to "Unlimited" whenever the key was missing.

Root cause

Migration 0061 (pricing-model-v2) created new plan rows with fresh UUIDs and features JSONB that didn't include maxStoredArtifacts. Migration 0067 then merges the key in via UPDATE … features || jsonb_build_object(…). So in any environment where 0067 hasn't landed yet — or a future pricing migration inserts fresh rows without the key — both the UI tile AND the gate fall through to "unlimited" (silent under-enforcement on the gate side, false advertising on the UI side).

Fix

Add packages/billing/src/plan-defaults.ts with the canonical caps per slug and a resolveStorageCap(slug, featureValue) helper:

PLAN_DEFAULT_STORAGE_CAP = {
  free: 10_000,
  starter: 100_000,
  team: 1_000_000,
  business: 10_000_000,
  enterprise: null,
};

function resolveStorageCap(slug, featureValue) {
  if (featureValue !== undefined) return featureValue;  // explicit number or explicit null
  return PLAN_DEFAULT_STORAGE_CAP[slug] ?? null;
}

Used in both:

  • checkStorageQuota — gate stays armed even when the migration is pending (was silently allowing unlimited before)
  • PlanGrid tile rendering — what we advertise now matches what we enforce

undefined (missing key) → fallback to the slug default. Explicit null (intentional unlimited, e.g. Enterprise) is honoured.

What about the landing page?

Already correct (hardcoded in PR #223). Left alone to keep this diff focused; if values change, the landing copy needs human review anyway. If we want to DRY it later, swap the hardcoded indexedItems: '10K indexed items' strings for reads from PLAN_DEFAULT_STORAGE_CAP.

Files

New:

  • packages/billing/src/plan-defaults.ts
  • packages/billing/test/plan-defaults.test.ts (5 tests)

Modified:

  • packages/billing/src/limits.tscheckStorageQuota uses the resolver
  • packages/billing/src/index.ts — exports
  • apps/web/src/app/(app)/settings/billing/_components/plan-grid.tsx — uses the resolver

Test plan

  • pnpm --filter @holo/billing test — 32/32 pass (5 new for the resolver: explicit numbers, explicit null, undefined fallback, unknown slugs, constants map)
  • pnpm -r typecheck — clean
  • pnpm exec eslint . — clean
  • Visit /settings/billing on a fresh DB before applying migration 0067 → tiles still show correct "Up to 10K / 100K / 1M / 10M indexed items"
  • Same DB: triggering a sync past the cap still trips storage_cap_reached (gate enforces)
  • Visit on a DB where 0067 has applied → identical output (no regression)

https://claude.ai/code/session_01Mvr1Stxz7czdDcyB5XsjHD


Generated by Claude Code

The dashboard's PlanGrid tiles were advertising "Unlimited indexed items"
for every plan, including Free. Root cause: the tiles read
`plan.features.maxStoredArtifacts` directly from the DB JSONB, but
migration 0061 (pricing-model-v2) created new plan rows with `features`
JSONB that didn't include the storage key. Migration 0067 then merges
the key in via UPDATE — but in any environment where 0067 hasn't landed
yet (or where a future pricing migration creates fresh rows without the
key), the UI silently falls through to the "Unlimited" branch and lies.

Fix: add a `plan-defaults.ts` module with canonical caps per slug, and
a `resolveStorageCap(slug, featureValue)` helper that returns:
  1. the row value if explicitly set (numbers OR null)
  2. the slug-keyed default otherwise
  3. null (unlimited) for unknown slugs

Use the resolver in BOTH:
  - `checkStorageQuota` so enforcement stays armed even when migration
    is pending (previously the gate also fell through to "unlimited"
    when the key was missing — silent under-enforcement)
  - PlanGrid so the dashboard tiles match the landing page

What we advertise now matches what we enforce. Landing-page PricingBand
already had the right hardcoded values from PR #223; leaving it alone
to keep the diff small (it'll drift if values change, but landing copy
needs human review anyway).

Tests: 5 new for `resolveStorageCap` covering explicit values, explicit
null (intentional unlimited), undefined (fallback), unknown slugs, and
the constants map. All 32 billing tests pass.

https://claude.ai/code/session_01Mvr1Stxz7czdDcyB5XsjHD
@maakle
maakle merged commit 1c224cc into main May 21, 2026
5 checks passed
@maakle
maakle deleted the claude/storage-caps-ui-truthful-ROx9U branch May 21, 2026 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants