feat(billing): enforce 50 GB premium storage cap (was unlimited)

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 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-06-06 15:07:19 +05:30
parent a69546977c
commit 9dcd0fc854
3 changed files with 31 additions and 19 deletions

View file

@ -11,12 +11,13 @@ export async function GET() {
const familyId = auth.session!.familyId!; const familyId = auth.session!.familyId!;
const info = await getStorageInfo(familyId); const info = await getStorageInfo(familyId);
// Both tiers now have a real cap (free 1 GB / premium 50 GB) — show actuals.
return NextResponse.json({ return NextResponse.json({
usedBytes: info.usedBytes, usedBytes: info.usedBytes,
limitBytes: info.isPaid ? null : info.limitBytes, limitBytes: info.limitBytes,
usedFormatted: formatBytes(info.usedBytes), usedFormatted: formatBytes(info.usedBytes),
limitFormatted: info.isPaid ? "Unlimited" : formatBytes(info.limitBytes), limitFormatted: formatBytes(info.limitBytes),
fraction: info.isPaid ? 0 : info.fraction, fraction: info.fraction,
approaching: info.approaching, approaching: info.approaching,
exceeded: info.exceeded, exceeded: info.exceeded,
isPaid: info.isPaid, isPaid: info.isPaid,

View file

@ -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; if (compact && !info.approaching && !info.exceeded) return null;
const pct = Math.min(info.fraction * 100, 100); const pct = Math.min(info.fraction * 100, 100);
@ -84,7 +85,13 @@ export function StorageMeter({ info: propInfo, className = "", compact = false }
{info.exceeded && ( {info.exceeded && (
<p className="text-xs text-red-600 dark:text-red-400"> <p className="text-xs text-red-600 dark:text-red-400">
New uploads are paused. Your existing memories are safe.{" "} New uploads are paused. Your existing memories are safe.{" "}
{info.isPaid ? (
<>Delete some memories to free up space.</>
) : (
<>
<a href="/settings#upgrade" className="underline font-medium">Upgrade</a> or delete some memories to continue. <a href="/settings#upgrade" className="underline font-medium">Upgrade</a> or delete some memories to continue.
</>
)}
</p> </p>
)} )}
{info.approaching && !info.exceeded && ( {info.approaching && !info.exceeded && (

View file

@ -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_STORAGE_LIMIT_BYTES = 1_073_741_824; // 1 GiB
export const FREE_MEMBER_LIMIT = 2; 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 %). // Storage threshold at which we show a "approaching limit" nudge (80 %).
export const STORAGE_WARN_THRESHOLD = 0.8; export const STORAGE_WARN_THRESHOLD = 0.8;
@ -129,15 +133,15 @@ export async function getStorageInfo(familyId: string): Promise<StorageUsageInfo
`; `;
const paid = isPaidFamily(familyRow?.tier); const paid = isPaidFamily(familyRow?.tier);
const usedBytes = await getFamilyStorageUsage(familyId); const usedBytes = await getFamilyStorageUsage(familyId);
const limitBytes = paid ? Infinity : FREE_STORAGE_LIMIT_BYTES; const limitBytes = paid ? PAID_STORAGE_LIMIT_BYTES : FREE_STORAGE_LIMIT_BYTES;
const fraction = paid ? 0 : usedBytes / FREE_STORAGE_LIMIT_BYTES; const fraction = limitBytes > 0 ? usedBytes / limitBytes : 0;
return { return {
usedBytes, usedBytes,
limitBytes, limitBytes,
fraction, fraction,
approaching: !paid && fraction >= STORAGE_WARN_THRESHOLD && fraction < 1, approaching: fraction >= STORAGE_WARN_THRESHOLD && fraction < 1,
exceeded: !paid && fraction >= 1, exceeded: fraction >= 1,
isPaid: paid, isPaid: paid,
}; };
} }
@ -155,12 +159,8 @@ export async function checkStorageQuota(
SELECT tier FROM families WHERE id = ${familyId} LIMIT 1 SELECT tier FROM families WHERE id = ${familyId} LIMIT 1
`; `;
if (isPaidFamily(familyRow?.tier)) { const paid = isPaidFamily(familyRow?.tier);
const usedBytes = await getFamilyStorageUsage(familyId); const limitBytes = paid ? PAID_STORAGE_LIMIT_BYTES : FREE_STORAGE_LIMIT_BYTES;
return { allowed: true, usedBytes, limitBytes: Infinity };
}
const limitBytes = FREE_STORAGE_LIMIT_BYTES;
const usedBytes = await getFamilyStorageUsage(familyId); const usedBytes = await getFamilyStorageUsage(familyId);
if (wouldExceedQuota(usedBytes, declaredBytes, limitBytes)) { if (wouldExceedQuota(usedBytes, declaredBytes, limitBytes)) {
@ -169,7 +169,9 @@ export async function checkStorageQuota(
reason: "storage_quota_exceeded", reason: "storage_quota_exceeded",
usedBytes, usedBytes,
limitBytes, 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} 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 // Re-derive usage with the updated size
const usedBytes = await getFamilyStorageUsage(familyId); const usedBytes = await getFamilyStorageUsage(familyId);
if (usedBytes > FREE_STORAGE_LIMIT_BYTES) { if (usedBytes > limitBytes) {
return { return {
keep: false, 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.`,
}; };
} }