feat: storage and token limiting

This commit is contained in:
Vasily Zubarev
2025-04-21 13:50:45 +02:00
parent 62bad46e58
commit 73e83221b8
25 changed files with 232 additions and 65 deletions

View File

@@ -11,6 +11,10 @@ OPENAI_API_KEY=""
# Auth Config # Auth Config
BETTER_AUTH_SECRET="random-secret-key" # please use any long random string here BETTER_AUTH_SECRET="random-secret-key" # please use any long random string here
# Stripe Configuration
STRIPE_SECRET_KEY=""
STRIPE_WEBHOOK_SECRET=""
# Resend Configuration (optional, use if you want to send emails) # Resend Configuration (optional, use if you want to send emails)
RESEND_API_KEY="" RESEND_API_KEY=""
RESEND_AUDIENCE_ID="" RESEND_AUDIENCE_ID=""

View File

@@ -4,12 +4,17 @@ import { ActionState } from "@/lib/actions"
import OpenAI from "openai" import OpenAI from "openai"
import { AnalyzeAttachment } from "./attachments" import { AnalyzeAttachment } from "./attachments"
export type AnalysisResult = {
output: Record<string, string>
tokensUsed: number
}
export async function analyzeTransaction( export async function analyzeTransaction(
prompt: string, prompt: string,
schema: Record<string, unknown>, schema: Record<string, unknown>,
attachments: AnalyzeAttachment[], attachments: AnalyzeAttachment[],
apiKey: string apiKey: string
): Promise<ActionState<Record<string, string>>> { ): Promise<ActionState<AnalysisResult>> {
const openai = new OpenAI({ const openai = new OpenAI({
apiKey, apiKey,
}) })
@@ -19,7 +24,7 @@ export async function analyzeTransaction(
try { try {
const response = await openai.responses.create({ const response = await openai.responses.create({
model: "gpt-4o-mini-2024-07-18", model: "gpt-4o-mini",
input: [ input: [
{ {
role: "user", role: "user",
@@ -48,7 +53,7 @@ export async function analyzeTransaction(
console.log("ChatGPT tokens used:", response.usage) console.log("ChatGPT tokens used:", response.usage)
const result = JSON.parse(response.output_text) const result = JSON.parse(response.output_text)
return { success: true, data: result } return { success: true, data: { output: result, tokensUsed: response.usage?.total_tokens || 0 } }
} catch (error) { } catch (error) {
console.error("AI Analysis error:", error) console.error("AI Analysis error:", error)
return { return {

View File

@@ -2,7 +2,7 @@
import { ActionState } from "@/lib/actions" import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { getDirectorySize, getUserUploadsDirectory, unsortedFilePath } from "@/lib/files" import { getDirectorySize, getUserUploadsDirectory, isEnoughStorageToUploadFile, unsortedFilePath } from "@/lib/files"
import { createFile } from "@/models/files" import { createFile } from "@/models/files"
import { updateUser } from "@/models/users" import { updateUser } from "@/models/users"
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
@@ -24,6 +24,10 @@ export async function uploadFilesAction(formData: FormData): Promise<ActionState
return { success: false, error: "Invalid file" } return { success: false, error: "Invalid file" }
} }
if (!isEnoughStorageToUploadFile(user, file.size)) {
return { success: false, error: `Insufficient storage to upload this file: ${file.name}` }
}
// Save file to filesystem // Save file to filesystem
const fileUuid = randomUUID() const fileUuid = randomUUID()
const relativeFilePath = await unsortedFilePath(fileUuid, file.name) const relativeFilePath = await unsortedFilePath(fileUuid, file.name)

View File

@@ -38,6 +38,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
email: user.email, email: user.email,
avatar: user.avatar || undefined, avatar: user.avatar || undefined,
storageUsed: user.storageUsed || 0, storageUsed: user.storageUsed || 0,
storageLimit: user.storageLimit || -1,
tokenBalance: user.tokenBalance || 0,
} }
return ( return (

View File

@@ -1,4 +1,4 @@
import ProfileSettingsForm from "@/components/settings/profile-settings-form copy" import ProfileSettingsForm from "@/components/settings/profile-settings-form"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
export default async function ProfileSettingsPage() { export default async function ProfileSettingsPage() {

View File

@@ -3,7 +3,12 @@
import { transactionFormSchema } from "@/forms/transactions" import { transactionFormSchema } from "@/forms/transactions"
import { ActionState } from "@/lib/actions" import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { getDirectorySize, getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files" import {
getDirectorySize,
getTransactionFileUploadPath,
getUserUploadsDirectory,
isEnoughStorageToUploadFile,
} from "@/lib/files"
import { updateField } from "@/models/fields" import { updateField } from "@/models/fields"
import { createFile, deleteFile } from "@/models/files" import { createFile, deleteFile } from "@/models/files"
import { import {
@@ -133,6 +138,11 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
const userUploadsDirectory = await getUserUploadsDirectory(user) const userUploadsDirectory = await getUserUploadsDirectory(user)
const totalFileSize = files.reduce((acc, file) => acc + file.size, 0)
if (!isEnoughStorageToUploadFile(user, totalFileSize)) {
return { success: false, error: `Insufficient storage to upload new files` }
}
const fileRecords = await Promise.all( const fileRecords = await Promise.all(
files.map(async (file) => { files.map(async (file) => {
const fileUuid = randomUUID() const fileUuid = randomUUID()

View File

@@ -1,6 +1,6 @@
"use server" "use server"
import { analyzeTransaction } from "@/ai/analyze" import { AnalysisResult, analyzeTransaction } from "@/ai/analyze"
import { AnalyzeAttachment, loadAttachmentsForAI } from "@/ai/attachments" import { AnalyzeAttachment, loadAttachmentsForAI } from "@/ai/attachments"
import { buildLLMPrompt } from "@/ai/prompt" import { buildLLMPrompt } from "@/ai/prompt"
import { fieldsToJsonSchema } from "@/ai/schema" import { fieldsToJsonSchema } from "@/ai/schema"
@@ -12,6 +12,7 @@ import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/fil
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults" import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
import { deleteFile, getFileById, updateFile } from "@/models/files" import { deleteFile, getFileById, updateFile } from "@/models/files"
import { createTransaction, updateTransactionFiles } from "@/models/transactions" import { createTransaction, updateTransactionFiles } from "@/models/transactions"
import { updateUser } from "@/models/users"
import { Category, Field, File, Project, Transaction } from "@prisma/client" import { Category, Field, File, Project, Transaction } from "@prisma/client"
import { mkdir, rename } from "fs/promises" import { mkdir, rename } from "fs/promises"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
@@ -23,7 +24,7 @@ export async function analyzeFileAction(
fields: Field[], fields: Field[],
categories: Category[], categories: Category[],
projects: Project[] projects: Project[]
): Promise<ActionState<Record<string, string>>> { ): Promise<ActionState<AnalysisResult>> {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!file || file.userId !== user.id) { if (!file || file.userId !== user.id) {
@@ -35,6 +36,10 @@ export async function analyzeFileAction(
return { success: false, error: "OpenAI API key is not set" } return { success: false, error: "OpenAI API key is not set" }
} }
if (!config.selfHosted.isEnabled && user.tokenBalance < 0) {
return { success: false, error: "You used all your AI tokens, please upgrade your account" }
}
let attachments: AnalyzeAttachment[] = [] let attachments: AnalyzeAttachment[] = []
try { try {
attachments = await loadAttachmentsForAI(user, file) attachments = await loadAttachmentsForAI(user, file)
@@ -56,6 +61,10 @@ export async function analyzeFileAction(
console.log("Analysis results:", results) console.log("Analysis results:", results)
if (results.data?.tokensUsed && results.data.tokensUsed > 0) {
await updateUser(user.id, { tokenBalance: { decrement: results.data.tokensUsed } })
}
return results return results
} }

View File

@@ -1,7 +1,9 @@
"use client" "use client"
import { Button } from "@/components/ui/button"
import * as Sentry from "@sentry/nextjs" import * as Sentry from "@sentry/nextjs"
import NextError from "next/error" import { Angry } from "lucide-react"
import Link from "next/link"
import { useEffect } from "react" import { useEffect } from "react"
export default function GlobalError({ error }: { error: Error }) { export default function GlobalError({ error }: { error: Error }) {
@@ -12,11 +14,20 @@ export default function GlobalError({ error }: { error: Error }) {
return ( return (
<html> <html>
<body> <body>
{/* `NextError` is the default Next.js error page component. Its type <div className="min-h-screen flex flex-col items-center justify-center bg-background p-4">
definition requires a `statusCode` prop. However, since the App Router <div className="text-center space-y-4">
does not expose status codes for errors, we simply pass 0 to render a <Angry className="w-24 h-24 text-destructive mx-auto" />
generic error message. */} <h1 className="text-4xl font-bold text-foreground">Oops! Something went wrong</h1>
<NextError statusCode={0} /> <p className="text-muted-foreground max-w-md mx-auto">
We apologize for the inconvenience. Our team has been notified and is working to fix the issue.
</p>
<div className="pt-4">
<Button asChild>
<Link href="/">Go Home</Link>
</Button>
</div>
</div>
</div>
</body> </body>
</html> </html>
) )

View File

@@ -17,7 +17,12 @@ export const FormSelectCurrency = ({
hideIfEmpty?: boolean hideIfEmpty?: boolean
} & SelectProps) => { } & SelectProps) => {
const items = useMemo( const items = useMemo(
() => currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` })), () =>
currencies.map((currency) => ({
code: currency.code,
name: `${currency.code}`,
badge: currency.name,
})),
[currencies] [currencies]
) )
return ( return (

View File

@@ -9,10 +9,10 @@ export const FormSelectType = ({
...props ...props
}: { title: string; emptyValue?: string; placeholder?: string; hideIfEmpty?: boolean } & SelectProps) => { }: { title: string; emptyValue?: string; placeholder?: string; hideIfEmpty?: boolean } & SelectProps) => {
const items = [ const items = [
{ code: "expense", name: "Expense" }, { code: "expense", name: "Expense", badge: "↓" },
{ code: "income", name: "Income" }, { code: "income", name: "Income", badge: "↑" },
{ code: "pending", name: "Pending" }, { code: "pending", name: "Pending", badge: "⏲︎" },
{ code: "other", name: "Other" }, { code: "other", name: "Other", badge: "?" },
] ]
return ( return (

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar" import { Calendar } from "@/components/ui/calendar"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -57,7 +58,7 @@ export const FormSelect = ({
...props ...props
}: { }: {
title: string title: string
items: Array<{ code: string; name: string; color?: string }> items: Array<{ code: string; name: string; color?: string; badge?: string }>
emptyValue?: string emptyValue?: string
placeholder?: string placeholder?: string
hideIfEmpty?: boolean hideIfEmpty?: boolean
@@ -78,7 +79,10 @@ export const FormSelect = ({
{items.map((item) => ( {items.map((item) => (
<SelectItem key={item.code} value={item.code}> <SelectItem key={item.code} value={item.code}>
<div className="flex items-center gap-2 text-base pr-2"> <div className="flex items-center gap-2 text-base pr-2">
{item.color && <div className="w-2 h-2 rounded-full" style={{ backgroundColor: item.color }} />} {item.badge && <Badge className="px-2">{item.badge}</Badge>}
{!item.badge && item.color && (
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: item.color }} />
)}
{item.name} {item.name}
</div> </div>
</SelectItem> </SelectItem>

View File

@@ -1,33 +0,0 @@
"use client"
import { saveProfileAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { User } from "@prisma/client"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
export default function ProfileSettingsForm({ user }: { user: User }) {
const [saveState, saveAction, pending] = useActionState(saveProfileAction, null)
return (
<form action={saveAction} className="space-y-4">
<FormInput title="Your Name" name="name" defaultValue={user.name || ""} />
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save"}
</Button>
{saveState?.success && (
<p className="text-green-500 flex flex-row items-center gap-2">
<CircleCheckBig />
Saved!
</p>
)}
</div>
{saveState?.error && <FormError>{saveState.error}</FormError>}
</form>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import { saveProfileAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { formatBytes, formatNumber } from "@/lib/utils"
import { User } from "@prisma/client"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
export default function ProfileSettingsForm({ user }: { user: User }) {
const [saveState, saveAction, pending] = useActionState(saveProfileAction, null)
return (
<div>
<form action={saveAction} className="space-y-4">
<FormInput title="Your Name" name="name" defaultValue={user.name || ""} />
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save"}
</Button>
{saveState?.success && (
<p className="text-green-500 flex flex-row items-center gap-2">
<CircleCheckBig />
Saved!
</p>
)}
</div>
{saveState?.error && <FormError>{saveState.error}</FormError>}
</form>
<Card className="mt-4 p-4">
<p>
Storage: {user.storageUsed ? formatBytes(user.storageUsed) : "N/A"} /{" "}
{user.storageLimit && user.storageLimit > 0 ? formatBytes(user.storageLimit) : "Unlimited"}
</p>
<p>Tokens Balance: {user.tokenBalance ? formatNumber(user.tokenBalance) : "N/A"}</p>
</Card>
</div>
)
}

View File

@@ -106,7 +106,7 @@ export default function AnalyzeForm({
setAnalyzeError(results.error ? results.error : "Something went wrong...") setAnalyzeError(results.error ? results.error : "Something went wrong...")
} else { } else {
const nonEmptyFields = Object.fromEntries( const nonEmptyFields = Object.fromEntries(
Object.entries(results.data || {}).filter( Object.entries(results.data?.output || {}).filter(
([_, value]) => value !== null && value !== undefined && value !== "" ([_, value]) => value !== null && value !== undefined && value !== ""
) )
) )

View File

@@ -1,6 +1,12 @@
import { stripeClient } from "@better-auth/stripe/client"
import { createAuthClient } from "better-auth/client" import { createAuthClient } from "better-auth/client"
import { emailOTPClient } from "better-auth/client/plugins" import { emailOTPClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [emailOTPClient()], plugins: [
emailOTPClient(),
stripeClient({
subscription: true,
}),
],
}) })

View File

@@ -1,6 +1,7 @@
import config from "@/lib/config" import config from "@/lib/config"
import { createUserDefaults } from "@/models/defaults" import { createUserDefaults } from "@/models/defaults"
import { getSelfHostedUser, getUserByEmail } from "@/models/users" import { getSelfHostedUser, getUserByEmail } from "@/models/users"
import { stripe } from "@better-auth/stripe"
import { User } from "@prisma/client" import { User } from "@prisma/client"
import { betterAuth } from "better-auth" import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma" import { prismaAdapter } from "better-auth/adapters/prisma"
@@ -11,6 +12,7 @@ import { headers } from "next/headers"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { prisma } from "./db" import { prisma } from "./db"
import { resend, sendOTPCodeEmail } from "./email" import { resend, sendOTPCodeEmail } from "./email"
import { isStripeEnabled, stripeClient } from "./stripe"
export type UserProfile = { export type UserProfile = {
id: string id: string
@@ -18,6 +20,7 @@ export type UserProfile = {
email: string email: string
avatar?: string avatar?: string
storageUsed?: number storageUsed?: number
tokenBalance?: number
} }
export const auth = betterAuth({ export const auth = betterAuth({
@@ -65,6 +68,13 @@ export const auth = betterAuth({
await sendOTPCodeEmail({ email, otp }) await sendOTPCodeEmail({ email, otp })
}, },
}), }),
isStripeEnabled(stripeClient)
? stripe({
stripeClient: stripeClient!,
stripeWebhookSecret: config.stripe.webhookSecret,
createCustomerOnSignUp: true,
})
: { id: "stripe", endpoints: {} },
nextCookies(), // make sure this is the last plugin in the array nextCookies(), // make sure this is the last plugin in the array
], ],
}) })

View File

@@ -13,6 +13,8 @@ const envSchema = z.object({
RESEND_API_KEY: z.string().default("please-set-your-resend-api-key-here"), RESEND_API_KEY: z.string().default("please-set-your-resend-api-key-here"),
RESEND_FROM_EMAIL: z.string().default("TaxHacker <user@localhost>"), RESEND_FROM_EMAIL: z.string().default("TaxHacker <user@localhost>"),
RESEND_AUDIENCE_ID: z.string().default(""), RESEND_AUDIENCE_ID: z.string().default(""),
STRIPE_SECRET_KEY: z.string().default(""),
STRIPE_WEBHOOK_SECRET: z.string().default(""),
}) })
const env = envSchema.parse(process.env) const env = envSchema.parse(process.env)
@@ -40,6 +42,10 @@ const config = {
loginUrl: "/enter", loginUrl: "/enter",
disableSignup: env.DISABLE_SIGNUP === "true" || env.SELF_HOSTED_MODE === "true", disableSignup: env.DISABLE_SIGNUP === "true" || env.SELF_HOSTED_MODE === "true",
}, },
stripe: {
secretKey: env.STRIPE_SECRET_KEY,
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
},
email: { email: {
apiKey: env.RESEND_API_KEY, apiKey: env.RESEND_API_KEY,
from: env.RESEND_FROM_EMAIL, from: env.RESEND_FROM_EMAIL,

View File

@@ -1,6 +1,7 @@
import { File, Transaction, User } from "@prisma/client" import { File, Transaction, User } from "@prisma/client"
import { access, constants, readdir, stat } from "fs/promises" import { access, constants, readdir, stat } from "fs/promises"
import path from "path" import path from "path"
import config from "./config"
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads") export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
export const FILE_UNSORTED_DIRECTORY_NAME = "unsorted" export const FILE_UNSORTED_DIRECTORY_NAME = "unsorted"
@@ -70,3 +71,10 @@ export async function getDirectorySize(directoryPath: string) {
await calculateSize(directoryPath) await calculateSize(directoryPath)
return totalSize return totalSize
} }
export function isEnoughStorageToUploadFile(user: User, fileSize: number) {
if (config.selfHosted.isEnabled || user.storageLimit < 0) {
return true
}
return user.storageUsed + fileSize <= user.storageLimit
}

13
lib/stripe.ts Normal file
View File

@@ -0,0 +1,13 @@
import Stripe from "stripe"
import config from "./config"
export const stripeClient: Stripe | null = config.stripe.secretKey
? new Stripe(config.stripe.secretKey, {
apiVersion: "2025-03-31.basil",
})
: null
// Type guard to check if Stripe is initialized
export const isStripeEnabled = (client: Stripe | null): client is Stripe => {
return client !== null
}

View File

@@ -2,12 +2,14 @@ import { clsx, type ClassValue } from "clsx"
import slugify from "slugify" import slugify from "slugify"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
const LOCALE = "en-US"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function formatCurrency(total: number, currency: string) { export function formatCurrency(total: number, currency: string) {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat(LOCALE, {
style: "currency", style: "currency",
currency: currency, currency: currency,
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -28,6 +30,12 @@ export function formatBytes(bytes: number) {
return `${parseFloat(value.toFixed(2))} ${sizes[i]}` return `${parseFloat(value.toFixed(2))} ${sizes[i]}`
} }
export function formatNumber(number: number) {
return new Intl.NumberFormat(LOCALE, {
useGrouping: true,
}).format(number)
}
export function codeFromName(name: string, maxLength: number = 16) { export function codeFromName(name: string, maxLength: number = 16) {
const code = slugify(name, { const code = slugify(name, {
replacement: "_", replacement: "_",

46
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "taxhacker", "name": "taxhacker",
"version": "0.5.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"@better-auth/stripe": "^1.2.5",
"@fast-csv/format": "^5.0.2", "@fast-csv/format": "^5.0.2",
"@fast-csv/parse": "^5.0.2", "@fast-csv/parse": "^5.0.2",
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
@@ -42,6 +43,7 @@
"sharp": "^0.33.5", "sharp": "^0.33.5",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"sonner": "^2.0.1", "sonner": "^2.0.1",
"stripe": "^18.0.0",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2" "zod": "^3.24.2"
@@ -347,6 +349,16 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@better-auth/stripe": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@better-auth/stripe/-/stripe-1.2.5.tgz",
"integrity": "sha512-+87qnc4rtDJxzdCswJQOHTopRRcVw+93cSNz8O1TP3GcBEooEjAspHHAxSmutPm7pluLrHIX5g0uFE2MIOUbmQ==",
"license": "MIT",
"dependencies": {
"better-auth": "^1.2.5",
"zod": "^3.24.1"
}
},
"node_modules/@better-auth/utils": { "node_modules/@better-auth/utils": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.2.4.tgz", "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.2.4.tgz",
@@ -5685,7 +5697,6 @@
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -8857,7 +8868,6 @@
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -9554,6 +9564,21 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -10278,7 +10303,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -10298,7 +10322,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -10315,7 +10338,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -10334,7 +10356,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -10688,6 +10709,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/stripe": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.0.0.tgz",
"integrity": "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA==",
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/styled-jsx": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",

View File

@@ -10,6 +10,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@better-auth/stripe": "^1.2.5",
"@fast-csv/format": "^5.0.2", "@fast-csv/format": "^5.0.2",
"@fast-csv/parse": "^5.0.2", "@fast-csv/parse": "^5.0.2",
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
@@ -44,6 +45,7 @@
"sharp": "^0.33.5", "sharp": "^0.33.5",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"sonner": "^2.0.1", "sonner": "^2.0.1",
"stripe": "^18.0.0",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2" "zod": "^3.24.2"

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "storage_limit" INTEGER DEFAULT -1;

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- Made the column `storage_used` on table `users` required. This step will fail if there are existing NULL values in that column.
- Made the column `token_balance` on table `users` required. This step will fail if there are existing NULL values in that column.
- Made the column `storage_limit` on table `users` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "storage_used" SET NOT NULL,
ALTER COLUMN "token_balance" SET NOT NULL,
ALTER COLUMN "storage_limit" SET NOT NULL;

View File

@@ -27,8 +27,9 @@ model User {
membershipPlan String? @map("membership_plan") membershipPlan String? @map("membership_plan")
membershipExpiresAt DateTime? @map("membership_expires_at") membershipExpiresAt DateTime? @map("membership_expires_at")
emailVerified Boolean @default(false) @map("is_email_verified") emailVerified Boolean @default(false) @map("is_email_verified")
storageUsed Int? @default(0) @map("storage_used") storageUsed Int @default(0) @map("storage_used")
tokenBalance Int? @default(0) @map("token_balance") storageLimit Int @default(-1) @map("storage_limit")
tokenBalance Int @default(0) @map("token_balance")
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]