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
17 changes: 17 additions & 0 deletions apps/web/src/app/(app)/settings/billing/_components/plan-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ export function PlanGrid({ plans, currentSlug, highlightSlug }: Props) {
: `${plan.features.maxConnectors} connector${plan.features.maxConnectors === 1 ? '' : 's'}`}
</span>
</li>
<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
</>
)}
</span>
</li>
<li className="flex gap-2">
<Check className="h-4 w-4 shrink-0 text-text-subtle" aria-hidden />
<span>Star Wars sample dataset included</span>
Expand Down
118 changes: 118 additions & 0 deletions apps/web/src/app/(app)/settings/billing/_components/storage-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
interface Props {
currentCount: number;
limit: number | null;
currentPlanName: string;
suggestedUpgradeSlug: string | null;
}

function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
return n.toLocaleString('en-US');
}

/**
* "Indexed items" card — counterpart to BalanceCard. Surfaces the plan's
* `maxStoredArtifacts` ceiling vs. the org's actual `chunks` row count, with
* a progress bar and graduated warnings.
*
* Visual rules (DESIGN.md):
* - JetBrains Mono + tabular-nums on the count (data, not decoration).
* - Progress bar uses `--text-subtle` fill, NOT `--accent` — BalanceCard's
* bar already owns the page's single accent use.
* - Status row colour shifts: muted < 80%, warning ≥ 80%, error at 100%.
*
* "Item" = one row in the `chunks` table (one embedding vector). The footer
* tooltip explains the unit since it's not obvious from the UI alone.
*/
export function StorageCard({
currentCount,
limit,
currentPlanName,
suggestedUpgradeSlug,
}: Props) {
const isUnlimited = limit === null;
const ratio = isUnlimited ? 0 : limit === 0 ? 1 : Math.min(1, currentCount / limit);
const atCap = !isUnlimited && currentCount >= (limit ?? 0);
const nearCap = !isUnlimited && !atCap && ratio >= 0.9;

return (
<section className="space-y-3">
<h3 className="text-[15px] font-medium text-text">Indexed items</h3>
<div className="rounded-md border border-border bg-surface p-6">
<div className="flex items-baseline gap-2">
<div className="font-mono text-[36px] leading-[44px] font-medium tabular-nums text-text">
{formatCount(currentCount)}
</div>
{!isUnlimited ? (
<div className="font-mono text-[20px] leading-[44px] tabular-nums text-text-muted">
/ {formatCount(limit)}
</div>
) : (
<span className="ml-1 inline-flex items-center rounded-sm bg-surface-2 px-2 py-0.5 text-[11px] uppercase tracking-[0.06em] text-text-muted">
Unlimited
</span>
)}
</div>
<p className="mt-2 text-[13px] text-text-muted">
chunks in your search index ·{' '}
<span title="Each item is one chunk in the search index. A typical Notion page is 5–20 chunks; a long PDF is ~150." className="underline decoration-text-subtle decoration-dotted underline-offset-2">
what counts?
</span>
</p>

{!isUnlimited ? (
<div className="mt-5 space-y-2">
<div className="h-1.5 w-full overflow-hidden rounded-sm bg-surface-2">
<div
className="h-full bg-text-subtle"
style={{ width: `${(ratio * 100).toFixed(1)}%` }}
/>
</div>
<div className="flex justify-between text-[12px] tabular-nums text-text-muted">
<span>
{(ratio * 100).toFixed(ratio === 0 || ratio === 1 ? 0 : 1)}% used
</span>
<span>on {currentPlanName}</span>
</div>
</div>
) : null}

{atCap ? (
<div className="mt-5 rounded-md border border-danger/40 bg-[color-mix(in_srgb,var(--danger)_8%,transparent)] px-4 py-3 text-[13px] text-text">
<span className="font-medium">Storage full.</span> New ingestion is
paused; existing items stay queryable.
{suggestedUpgradeSlug ? (
<>
{' '}
<a
href={`/settings/billing?upgrade=${suggestedUpgradeSlug}#plans`}
className="font-medium underline underline-offset-2 hover:no-underline"
>
Upgrade
</a>{' '}
to resume.
</>
) : null}
</div>
) : nearCap ? (
<div className="mt-5 rounded-md border border-warning/40 bg-[color-mix(in_srgb,var(--warning)_8%,transparent)] px-4 py-3 text-[13px] text-text-muted">
Approaching your plan&apos;s limit. Ingestion will pause at the cap.
{suggestedUpgradeSlug ? (
<>
{' '}
<a
href={`/settings/billing?upgrade=${suggestedUpgradeSlug}#plans`}
className="font-medium text-text underline underline-offset-2 hover:no-underline"
>
Upgrade
</a>
.
</>
) : null}
</div>
) : null}
</div>
</section>
);
}
58 changes: 43 additions & 15 deletions apps/web/src/app/(app)/settings/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
listActiveTopupPackages,
recentLedgerActivity,
deriveTrialState,
checkStorageQuota,
type SubscriptionWithPlan,
type PlanRow,
type LedgerActivityRow,
Expand All @@ -20,6 +21,7 @@ import { resolveActiveOrgId } from '@/lib/active-org';
import { BillingDisabled } from './_components/billing-disabled';
import { PlanSummary } from './_components/plan-summary';
import { BalanceCard } from './_components/balance-card';
import { StorageCard } from './_components/storage-card';
import { UsageBreakdown } from './_components/usage-breakdown';
import { PlanGrid } from './_components/plan-grid';
import { TopupCard } from './_components/topup-card';
Expand All @@ -45,21 +47,31 @@ export default async function BillingSettingsPage({
const orgId = resolveActiveOrgId(session);
if (!orgId) redirect('/dashboard');

const [subscription, balance, period, plans, topupPackages, activity, sp, customerRow] =
await Promise.all([
getCurrentSubscription(db, orgId),
getOrgBalance(db, orgId),
getCurrentPeriodUsage(db, orgId),
listPublicPlans(db),
listActiveTopupPackages(db),
recentLedgerActivity(db, orgId, 50),
searchParams,
db
.select({ stripeCustomerId: schema.organizationSubscriptions.stripeCustomerId })
.from(schema.organizationSubscriptions)
.where(eq(schema.organizationSubscriptions.organizationId, orgId))
.limit(1),
]);
const [
subscription,
balance,
period,
plans,
topupPackages,
activity,
sp,
customerRow,
storageDecision,
] = await Promise.all([
getCurrentSubscription(db, orgId),
getOrgBalance(db, orgId),
getCurrentPeriodUsage(db, orgId),
listPublicPlans(db),
listActiveTopupPackages(db),
recentLedgerActivity(db, orgId, 50),
searchParams,
db
.select({ stripeCustomerId: schema.organizationSubscriptions.stripeCustomerId })
.from(schema.organizationSubscriptions)
.where(eq(schema.organizationSubscriptions.organizationId, orgId))
.limit(1),
checkStorageQuota(db, orgId),
]);

const hasStripeCustomer = Boolean(customerRow[0]?.stripeCustomerId);
const checkoutFlash: 'success' | 'cancel' | undefined =
Expand All @@ -83,6 +95,22 @@ export default async function BillingSettingsPage({
monthlyGrant={subscription?.plan.monthlyCredits ?? 0}
debitsThisPeriod={period.total}
/>
<StorageCard
currentCount={storageDecision.currentCount}
limit={storageDecision.limit}
currentPlanName={subscription?.plan.name ?? 'Free'}
suggestedUpgradeSlug={
storageDecision.allowed
? subscription?.plan.slug === 'free'
? 'starter'
: subscription?.plan.slug === 'starter'
? 'team'
: subscription?.plan.slug === 'team'
? 'business'
: null
: storageDecision.suggestedUpgradeSlug
}
/>
<TopupCard packages={topupPackages} />
<UsageBreakdown
llmCredits={period.llmCredits}
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/landing/pricing-band.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ type HostedPlan = {
cadence?: string;
credits: string;
connectors: string;
/** Cap on chunks stored in the search index (one row per embedding
* vector). Indexed-items cap and connector cap are independent levers —
* see packages/billing/src/limits.ts. */
indexedItems: string;
blurb: string;
popular?: boolean;
};
Expand All @@ -25,6 +29,7 @@ const HOSTED_PLANS: HostedPlan[] = [
cadence: '14-day trial',
credits: '250',
connectors: '1 connector',
indexedItems: '10K indexed items',
blurb: 'Kick the tires on the hosted version. No credit card.',
},
{
Expand All @@ -34,6 +39,7 @@ const HOSTED_PLANS: HostedPlan[] = [
cadence: '/mo',
credits: '2,500',
connectors: '5 connectors',
indexedItems: '100K indexed items',
blurb: 'For solo builders and small teams running a handful of agents.',
},
{
Expand All @@ -43,6 +49,7 @@ const HOSTED_PLANS: HostedPlan[] = [
cadence: '/mo',
credits: '20,000',
connectors: 'Unlimited connectors',
indexedItems: '1M indexed items',
blurb: 'For engineering teams in production. Standard sync intervals.',
popular: true,
},
Expand All @@ -53,6 +60,7 @@ const HOSTED_PLANS: HostedPlan[] = [
cadence: '/mo',
credits: '100,000',
connectors: 'Unlimited connectors',
indexedItems: '10M indexed items',
blurb: 'High-volume workloads. Priority sync intervals. Same binary.',
},
];
Expand Down Expand Up @@ -209,6 +217,13 @@ export function PricingBand() {
/>
<span>{plan.connectors}</span>
</li>
<li className="flex gap-2">
<Check
className="h-4 w-4 shrink-0 text-text-subtle"
aria-hidden
/>
<span>{plan.indexedItems}</span>
</li>
</ul>
<Link
href="/sign-in"
Expand Down
1 change: 1 addition & 0 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@holo/connectors": "workspace:*",
"@holo/crypto": "workspace:*",
"@holo/db": "workspace:*",
"@holo/email": "workspace:*",
"@holo/embedder": "workspace:*",
"@holo/env": "workspace:*",
"@holo/errors": "workspace:*",
Expand Down
59 changes: 59 additions & 0 deletions apps/worker/src/queues/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { Module, Logger } from '@nestjs/common';
import { BullModule, Processor, WorkerHost } from '@nestjs/bullmq';
import type { Job } from 'bullmq';
import postgres, { type Sql } from 'postgres';
import { createDb, type DB } from '@holo/db';
import { checkStorageQuota } from '@holo/billing';
import { holoError, ErrorCode } from '@holo/errors';
import { getWorkerPosthog } from '../posthog';
import { QUEUE_NAMES, QUEUE_CONCURRENCY } from './types';
import {
runEmbedJob,
Expand All @@ -12,6 +15,7 @@ import {
import type { EmbedJobPayload } from './embed-insert';

let cachedSql: Sql | null = null;
let cachedDb: DB | null = null;
let cachedEmbedder: EmbedderClient | null = null;

function getSql(): Sql {
Expand All @@ -32,6 +36,20 @@ function getSql(): Sql {
return cachedSql;
}

function getDb(): DB {
if (cachedDb) return cachedDb;
const url = process.env.DATABASE_URL;
if (!url) {
throw holoError({
code: ErrorCode.HOLO_DB_CONNECTION_FAILED,
problem: 'DATABASE_URL is not set',
fix: 'Export DATABASE_URL before starting the worker process.',
});
}
cachedDb = createDb(url);
return cachedDb;
}

function getEmbedder(): EmbedderClient {
if (cachedEmbedder) return cachedEmbedder;
throw holoError({
Expand Down Expand Up @@ -59,6 +77,47 @@ export class EmbedProcessor extends WorkerHost {
private readonly logger = new Logger(EmbedProcessor.name);

async process(job: Job<EmbedJobPayload>): Promise<EmbedJobResult> {
// Defensive secondary cap check. The sync processor already gates with
// `checkStorageQuota(db, orgId)` at run-start, but a single fat batch
// (e.g. a fresh GitHub code sync emitting tens of thousands of chunks)
// can otherwise blast far past the ceiling between gate checks. Ask
// here "can this batch fit?" and short-circuit the whole batch if not —
// we don't insert any chunks rather than partial-fill up to the cap,
// because partial-fill would silently drop the rest of the batch with no
// way to retry just the missing chunks. Cap-induced no-op is logged so
// it's visible in PostHog + worker logs.
const batchSize = job.data.chunks.length;
if (batchSize > 0) {
const storageDecision = await checkStorageQuota(
getDb(),
job.data.organizationId,
batchSize,
);
if (!storageDecision.allowed) {
this.logger.log(
`embed job ${job.id} skipped: storage cap reached `
+ `(${storageDecision.currentCount}/${storageDecision.limit}, +${batchSize} would overflow) `
+ `— upgrade from ${storageDecision.currentPlanSlug}`,
);
getWorkerPosthog().capture({
distinctId: `org:${job.data.organizationId}`,
event: 'holo.storage.cap_reached',
groups: { organization: job.data.organizationId },
properties: {
surface: 'embed',
current_plan: storageDecision.currentPlanSlug,
limit: storageDecision.limit,
current_count: storageDecision.currentCount,
batch_size: batchSize,
},
});
return {
inserted: 0,
perModel: { 'openai-3-small': 0, 'openai-3-large': 0, 'voyage-code-3': 0 },
};
}
}

const result = await runEmbedJob({
payload: job.data,
sql: getSql(),
Expand Down
Loading
Loading