fix(billing): show truthful storage caps on /settings/billing - #224
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The dashboard
/settings/billingPlanGrid 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 frombilling_plans.features.maxStoredArtifactsin 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
featuresJSONB that didn't includemaxStoredArtifacts. Migration 0067 then merges the key in viaUPDATE … 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.tswith the canonical caps per slug and aresolveStorageCap(slug, featureValue)helper:Used in both:
checkStorageQuota— gate stays armed even when the migration is pending (was silently allowing unlimited before)PlanGridtile rendering — what we advertise now matches what we enforceundefined(missing key) → fallback to the slug default. Explicitnull(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 fromPLAN_DEFAULT_STORAGE_CAP.Files
New:
packages/billing/src/plan-defaults.tspackages/billing/test/plan-defaults.test.ts(5 tests)Modified:
packages/billing/src/limits.ts—checkStorageQuotauses the resolverpackages/billing/src/index.ts— exportsapps/web/src/app/(app)/settings/billing/_components/plan-grid.tsx— uses the resolverTest 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— cleanpnpm exec eslint .— clean/settings/billingon a fresh DB before applying migration 0067 → tiles still show correct "Up to 10K / 100K / 1M / 10M indexed items"storage_cap_reached(gate enforces)https://claude.ai/code/session_01Mvr1Stxz7czdDcyB5XsjHD
Generated by Claude Code