feat(billing): hard storage caps per plan + transactional email pipeline - #223
Merged
Conversation
Closes the downgrade-and-keep loophole: a customer can no longer ingest millions of chunks on Team, drop to Free, and keep the indexed corpus. Adds a hard `maxStoredArtifacts` ceiling per plan. New ingestion is paused at the cap; existing chunks remain queryable. The per-chunk credit debit (PR 1) and connector cap (existing) are unchanged. Caps (chunks = rows in the `chunks` table, one per embedding vector): | Plan | maxConnectors | maxStoredArtifacts | |------------|--------------:|-------------------:| | Free | 1 | 10,000 | | Starter | 5 | 100,000 | | Team | unlimited | 1,000,000 | | Business | unlimited | 10,000,000 | | Enterprise | unlimited | unlimited | Backend: - packages/billing: `checkStorageQuota(db, orgId, deltaCount=0)` mirrors the existing `canAddConnector` / `checkCreditPool` shape. Returns the current count, the plan's limit, and a suggested upgrade slug on deny. Uses an index-only scan on `chunks_org_idx`; no caching needed. - packages/db/src/schema/billing.ts: extend `features` JSONB type with `maxStoredArtifacts: number | null` (already-JSONB so no DDL). - Migration 0067_storage_caps.sql: pure UPDATE seeding the 5 plans. - apps/worker sync-processor-base.ts: gate at sync start (deltaCount=0), return `skipReason: 'storage_cap_reached'`, emit PostHog event. - apps/worker embed.ts: secondary defensive check at the batch level (deltaCount=batch.length) so a single fat sync (e.g. fresh GitHub code ingest emitting 10K+ chunks) can't blast past the ceiling between gate ticks. Whole batch is skipped to avoid silent partial-fill. UI (/settings/billing): - New `StorageCard` component alongside `BalanceCard`. Shows X/Y count, progress bar in neutral fill (DESIGN.md: single accent per page is owned by the credit balance bar), warning banner ≥90%, error banner + "View plans" CTA at 100%. - `PlanGrid` tiles surface the maxStoredArtifacts line. New package @holo/email + React Email + Resend: - `sendEmail` / `sendIdempotent` exported from @holo/email. - React Email templates as .tsx. First template: `StorageCapReached` (light surfaces, single accent CTA, matches the inline-HTML auth templates). - `sendStorageCapReachedEmail` wrapper hides the JSX from the worker so consumers don't need JSX configured (worker's tsconfig gains `jsx: react-jsx` only to compile the template). - Schema `email_log` table for at-least-once-delivery dedup; PK on `idempotency_key`. Failure rolls back the log row so retry is possible. - Migration 0068_email_log.sql. - Auth's existing OTP/invitation emails are NOT migrated — they still use the inline-HTML pattern. New transactional emails should use the React Email path going forward. Wiring: - When sync-processor-base trips the storage cap, it fire-and-forget emails the org owner. Idempotency key `storage_cap_reached:<org>:<period_start>` so an org over cap for weeks gets one email per billing period, not one per blocked sync tick. The launch backfill happens naturally — the first sync tick after deploy for an over-cap org triggers the notification; no separate one-shot script needed. Verification (next steps after merge): - `pnpm db:migrate` lands both new migrations cleanly. - Force an org over cap → next sync produces `skipReason='storage_cap_reached'` in `sync_runs`, an `email_log` row appears, the owner receives the email (test mode: EMAIL_PROVIDER=console; prod: EMAIL_PROVIDER=resend). - Upgrade lifts the cap; next sync goes green. - CE bypass: HOLO_BILLING_ENABLED=false → gates short-circuit. https://claude.ai/code/session_01Mvr1Stxz7czdDcyB5XsjHD
The /settings/billing PlanGrid already shows maxStoredArtifacts per tile, but the public pricing page didn't. Prospective customers should see all three commercial levers (credits / month, connectors, indexed items) before they sign up. - apps/web/src/components/landing/pricing-band.tsx: add `indexedItems` row per plan tile (10K / 100K / 1M / 10M, matching the seeded caps). No backend change — landing-page plans are hard-coded, not DB-driven. https://claude.ai/code/session_01Mvr1Stxz7czdDcyB5XsjHD
6 tasks
maakle
added a commit
that referenced
this pull request
May 21, 2026
#224) 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 Co-authored-by: Claude <noreply@anthropic.com>
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
Closes the downgrade-and-keep loophole: a customer can no longer ingest millions of chunks on Team, drop to Free, and keep enjoying the indexed corpus essentially rent-free. Adds a hard
maxStoredArtifactsceiling per plan. New ingestion pauses at the cap; existing chunks remain queryable.Also adds the missing transactional email infrastructure (React Email + Resend, with idempotent send) and uses it to notify org owners the first time they hit the cap each period.
Pricing model after this PR
Retrieval is free at the retrieval layer; it's metered indirectly via the LLM token cost of the chat turn that triggers it.
Cap values
"1 artifact" = 1 row in
chunks(one embedding vector). UI labels this "indexed items" with a "what counts?" tooltip — a Slack message ≈ 1 chunk, a Notion page ≈ 5–20, a long PDF ≈ 150.Backend
packages/billing/src/limits.ts— newcheckStorageQuota(db, orgId, deltaCount=0). Mirrors the shape ofcanAddConnector/checkCreditPool. Returns{ allowed: true, currentCount, limit }or{ allowed: false, reason: 'storage_cap', currentCount, limit, currentPlanSlug, currentPlanName, suggestedUpgradeSlug }. Index-only scan againstchunks_org_idx— fast even at 10M rows. No caching needed.packages/db/src/schema/billing.ts— extendfeaturesJSONB type withmaxStoredArtifacts: number | null. JSONB column, so no DDL.0067_storage_caps.sql— pure UPDATE seeding the 5 plans.apps/worker/src/queues/sync-processor-base.ts— gate at sync start. ReturnsskipReason: 'storage_cap_reached', emitsholo.storage.cap_reachedPostHog event, fire-and-forget owner email.apps/worker/src/queues/embed.ts— defensive secondary check at batch level. A fresh GitHub code sync can emit 10K+ chunks in a single embed batch; this prevents a single fat batch from blasting past the cap between sync-start gate ticks. Whole batch is skipped (no silent partial-fill).UI
StorageCardon/settings/billingalongsideBalanceCard. ShowsX / Ycount, neutral-fill progress bar (DESIGN.md: single accent per page is owned by the existing credit balance bar), warning banner ≥90%, error banner + "View plans" CTA at 100%.PlanGridtiles surface themaxStoredArtifactsline.New package
@holo/emailThe repo had Resend wiring only for auth's OTP/invitation flow. This adds a proper general transactional pipeline.
sendEmail/sendIdempotent— low-level + dedup-on-key wrapper.@react-email/render+@react-email/components).StorageCapReached.tsx— light surfaces, single accent CTA, matches the visual posture ofpackages/auth/src/email-templates.ts. Plain-text fallback auto-extracted.sendStorageCapReachedEmailwrapper hides the JSX from the worker so consumers don't need JSX configured. (Worker's tsconfig gainsjsx: react-jsxonly because tsc transitively parses the.tsx.)email_logtable for at-least-once-delivery dedup; PK onidempotency_key. Failure rolls back the log row so retry is possible.0068_email_log.sql.Auth's existing OTP/invitation templates are NOT migrated to React Email in this PR (scope discipline — they work, and breaking them breaks sign-in). New transactional emails should use the React Email path going forward.
Wiring the email
When sync-processor-base trips the storage cap, it looks up the org owner via the
membertable (role='owner', earliest bymember.createdAt), constructs the upgrade URL fromBETTER_AUTH_URL, and fires the email. Idempotency key:storage_cap_reached:<org_id>:<period_start_iso>.So an org sitting over cap for weeks gets one email per billing period (not one per blocked 6h sync tick). The "launch backfill" happens naturally — the first sync tick after deploy for any over-cap org triggers the notification; no separate one-shot script needed.
Files
New:
packages/email/(entire package)packages/db/src/schema/email.tspackages/db/migrations/0067_storage_caps.sqlpackages/db/migrations/0068_email_log.sqlapps/web/src/app/(app)/settings/billing/_components/storage-card.tsxpackages/billing/test/storage-quota.test.tsModified:
packages/billing/src/limits.ts— addcheckStorageQuotapackages/billing/src/plans.ts,packages/billing/src/index.tspackages/db/src/schema/billing.ts,packages/db/src/schema/index.tsapps/worker/src/queues/sync-processor-base.ts— gate + emailapps/worker/src/queues/embed.ts— secondary cap checkapps/worker/tsconfig.json— enable JSX parsing for transitively-imported.tsxapps/web/src/app/(app)/settings/billing/page.tsxapps/web/src/app/(app)/settings/billing/_components/plan-grid.tsxOut of scope
Test plan
pnpm installresolves React Email depspnpm -r typecheck— cleanpnpm exec eslint .— cleanpnpm --filter @holo/billing test— 27/27 pass (incl. 9 new storage-quota tests covering boundary, unlimited, missing-feature-key, CE bypass, plan-tier suggestion)pnpm --filter @holo/email test— 4/4 pass (render JSX → HTML+text, idempotent dedup, transport-failure rollback)pnpm --filter @holo/worker test— 124/124 passsync_runshasskipReason='storage_cap_reached',email_loghas a row, owner inbox receives the email (verify withEMAIL_PROVIDER=consolefirst, thenresend)HOLO_BILLING_ENABLED=falseshort-circuits both gateshttps://claude.ai/code/session_01Mvr1Stxz7czdDcyB5XsjHD
Generated by Claude Code