From 9dcd0fc8548ab25252776cb697a5c91adb332576 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 6 Jun 2026 15:07:19 +0530 Subject: [PATCH] feat(billing): enforce 50 GB premium storage cap (was unlimited) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Premium granted unlimited storage because quota.ts treated any paid tier as Infinity. Plan sells 50 GB — now enforced: - quota.ts: PAID_STORAGE_LIMIT_BYTES = 50 GiB; getStorageInfo/checkStorageQuota/ reconcileActualSize use it for paid families instead of Infinity. Meter/ warn/exceeded now computed for both tiers. - storage-usage route: return real limit for paid (was forcing "Unlimited"/null) - StorageMeter: show meter for premium too (had `isPaid -> return null`); exceeded message drops the Upgrade link for premium (delete-only) Free 1 GB unchanged. Premium = 50 GB / 6 members / 3 babies, all enforced. Co-Authored-By: Claude Opus 4.8 --- src/app/api/storage-usage/route.ts | 7 ++++--- src/components/StorageMeter.tsx | 11 ++++++++-- src/lib/quota.ts | 32 +++++++++++++++++------------- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/app/api/storage-usage/route.ts b/src/app/api/storage-usage/route.ts index 56f0bcb..7a1e52b 100644 --- a/src/app/api/storage-usage/route.ts +++ b/src/app/api/storage-usage/route.ts @@ -11,12 +11,13 @@ export async function GET() { const familyId = auth.session!.familyId!; const info = await getStorageInfo(familyId); + // Both tiers now have a real cap (free 1 GB / premium 50 GB) — show actuals. return NextResponse.json({ usedBytes: info.usedBytes, - limitBytes: info.isPaid ? null : info.limitBytes, + limitBytes: info.limitBytes, usedFormatted: formatBytes(info.usedBytes), - limitFormatted: info.isPaid ? "Unlimited" : formatBytes(info.limitBytes), - fraction: info.isPaid ? 0 : info.fraction, + limitFormatted: formatBytes(info.limitBytes), + fraction: info.fraction, approaching: info.approaching, exceeded: info.exceeded, isPaid: info.isPaid, diff --git a/src/components/StorageMeter.tsx b/src/components/StorageMeter.tsx index 84933a0..0a9e2f2 100644 --- a/src/components/StorageMeter.tsx +++ b/src/components/StorageMeter.tsx @@ -40,7 +40,8 @@ export function StorageMeter({ info: propInfo, className = "", compact = false } ); } - if (!info || info.isPaid) return null; + // Show the meter for both free and premium — both now have a real cap. + if (!info) return null; if (compact && !info.approaching && !info.exceeded) return null; const pct = Math.min(info.fraction * 100, 100); @@ -84,7 +85,13 @@ export function StorageMeter({ info: propInfo, className = "", compact = false } {info.exceeded && (

New uploads are paused. Your existing memories are safe.{" "} - Upgrade or delete some memories to continue. + {info.isPaid ? ( + <>Delete some memories to free up space. + ) : ( + <> + Upgrade or delete some memories to continue. + + )}

)} {info.approaching && !info.exceeded && ( diff --git a/src/lib/quota.ts b/src/lib/quota.ts index 0f6399d..3548df7 100644 --- a/src/lib/quota.ts +++ b/src/lib/quota.ts @@ -21,6 +21,10 @@ export { formatBytes } from "@/lib/format-bytes"; export const FREE_STORAGE_LIMIT_BYTES = 1_073_741_824; // 1 GiB export const FREE_MEMBER_LIMIT = 2; +// Paid (premium) storage cap. Matches the seeded plan's storage grant (50 GiB). +// Kept here (not Infinity) so premium also has a ceiling and the meter shows X/50GB. +export const PAID_STORAGE_LIMIT_BYTES = 50 * 1_073_741_824; // 50 GiB + // Storage threshold at which we show a "approaching limit" nudge (80 %). export const STORAGE_WARN_THRESHOLD = 0.8; @@ -129,15 +133,15 @@ export async function getStorageInfo(familyId: string): Promise 0 ? usedBytes / limitBytes : 0; return { usedBytes, limitBytes, fraction, - approaching: !paid && fraction >= STORAGE_WARN_THRESHOLD && fraction < 1, - exceeded: !paid && fraction >= 1, + approaching: fraction >= STORAGE_WARN_THRESHOLD && fraction < 1, + exceeded: fraction >= 1, isPaid: paid, }; } @@ -155,12 +159,8 @@ export async function checkStorageQuota( SELECT tier FROM families WHERE id = ${familyId} LIMIT 1 `; - if (isPaidFamily(familyRow?.tier)) { - const usedBytes = await getFamilyStorageUsage(familyId); - return { allowed: true, usedBytes, limitBytes: Infinity }; - } - - const limitBytes = FREE_STORAGE_LIMIT_BYTES; + const paid = isPaidFamily(familyRow?.tier); + const limitBytes = paid ? PAID_STORAGE_LIMIT_BYTES : FREE_STORAGE_LIMIT_BYTES; const usedBytes = await getFamilyStorageUsage(familyId); if (wouldExceedQuota(usedBytes, declaredBytes, limitBytes)) { @@ -169,7 +169,9 @@ export async function checkStorageQuota( reason: "storage_quota_exceeded", usedBytes, limitBytes, - message: `Storage quota exceeded. You have used ${formatBytes(usedBytes)} of your ${formatBytes(limitBytes)} limit. Delete some memories or upgrade to continue uploading.`, + message: paid + ? `Storage limit reached. You have used ${formatBytes(usedBytes)} of your ${formatBytes(limitBytes)} premium storage.` + : `Storage quota exceeded. You have used ${formatBytes(usedBytes)} of your ${formatBytes(limitBytes)} limit. Delete some memories or upgrade to continue uploading.`, }; } @@ -194,14 +196,16 @@ export async function reconcileActualSize( UPDATE memories SET size_bytes = ${actualBytes} WHERE id = ${memoryId} AND family_id = ${familyId} `; - if (isPaidFamily(familyRow?.tier)) return { keep: true }; + const limitBytes = isPaidFamily(familyRow?.tier) + ? PAID_STORAGE_LIMIT_BYTES + : FREE_STORAGE_LIMIT_BYTES; // Re-derive usage with the updated size const usedBytes = await getFamilyStorageUsage(familyId); - if (usedBytes > FREE_STORAGE_LIMIT_BYTES) { + if (usedBytes > limitBytes) { return { keep: false, - reason: `Actual file size (${formatBytes(actualBytes)}) pushed family over the ${formatBytes(FREE_STORAGE_LIMIT_BYTES)} quota.`, + reason: `Actual file size (${formatBytes(actualBytes)}) pushed family over the ${formatBytes(limitBytes)} quota.`, }; }