Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions apps/web/src/app/(app)/settings/billing/_components/plan-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState, useTransition } from 'react';
import { toast } from 'sonner';
import { Check } from 'lucide-react';
import type { PlanRow } from '@holo/billing';
import { resolveStorageCap, type PlanRow } from '@holo/billing';

interface Props {
plans: PlanRow[];
Expand Down Expand Up @@ -122,18 +122,27 @@ export function PlanGrid({ plans, currentSlug, highlightSlug }: Props) {
<li className="flex gap-2">
<Check className="h-4 w-4 shrink-0 text-text-subtle" aria-hidden />
<span>
{plan.features.maxStoredArtifacts === null
|| plan.features.maxStoredArtifacts === undefined ? (
'Unlimited indexed items'
) : (
<>
Up to{' '}
<span className="tabular-nums text-text">
{formatCredits(plan.features.maxStoredArtifacts)}
</span>{' '}
indexed items
</>
)}
{(() => {
// Fall back to the slug-keyed default if the DB row is
// missing `maxStoredArtifacts` (legacy seed rows from
// pre-0067 migrations). Same source of truth the gate
// uses, so what we advertise matches what we enforce.
const cap = resolveStorageCap(
plan.slug,
plan.features.maxStoredArtifacts,
);
return cap === null ? (
'Unlimited indexed items'
) : (
<>
Up to{' '}
<span className="tabular-nums text-text">
{formatCredits(cap)}
</span>{' '}
indexed items
</>
);
})()}
</span>
</li>
<li className="flex gap-2">
Expand Down
4 changes: 4 additions & 0 deletions packages/billing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export {
type CreditPoolDecision,
type StorageQuotaDecision,
} from './limits';
export {
resolveStorageCap,
PLAN_DEFAULT_STORAGE_CAP,
} from './plan-defaults';
export {
seedInitialSubscriptionAndGrant,
processExpiredPeriods,
Expand Down
7 changes: 6 additions & 1 deletion packages/billing/src/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { holoError, ErrorCode } from '@holo/errors';
import { billingEnabled } from './env';
import { getOrgBalance } from './ledger';
import { getCurrentSubscription } from './plans';
import { resolveStorageCap } from './plan-defaults';

const { connectorCredentials, chunks } = schema;

Expand Down Expand Up @@ -160,7 +161,11 @@ export async function checkStorageQuota(
if (!billingEnabled()) return { allowed: true, currentCount: 0, limit: null };
const sub = await getCurrentSubscription(db, organizationId);
if (!sub) return { allowed: true, currentCount: 0, limit: null };
const limit = sub.plan.features.maxStoredArtifacts ?? null;
// Resolve via the slug-keyed defaults: if a legacy plan row is missing
// `maxStoredArtifacts` (because migration 0067 hasn't reached this env),
// we still enforce the canonical cap. Explicit `null` on the row — e.g.
// enterprise — means intentionally unlimited and is honoured.
const limit = resolveStorageCap(sub.plan.slug, sub.plan.features.maxStoredArtifacts);
if (limit === null) return { allowed: true, currentCount: 0, limit: null };

const rows = await db
Expand Down
39 changes: 39 additions & 0 deletions packages/billing/src/plan-defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Canonical hard caps per plan slug, used as the source of truth for both
* enforcement (`checkStorageQuota`) and presentation (settings/billing
* PlanGrid, the landing page PricingBand). The DB `billing_plans.features`
* JSONB is the authoritative store, but a row created by an older migration
* (or one that hasn't been re-seeded) may be missing the
* `maxStoredArtifacts` key. We fall back to these defaults so:
*
* - the UI never falsely advertises "Unlimited"
* - the gate stays armed even if a migration is pending
*
* Keep these in sync with the seed values in
* `packages/db/migrations/0067_storage_caps.sql`. If you tune one, tune both.
*/
export const PLAN_DEFAULT_STORAGE_CAP: Record<string, number | null> = {
free: 10_000,
starter: 100_000,
team: 1_000_000,
business: 10_000_000,
enterprise: null,
};

/**
* Resolve a plan's effective storage cap. Order of precedence:
* 1. value on the row's `features` JSONB (if set explicitly, even to null)
* 2. canonical default for the slug
* 3. `null` (unlimited) as a last resort
*
* `featureValue` is `undefined` when the JSONB key is missing entirely (the
* common case for legacy rows). It can be explicitly `null` to mean
* "intentionally unlimited" — e.g. enterprise — and we honour that.
*/
export function resolveStorageCap(
slug: string,
featureValue: number | null | undefined,
): number | null {
if (featureValue !== undefined) return featureValue;
return PLAN_DEFAULT_STORAGE_CAP[slug] ?? null;
}
34 changes: 34 additions & 0 deletions packages/billing/test/plan-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { PLAN_DEFAULT_STORAGE_CAP, resolveStorageCap } from '../src/plan-defaults';

describe('resolveStorageCap', () => {
it('returns the row value when set (honours explicit numbers)', () => {
expect(resolveStorageCap('free', 25_000)).toBe(25_000);
expect(resolveStorageCap('team', 5_000_000)).toBe(5_000_000);
});

it('honours explicit null on the row (intentional unlimited)', () => {
expect(resolveStorageCap('team', null)).toBe(null);
expect(resolveStorageCap('enterprise', null)).toBe(null);
});

it('falls back to the slug default when the row value is undefined', () => {
// The common case: legacy `billing_plans` row from before migration 0067
// where the JSONB key is simply missing.
expect(resolveStorageCap('free', undefined)).toBe(10_000);
expect(resolveStorageCap('starter', undefined)).toBe(100_000);
expect(resolveStorageCap('team', undefined)).toBe(1_000_000);
expect(resolveStorageCap('business', undefined)).toBe(10_000_000);
});

it('returns null (unlimited) for unknown slugs', () => {
// Custom/legacy slugs we don't have an opinion on get the safe default
// of no cap rather than an arbitrary number.
expect(resolveStorageCap('starter-legacy-2026-05', undefined)).toBe(null);
expect(resolveStorageCap('foo', undefined)).toBe(null);
});

it('exposes enterprise as null in the constants map', () => {
expect(PLAN_DEFAULT_STORAGE_CAP.enterprise).toBe(null);
});
});
Loading