Repository: piyush-eon/ai-finance-platform Branch: main Commit: cc17f99af70e Files: 77 Total size: 176.3 KB Directory structure: gitextract_amwpott_/ ├── .eslintrc.json ├── .gitignore ├── README.md ├── actions/ │ ├── account.js │ ├── budget.js │ ├── dashboard.js │ ├── seed.js │ ├── send-email.js │ └── transaction.js ├── app/ │ ├── (auth)/ │ │ ├── layout.js │ │ ├── sign-in/ │ │ │ └── [[...sign-in]]/ │ │ │ └── page.jsx │ │ └── sign-up/ │ │ └── [[...sign-up]]/ │ │ └── page.jsx │ ├── (main)/ │ │ ├── account/ │ │ │ ├── [id]/ │ │ │ │ └── page.jsx │ │ │ └── _components/ │ │ │ ├── account-chart.jsx │ │ │ ├── no-pagination-transaction-table.jsx │ │ │ └── transaction-table.jsx │ │ ├── dashboard/ │ │ │ ├── _components/ │ │ │ │ ├── account-card.jsx │ │ │ │ ├── budget-progress.jsx │ │ │ │ └── transaction-overview.jsx │ │ │ ├── layout.js │ │ │ └── page.jsx │ │ ├── layout.js │ │ └── transaction/ │ │ ├── _components/ │ │ │ ├── recipt-scanner.jsx │ │ │ └── transaction-form.jsx │ │ └── create/ │ │ └── page.jsx │ ├── api/ │ │ ├── inngest/ │ │ │ └── route.js │ │ └── seed/ │ │ └── route.js │ ├── globals.css │ ├── layout.js │ ├── lib/ │ │ └── schema.js │ ├── not-found.jsx │ └── page.js ├── components/ │ ├── create-account-drawer.jsx │ ├── header.jsx │ ├── hero.jsx │ └── ui/ │ ├── badge.jsx │ ├── button.jsx │ ├── calendar.jsx │ ├── card.jsx │ ├── checkbox.jsx │ ├── drawer.jsx │ ├── dropdown-menu.jsx │ ├── input.jsx │ ├── popover.jsx │ ├── progress.jsx │ ├── select.jsx │ ├── sonner.jsx │ ├── switch.jsx │ ├── table.jsx │ └── tooltip.jsx ├── components.json ├── data/ │ ├── categories.js │ └── landing.js ├── emails/ │ └── template.jsx ├── hooks/ │ └── use-fetch.js ├── jsconfig.json ├── lib/ │ ├── arcjet.js │ ├── checkUser.js │ ├── inngest/ │ │ ├── client.js │ │ └── function.js │ ├── prisma.js │ └── utils.js ├── middleware.js ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── prisma/ │ ├── migrations/ │ │ ├── 20241204141034_init/ │ │ │ └── migration.sql │ │ ├── 20241205074927_remove_currency/ │ │ │ └── migration.sql │ │ ├── 20241205094020_remove_categories/ │ │ │ └── migration.sql │ │ ├── 20241205094352_remove_categories/ │ │ │ └── migration.sql │ │ ├── 20241206121749_budget/ │ │ │ └── migration.sql │ │ ├── 20241208092553_budget/ │ │ │ └── migration.sql │ │ ├── 20241208122341_budget/ │ │ │ └── migration.sql │ │ ├── 20241209133842_remove/ │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma └── tailwind.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.json ================================================ { "extends": "next/core-web-vitals", "rules": { "no-unused-vars": ["warn"] } } ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # env files (can opt-in for committing if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts .env ================================================ FILE: README.md ================================================ # Full Stack AI Fianace Platform with Next JS, Supabase, Tailwind, Prisma, Inngest, ArcJet, Shadcn UI Tutorial 🔥🔥 ## https://youtu.be/egS6fnZAdzk Screenshot 2024-12-10 at 9 45 45 AM ### Make sure to create a `.env` file with following variables - ``` DATABASE_URL= DIRECT_URL= NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_SECRET_KEY= NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/onboarding NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding GEMINI_API_KEY= RESEND_API_KEY= ARCJET_KEY= ``` ================================================ FILE: actions/account.js ================================================ "use server"; import { db } from "@/lib/prisma"; import { auth } from "@clerk/nextjs/server"; import { revalidatePath } from "next/cache"; const serializeDecimal = (obj) => { const serialized = { ...obj }; if (obj.balance) { serialized.balance = obj.balance.toNumber(); } if (obj.amount) { serialized.amount = obj.amount.toNumber(); } return serialized; }; export async function getAccountWithTransactions(accountId) { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) throw new Error("User not found"); const account = await db.account.findUnique({ where: { id: accountId, userId: user.id, }, include: { transactions: { orderBy: { date: "desc" }, }, _count: { select: { transactions: true }, }, }, }); if (!account) return null; return { ...serializeDecimal(account), transactions: account.transactions.map(serializeDecimal), }; } export async function bulkDeleteTransactions(transactionIds) { try { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) throw new Error("User not found"); // Get transactions to calculate balance changes const transactions = await db.transaction.findMany({ where: { id: { in: transactionIds }, userId: user.id, }, }); // Group transactions by account to update balances const accountBalanceChanges = transactions.reduce((acc, transaction) => { const change = transaction.type === "EXPENSE" ? transaction.amount : -transaction.amount; acc[transaction.accountId] = (acc[transaction.accountId] || 0) + change; return acc; }, {}); // Delete transactions and update account balances in a transaction await db.$transaction(async (tx) => { // Delete transactions await tx.transaction.deleteMany({ where: { id: { in: transactionIds }, userId: user.id, }, }); // Update account balances for (const [accountId, balanceChange] of Object.entries( accountBalanceChanges )) { await tx.account.update({ where: { id: accountId }, data: { balance: { increment: balanceChange, }, }, }); } }); revalidatePath("/dashboard"); revalidatePath("/account/[id]"); return { success: true }; } catch (error) { return { success: false, error: error.message }; } } export async function updateDefaultAccount(accountId) { try { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) { throw new Error("User not found"); } // First, unset any existing default account await db.account.updateMany({ where: { userId: user.id, isDefault: true, }, data: { isDefault: false }, }); // Then set the new default account const account = await db.account.update({ where: { id: accountId, userId: user.id, }, data: { isDefault: true }, }); revalidatePath("/dashboard"); return { success: true, data: serializeTransaction(account) }; } catch (error) { return { success: false, error: error.message }; } } ================================================ FILE: actions/budget.js ================================================ "use server"; import { db } from "@/lib/prisma"; import { auth } from "@clerk/nextjs/server"; import { revalidatePath } from "next/cache"; export async function getCurrentBudget(accountId) { try { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) { throw new Error("User not found"); } const budget = await db.budget.findFirst({ where: { userId: user.id, }, }); // Get current month's expenses const currentDate = new Date(); const startOfMonth = new Date( currentDate.getFullYear(), currentDate.getMonth(), 1 ); const endOfMonth = new Date( currentDate.getFullYear(), currentDate.getMonth() + 1, 0 ); const expenses = await db.transaction.aggregate({ where: { userId: user.id, type: "EXPENSE", date: { gte: startOfMonth, lte: endOfMonth, }, accountId, }, _sum: { amount: true, }, }); return { budget: budget ? { ...budget, amount: budget.amount.toNumber() } : null, currentExpenses: expenses._sum.amount ? expenses._sum.amount.toNumber() : 0, }; } catch (error) { console.error("Error fetching budget:", error); throw error; } } export async function updateBudget(amount) { try { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) throw new Error("User not found"); // Update or create budget const budget = await db.budget.upsert({ where: { userId: user.id, }, update: { amount, }, create: { userId: user.id, amount, }, }); revalidatePath("/dashboard"); return { success: true, data: { ...budget, amount: budget.amount.toNumber() }, }; } catch (error) { console.error("Error updating budget:", error); return { success: false, error: error.message }; } } ================================================ FILE: actions/dashboard.js ================================================ "use server"; import aj from "@/lib/arcjet"; import { db } from "@/lib/prisma"; import { request } from "@arcjet/next"; import { auth } from "@clerk/nextjs/server"; import { revalidatePath } from "next/cache"; const serializeTransaction = (obj) => { const serialized = { ...obj }; if (obj.balance) { serialized.balance = obj.balance.toNumber(); } if (obj.amount) { serialized.amount = obj.amount.toNumber(); } return serialized; }; export async function getUserAccounts() { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) { throw new Error("User not found"); } try { const accounts = await db.account.findMany({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, include: { _count: { select: { transactions: true, }, }, }, }); // Serialize accounts before sending to client const serializedAccounts = accounts.map(serializeTransaction); return serializedAccounts; } catch (error) { console.error(error.message); } } export async function createAccount(data) { try { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); // Get request data for ArcJet const req = await request(); // Check rate limit const decision = await aj.protect(req, { userId, requested: 1, // Specify how many tokens to consume }); if (decision.isDenied()) { if (decision.reason.isRateLimit()) { const { remaining, reset } = decision.reason; console.error({ code: "RATE_LIMIT_EXCEEDED", details: { remaining, resetInSeconds: reset, }, }); throw new Error("Too many requests. Please try again later."); } throw new Error("Request blocked"); } const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) { throw new Error("User not found"); } // Convert balance to float before saving const balanceFloat = parseFloat(data.balance); if (isNaN(balanceFloat)) { throw new Error("Invalid balance amount"); } // Check if this is the user's first account const existingAccounts = await db.account.findMany({ where: { userId: user.id }, }); // If it's the first account, make it default regardless of user input // If not, use the user's preference const shouldBeDefault = existingAccounts.length === 0 ? true : data.isDefault; // If this account should be default, unset other default accounts if (shouldBeDefault) { await db.account.updateMany({ where: { userId: user.id, isDefault: true }, data: { isDefault: false }, }); } // Create new account const account = await db.account.create({ data: { ...data, balance: balanceFloat, userId: user.id, isDefault: shouldBeDefault, // Override the isDefault based on our logic }, }); // Serialize the account before returning const serializedAccount = serializeTransaction(account); revalidatePath("/dashboard"); return { success: true, data: serializedAccount }; } catch (error) { throw new Error(error.message); } } export async function getDashboardData() { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) { throw new Error("User not found"); } // Get all user transactions const transactions = await db.transaction.findMany({ where: { userId: user.id }, orderBy: { date: "desc" }, }); return transactions.map(serializeTransaction); } ================================================ FILE: actions/seed.js ================================================ "use server"; import { db } from "@/lib/prisma"; import { subDays } from "date-fns"; const ACCOUNT_ID = "account-id"; const USER_ID = "user-id"; // Categories with their typical amount ranges const CATEGORIES = { INCOME: [ { name: "salary", range: [5000, 8000] }, { name: "freelance", range: [1000, 3000] }, { name: "investments", range: [500, 2000] }, { name: "other-income", range: [100, 1000] }, ], EXPENSE: [ { name: "housing", range: [1000, 2000] }, { name: "transportation", range: [100, 500] }, { name: "groceries", range: [200, 600] }, { name: "utilities", range: [100, 300] }, { name: "entertainment", range: [50, 200] }, { name: "food", range: [50, 150] }, { name: "shopping", range: [100, 500] }, { name: "healthcare", range: [100, 1000] }, { name: "education", range: [200, 1000] }, { name: "travel", range: [500, 2000] }, ], }; // Helper to generate random amount within a range function getRandomAmount(min, max) { return Number((Math.random() * (max - min) + min).toFixed(2)); } // Helper to get random category with amount function getRandomCategory(type) { const categories = CATEGORIES[type]; const category = categories[Math.floor(Math.random() * categories.length)]; const amount = getRandomAmount(category.range[0], category.range[1]); return { category: category.name, amount }; } export async function seedTransactions() { try { // Generate 90 days of transactions const transactions = []; let totalBalance = 0; for (let i = 90; i >= 0; i--) { const date = subDays(new Date(), i); // Generate 1-3 transactions per day const transactionsPerDay = Math.floor(Math.random() * 3) + 1; for (let j = 0; j < transactionsPerDay; j++) { // 40% chance of income, 60% chance of expense const type = Math.random() < 0.4 ? "INCOME" : "EXPENSE"; const { category, amount } = getRandomCategory(type); const transaction = { id: crypto.randomUUID(), type, amount, description: `${ type === "INCOME" ? "Received" : "Paid for" } ${category}`, date, category, status: "COMPLETED", userId: USER_ID, accountId: ACCOUNT_ID, createdAt: date, updatedAt: date, }; totalBalance += type === "INCOME" ? amount : -amount; transactions.push(transaction); } } // Insert transactions in batches and update account balance await db.$transaction(async (tx) => { // Clear existing transactions await tx.transaction.deleteMany({ where: { accountId: ACCOUNT_ID }, }); // Insert new transactions await tx.transaction.createMany({ data: transactions, }); // Update account balance await tx.account.update({ where: { id: ACCOUNT_ID }, data: { balance: totalBalance }, }); }); return { success: true, message: `Created ${transactions.length} transactions`, }; } catch (error) { console.error("Error seeding transactions:", error); return { success: false, error: error.message }; } } ================================================ FILE: actions/send-email.js ================================================ "use server"; import { Resend } from "resend"; export async function sendEmail({ to, subject, react }) { const resend = new Resend(process.env.RESEND_API_KEY || ""); try { const data = await resend.emails.send({ from: "Finance App ", to, subject, react, }); return { success: true, data }; } catch (error) { console.error("Failed to send email:", error); return { success: false, error }; } } ================================================ FILE: actions/transaction.js ================================================ "use server"; import { auth } from "@clerk/nextjs/server"; import { db } from "@/lib/prisma"; import { revalidatePath } from "next/cache"; import { GoogleGenerativeAI } from "@google/generative-ai"; import aj from "@/lib/arcjet"; import { request } from "@arcjet/next"; const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const serializeAmount = (obj) => ({ ...obj, amount: obj.amount.toNumber(), }); // Create Transaction export async function createTransaction(data) { try { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); // Get request data for ArcJet const req = await request(); // Check rate limit const decision = await aj.protect(req, { userId, requested: 1, // Specify how many tokens to consume }); if (decision.isDenied()) { if (decision.reason.isRateLimit()) { const { remaining, reset } = decision.reason; console.error({ code: "RATE_LIMIT_EXCEEDED", details: { remaining, resetInSeconds: reset, }, }); throw new Error("Too many requests. Please try again later."); } throw new Error("Request blocked"); } const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) { throw new Error("User not found"); } const account = await db.account.findUnique({ where: { id: data.accountId, userId: user.id, }, }); if (!account) { throw new Error("Account not found"); } // Calculate new balance const balanceChange = data.type === "EXPENSE" ? -data.amount : data.amount; const newBalance = account.balance.toNumber() + balanceChange; // Create transaction and update account balance const transaction = await db.$transaction(async (tx) => { const newTransaction = await tx.transaction.create({ data: { ...data, userId: user.id, nextRecurringDate: data.isRecurring && data.recurringInterval ? calculateNextRecurringDate(data.date, data.recurringInterval) : null, }, }); await tx.account.update({ where: { id: data.accountId }, data: { balance: newBalance }, }); return newTransaction; }); revalidatePath("/dashboard"); revalidatePath(`/account/${transaction.accountId}`); return { success: true, data: serializeAmount(transaction) }; } catch (error) { throw new Error(error.message); } } export async function getTransaction(id) { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) throw new Error("User not found"); const transaction = await db.transaction.findUnique({ where: { id, userId: user.id, }, }); if (!transaction) throw new Error("Transaction not found"); return serializeAmount(transaction); } export async function updateTransaction(id, data) { try { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) throw new Error("User not found"); // Get original transaction to calculate balance change const originalTransaction = await db.transaction.findUnique({ where: { id, userId: user.id, }, include: { account: true, }, }); if (!originalTransaction) throw new Error("Transaction not found"); // Calculate balance changes const oldBalanceChange = originalTransaction.type === "EXPENSE" ? -originalTransaction.amount.toNumber() : originalTransaction.amount.toNumber(); const newBalanceChange = data.type === "EXPENSE" ? -data.amount : data.amount; const netBalanceChange = newBalanceChange - oldBalanceChange; // Update transaction and account balance in a transaction const transaction = await db.$transaction(async (tx) => { const updated = await tx.transaction.update({ where: { id, userId: user.id, }, data: { ...data, nextRecurringDate: data.isRecurring && data.recurringInterval ? calculateNextRecurringDate(data.date, data.recurringInterval) : null, }, }); // Update account balance await tx.account.update({ where: { id: data.accountId }, data: { balance: { increment: netBalanceChange, }, }, }); return updated; }); revalidatePath("/dashboard"); revalidatePath(`/account/${data.accountId}`); return { success: true, data: serializeAmount(transaction) }; } catch (error) { throw new Error(error.message); } } // Get User Transactions export async function getUserTransactions(query = {}) { try { const { userId } = await auth(); if (!userId) throw new Error("Unauthorized"); const user = await db.user.findUnique({ where: { clerkUserId: userId }, }); if (!user) { throw new Error("User not found"); } const transactions = await db.transaction.findMany({ where: { userId: user.id, ...query, }, include: { account: true, }, orderBy: { date: "desc", }, }); return { success: true, data: transactions }; } catch (error) { throw new Error(error.message); } } // Scan Receipt export async function scanReceipt(file) { try { const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); // Convert File to ArrayBuffer const arrayBuffer = await file.arrayBuffer(); // Convert ArrayBuffer to Base64 const base64String = Buffer.from(arrayBuffer).toString("base64"); const prompt = ` Analyze this receipt image and extract the following information in JSON format: - Total amount (just the number) - Date (in ISO format) - Description or items purchased (brief summary) - Merchant/store name - Suggested category (one of: housing,transportation,groceries,utilities,entertainment,food,shopping,healthcare,education,personal,travel,insurance,gifts,bills,other-expense ) Only respond with valid JSON in this exact format: { "amount": number, "date": "ISO date string", "description": "string", "merchantName": "string", "category": "string" } If its not a recipt, return an empty object `; const result = await model.generateContent([ { inlineData: { data: base64String, mimeType: file.type, }, }, prompt, ]); const response = await result.response; const text = response.text(); const cleanedText = text.replace(/```(?:json)?\n?/g, "").trim(); try { const data = JSON.parse(cleanedText); return { amount: parseFloat(data.amount), date: new Date(data.date), description: data.description, category: data.category, merchantName: data.merchantName, }; } catch (parseError) { console.error("Error parsing JSON response:", parseError); throw new Error("Invalid response format from Gemini"); } } catch (error) { console.error("Error scanning receipt:", error); throw new Error("Failed to scan receipt"); } } // Helper function to calculate next recurring date function calculateNextRecurringDate(startDate, interval) { const date = new Date(startDate); switch (interval) { case "DAILY": date.setDate(date.getDate() + 1); break; case "WEEKLY": date.setDate(date.getDate() + 7); break; case "MONTHLY": date.setMonth(date.getMonth() + 1); break; case "YEARLY": date.setFullYear(date.getFullYear() + 1); break; } return date; } ================================================ FILE: app/(auth)/layout.js ================================================ const AuthLayout = ({ children }) => { return
{children}
; }; export default AuthLayout; ================================================ FILE: app/(auth)/sign-in/[[...sign-in]]/page.jsx ================================================ import { SignIn } from "@clerk/nextjs"; export default function Page() { return ; } ================================================ FILE: app/(auth)/sign-up/[[...sign-up]]/page.jsx ================================================ import { SignUp } from "@clerk/nextjs"; export default function Page() { return ; } ================================================ FILE: app/(main)/account/[id]/page.jsx ================================================ import { Suspense } from "react"; import { getAccountWithTransactions } from "@/actions/account"; import { BarLoader } from "react-spinners"; import { TransactionTable } from "../_components/transaction-table"; import { notFound } from "next/navigation"; import { AccountChart } from "../_components/account-chart"; export default async function AccountPage({ params }) { const accountData = await getAccountWithTransactions(params.id); if (!accountData) { notFound(); } const { transactions, ...account } = accountData; return (

{account.name}

{account.type.charAt(0) + account.type.slice(1).toLowerCase()}{" "} Account

${parseFloat(account.balance).toFixed(2)}

{account._count.transactions} Transactions

{/* Chart Section */} } > {/* Transactions Table */} } >
); } ================================================ FILE: app/(main)/account/_components/account-chart.jsx ================================================ "use client"; import { useState, useMemo } from "react"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from "recharts"; import { format, subDays, startOfDay, endOfDay } from "date-fns"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; const DATE_RANGES = { "7D": { label: "Last 7 Days", days: 7 }, "1M": { label: "Last Month", days: 30 }, "3M": { label: "Last 3 Months", days: 90 }, "6M": { label: "Last 6 Months", days: 180 }, ALL: { label: "All Time", days: null }, }; export function AccountChart({ transactions }) { const [dateRange, setDateRange] = useState("1M"); const filteredData = useMemo(() => { const range = DATE_RANGES[dateRange]; const now = new Date(); const startDate = range.days ? startOfDay(subDays(now, range.days)) : startOfDay(new Date(0)); // Filter transactions within date range const filtered = transactions.filter( (t) => new Date(t.date) >= startDate && new Date(t.date) <= endOfDay(now) ); // Group transactions by date const grouped = filtered.reduce((acc, transaction) => { const date = format(new Date(transaction.date), "MMM dd"); if (!acc[date]) { acc[date] = { date, income: 0, expense: 0 }; } if (transaction.type === "INCOME") { acc[date].income += transaction.amount; } else { acc[date].expense += transaction.amount; } return acc; }, {}); // Convert to array and sort by date return Object.values(grouped).sort( (a, b) => new Date(a.date) - new Date(b.date) ); }, [transactions, dateRange]); // Calculate totals for the selected period const totals = useMemo(() => { return filteredData.reduce( (acc, day) => ({ income: acc.income + day.income, expense: acc.expense + day.expense, }), { income: 0, expense: 0 } ); }, [filteredData]); return ( Transaction Overview

Total Income

${totals.income.toFixed(2)}

Total Expenses

${totals.expense.toFixed(2)}

Net

= 0 ? "text-green-500" : "text-red-500" }`} > ${(totals.income - totals.expense).toFixed(2)}

`$${value}`} /> [`$${value}`, undefined]} contentStyle={{ backgroundColor: "hsl(var(--popover))", border: "1px solid hsl(var(--border))", borderRadius: "var(--radius)", }} />
); } ================================================ FILE: app/(main)/account/_components/no-pagination-transaction-table.jsx ================================================ "use client"; import { useState, useEffect, useMemo } from "react"; import { ChevronDown, ChevronUp, MoreHorizontal, Trash, Search, X, RefreshCw, Clock, } from "lucide-react"; import { format } from "date-fns"; import { toast } from "sonner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { categoryColors } from "@/data/categories"; import { bulkDeleteTransactions } from "@/actions/account"; import useFetch from "@/hooks/use-fetch"; import { BarLoader } from "react-spinners"; import { useRouter } from "next/navigation"; const RECURRING_INTERVALS = { DAILY: "Daily", WEEKLY: "Weekly", MONTHLY: "Monthly", YEARLY: "Yearly", }; export function NoPaginationTransactionTable({ transactions }) { const [selectedIds, setSelectedIds] = useState([]); const [sortConfig, setSortConfig] = useState({ field: "date", direction: "desc", }); const [searchTerm, setSearchTerm] = useState(""); const [typeFilter, setTypeFilter] = useState(""); const [recurringFilter, setRecurringFilter] = useState(""); const router = useRouter(); // Memoized filtered and sorted transactions const filteredAndSortedTransactions = useMemo(() => { let result = [...transactions]; // Apply search filter if (searchTerm) { const searchLower = searchTerm.toLowerCase(); result = result.filter((transaction) => transaction.description?.toLowerCase().includes(searchLower) ); } // Apply type filter if (typeFilter) { result = result.filter((transaction) => transaction.type === typeFilter); } // Apply recurring filter if (recurringFilter) { result = result.filter((transaction) => { if (recurringFilter === "recurring") return transaction.isRecurring; return !transaction.isRecurring; }); } // Apply sorting result.sort((a, b) => { let comparison = 0; switch (sortConfig.field) { case "date": comparison = new Date(a.date) - new Date(b.date); break; case "amount": comparison = a.amount - b.amount; break; case "category": comparison = a.category.localeCompare(b.category); break; default: comparison = 0; } return sortConfig.direction === "asc" ? comparison : -comparison; }); return result; }, [transactions, searchTerm, typeFilter, recurringFilter, sortConfig]); const handleSort = (field) => { setSortConfig((current) => ({ field, direction: current.field === field && current.direction === "asc" ? "desc" : "asc", })); }; const handleSelect = (id) => { setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id] ); }; const handleSelectAll = () => { setSelectedIds((current) => current.length === filteredAndSortedTransactions.length ? [] : filteredAndSortedTransactions.map((t) => t.id) ); }; const { loading: deleteLoading, fn: deleteFn, data: deleted, } = useFetch(bulkDeleteTransactions); const handleBulkDelete = async () => { if ( !window.confirm( `Are you sure you want to delete ${selectedIds.length} transactions?` ) ) return; deleteFn(selectedIds); }; useEffect(() => { if (deleted && !deleteLoading) { toast.error("Transactions deleted successfully"); } }, [deleted, deleteLoading]); const handleClearFilters = () => { setSearchTerm(""); setTypeFilter(""); setRecurringFilter(""); setSelectedIds([]); }; return (
{deleteLoading && ( )} {/* Filters */}
setSearchTerm(e.target.value)} className="pl-8" />
{/* Bulk Actions */} {selectedIds.length > 0 && (
)} {(searchTerm || typeFilter || recurringFilter) && ( )}
{/* Transactions Table */}
0 } onCheckedChange={handleSelectAll} /> handleSort("date")} >
Date {sortConfig.field === "date" && (sortConfig.direction === "asc" ? ( ) : ( ))}
Description handleSort("category")} >
Category {sortConfig.field === "category" && (sortConfig.direction === "asc" ? ( ) : ( ))}
handleSort("amount")} >
Amount {sortConfig.field === "amount" && (sortConfig.direction === "asc" ? ( ) : ( ))}
Recurring
{filteredAndSortedTransactions.length === 0 ? ( No transactions found ) : ( filteredAndSortedTransactions.map((transaction) => ( handleSelect(transaction.id)} /> {format(new Date(transaction.date), "PP")} {transaction.description} {transaction.category} {transaction.type === "EXPENSE" ? "-" : "+"}$ {transaction.amount.toFixed(2)} {transaction.isRecurring ? ( { RECURRING_INTERVALS[ transaction.recurringInterval ] }
Next Date:
{format( new Date(transaction.nextRecurringDate), "PPP" )}
) : ( One-time )}
router.push( `/transaction/create?edit=${transaction.id}` ) } > Edit deleteFn([transaction.id])} > Delete
)) )}
); } ================================================ FILE: app/(main)/account/_components/transaction-table.jsx ================================================ "use client"; import { useState, useEffect, useMemo } from "react"; import { ChevronDown, ChevronUp, MoreHorizontal, Trash, Search, X, ChevronLeft, ChevronRight, RefreshCw, Clock, } from "lucide-react"; import { format } from "date-fns"; import { toast } from "sonner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { categoryColors } from "@/data/categories"; import { bulkDeleteTransactions } from "@/actions/account"; import useFetch from "@/hooks/use-fetch"; import { BarLoader } from "react-spinners"; import { useRouter } from "next/navigation"; const ITEMS_PER_PAGE = 10; const RECURRING_INTERVALS = { DAILY: "Daily", WEEKLY: "Weekly", MONTHLY: "Monthly", YEARLY: "Yearly", }; export function TransactionTable({ transactions }) { const [selectedIds, setSelectedIds] = useState([]); const [sortConfig, setSortConfig] = useState({ field: "date", direction: "desc", }); const [searchTerm, setSearchTerm] = useState(""); const [typeFilter, setTypeFilter] = useState(""); const [recurringFilter, setRecurringFilter] = useState(""); const [currentPage, setCurrentPage] = useState(1); const router = useRouter(); // Memoized filtered and sorted transactions const filteredAndSortedTransactions = useMemo(() => { let result = [...transactions]; // Apply search filter if (searchTerm) { const searchLower = searchTerm.toLowerCase(); result = result.filter((transaction) => transaction.description?.toLowerCase().includes(searchLower) ); } // Apply type filter if (typeFilter) { result = result.filter((transaction) => transaction.type === typeFilter); } // Apply recurring filter if (recurringFilter) { result = result.filter((transaction) => { if (recurringFilter === "recurring") return transaction.isRecurring; return !transaction.isRecurring; }); } // Apply sorting result.sort((a, b) => { let comparison = 0; switch (sortConfig.field) { case "date": comparison = new Date(a.date) - new Date(b.date); break; case "amount": comparison = a.amount - b.amount; break; case "category": comparison = a.category.localeCompare(b.category); break; default: comparison = 0; } return sortConfig.direction === "asc" ? comparison : -comparison; }); return result; }, [transactions, searchTerm, typeFilter, recurringFilter, sortConfig]); // Pagination calculations const totalPages = Math.ceil( filteredAndSortedTransactions.length / ITEMS_PER_PAGE ); const paginatedTransactions = useMemo(() => { const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; return filteredAndSortedTransactions.slice( startIndex, startIndex + ITEMS_PER_PAGE ); }, [filteredAndSortedTransactions, currentPage]); const handleSort = (field) => { setSortConfig((current) => ({ field, direction: current.field === field && current.direction === "asc" ? "desc" : "asc", })); }; const handleSelect = (id) => { setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id] ); }; const handleSelectAll = () => { setSelectedIds((current) => current.length === paginatedTransactions.length ? [] : paginatedTransactions.map((t) => t.id) ); }; const { loading: deleteLoading, fn: deleteFn, data: deleted, } = useFetch(bulkDeleteTransactions); const handleBulkDelete = async () => { if ( !window.confirm( `Are you sure you want to delete ${selectedIds.length} transactions?` ) ) return; deleteFn(selectedIds); }; useEffect(() => { if (deleted && !deleteLoading) { toast.error("Transactions deleted successfully"); } }, [deleted, deleteLoading]); const handleClearFilters = () => { setSearchTerm(""); setTypeFilter(""); setRecurringFilter(""); setCurrentPage(1); }; const handlePageChange = (newPage) => { setCurrentPage(newPage); setSelectedIds([]); // Clear selections on page change }; return (
{deleteLoading && ( )} {/* Filters */}
{ setSearchTerm(e.target.value); setCurrentPage(1); }} className="pl-8" />
{/* Bulk Actions */} {selectedIds.length > 0 && (
)} {(searchTerm || typeFilter || recurringFilter) && ( )}
{/* Transactions Table */}
0 } onCheckedChange={handleSelectAll} /> handleSort("date")} >
Date {sortConfig.field === "date" && (sortConfig.direction === "asc" ? ( ) : ( ))}
Description handleSort("category")} >
Category {sortConfig.field === "category" && (sortConfig.direction === "asc" ? ( ) : ( ))}
handleSort("amount")} >
Amount {sortConfig.field === "amount" && (sortConfig.direction === "asc" ? ( ) : ( ))}
Recurring
{paginatedTransactions.length === 0 ? ( No transactions found ) : ( paginatedTransactions.map((transaction) => ( handleSelect(transaction.id)} /> {format(new Date(transaction.date), "PP")} {transaction.description} {transaction.category} {transaction.type === "EXPENSE" ? "-" : "+"}$ {transaction.amount.toFixed(2)} {transaction.isRecurring ? ( { RECURRING_INTERVALS[ transaction.recurringInterval ] }
Next Date:
{format( new Date(transaction.nextRecurringDate), "PPP" )}
) : ( One-time )}
router.push( `/transaction/create?edit=${transaction.id}` ) } > Edit deleteFn([transaction.id])} > Delete
)) )}
{/* Pagination */} {totalPages > 1 && (
Page {currentPage} of {totalPages}
)}
); } ================================================ FILE: app/(main)/dashboard/_components/account-card.jsx ================================================ "use client"; import { ArrowUpRight, ArrowDownRight, CreditCard } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { useEffect } from "react"; import useFetch from "@/hooks/use-fetch"; import { Card, CardContent, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import Link from "next/link"; import { updateDefaultAccount } from "@/actions/account"; import { toast } from "sonner"; export function AccountCard({ account }) { const { name, type, balance, id, isDefault } = account; const { loading: updateDefaultLoading, fn: updateDefaultFn, data: updatedAccount, error, } = useFetch(updateDefaultAccount); const handleDefaultChange = async (event) => { event.preventDefault(); // Prevent navigation if (isDefault) { toast.warning("You need atleast 1 default account"); return; // Don't allow toggling off the default account } await updateDefaultFn(id); }; useEffect(() => { if (updatedAccount?.success) { toast.success("Default account updated successfully"); } }, [updatedAccount]); useEffect(() => { if (error) { toast.error(error.message || "Failed to update default account"); } }, [error]); return ( {name}
${parseFloat(balance).toFixed(2)}

{type.charAt(0) + type.slice(1).toLowerCase()} Account

Income
Expense
); } ================================================ FILE: app/(main)/dashboard/_components/budget-progress.jsx ================================================ "use client"; import { useState, useEffect } from "react"; import { Pencil, Check, X } from "lucide-react"; import useFetch from "@/hooks/use-fetch"; import { toast } from "sonner"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { updateBudget } from "@/actions/budget"; export function BudgetProgress({ initialBudget, currentExpenses }) { const [isEditing, setIsEditing] = useState(false); const [newBudget, setNewBudget] = useState( initialBudget?.amount?.toString() || "" ); const { loading: isLoading, fn: updateBudgetFn, data: updatedBudget, error, } = useFetch(updateBudget); const percentUsed = initialBudget ? (currentExpenses / initialBudget.amount) * 100 : 0; const handleUpdateBudget = async () => { const amount = parseFloat(newBudget); if (isNaN(amount) || amount <= 0) { toast.error("Please enter a valid amount"); return; } await updateBudgetFn(amount); }; const handleCancel = () => { setNewBudget(initialBudget?.amount?.toString() || ""); setIsEditing(false); }; useEffect(() => { if (updatedBudget?.success) { setIsEditing(false); toast.success("Budget updated successfully"); } }, [updatedBudget]); useEffect(() => { if (error) { toast.error(error.message || "Failed to update budget"); } }, [error]); return (
Monthly Budget (Default Account)
{isEditing ? (
setNewBudget(e.target.value)} className="w-32" placeholder="Enter amount" autoFocus disabled={isLoading} />
) : ( <> {initialBudget ? `$${currentExpenses.toFixed( 2 )} of $${initialBudget.amount.toFixed(2)} spent` : "No budget set"} )}
{initialBudget && (
= 90 ? "bg-red-500" : percentUsed >= 75 ? "bg-yellow-500" : "bg-green-500" }`} />

{percentUsed.toFixed(1)}% used

)}
); } ================================================ FILE: app/(main)/dashboard/_components/transaction-overview.jsx ================================================ "use client"; import { useState } from "react"; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, } from "recharts"; import { format } from "date-fns"; import { ArrowUpRight, ArrowDownRight } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { cn } from "@/lib/utils"; const COLORS = [ "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEEAD", "#D4A5A5", "#9FA8DA", ]; export function DashboardOverview({ accounts, transactions }) { const [selectedAccountId, setSelectedAccountId] = useState( accounts.find((a) => a.isDefault)?.id || accounts[0]?.id ); // Filter transactions for selected account const accountTransactions = transactions.filter( (t) => t.accountId === selectedAccountId ); // Get recent transactions (last 5) const recentTransactions = accountTransactions .sort((a, b) => new Date(b.date) - new Date(a.date)) .slice(0, 5); // Calculate expense breakdown for current month const currentDate = new Date(); const currentMonthExpenses = accountTransactions.filter((t) => { const transactionDate = new Date(t.date); return ( t.type === "EXPENSE" && transactionDate.getMonth() === currentDate.getMonth() && transactionDate.getFullYear() === currentDate.getFullYear() ); }); // Group expenses by category const expensesByCategory = currentMonthExpenses.reduce((acc, transaction) => { const category = transaction.category; if (!acc[category]) { acc[category] = 0; } acc[category] += transaction.amount; return acc; }, {}); // Format data for pie chart const pieChartData = Object.entries(expensesByCategory).map( ([category, amount]) => ({ name: category, value: amount, }) ); return (
{/* Recent Transactions Card */} Recent Transactions
{recentTransactions.length === 0 ? (

No recent transactions

) : ( recentTransactions.map((transaction) => (

{transaction.description || "Untitled Transaction"}

{format(new Date(transaction.date), "PP")}

{transaction.type === "EXPENSE" ? ( ) : ( )} ${transaction.amount.toFixed(2)}
)) )}
{/* Expense Breakdown Card */} Monthly Expense Breakdown {pieChartData.length === 0 ? (

No expenses this month

) : (
`${name}: $${value.toFixed(2)}`} > {pieChartData.map((entry, index) => ( ))} `$${value.toFixed(2)}`} contentStyle={{ backgroundColor: "hsl(var(--popover))", border: "1px solid hsl(var(--border))", borderRadius: "var(--radius)", }} />
)}
); } ================================================ FILE: app/(main)/dashboard/layout.js ================================================ import DashboardPage from "./page"; import { BarLoader } from "react-spinners"; import { Suspense } from "react"; export default function Layout() { return (

Dashboard

} >
); } ================================================ FILE: app/(main)/dashboard/page.jsx ================================================ import { Suspense } from "react"; import { getUserAccounts } from "@/actions/dashboard"; import { getDashboardData } from "@/actions/dashboard"; import { getCurrentBudget } from "@/actions/budget"; import { AccountCard } from "./_components/account-card"; import { CreateAccountDrawer } from "@/components/create-account-drawer"; import { BudgetProgress } from "./_components/budget-progress"; import { Card, CardContent } from "@/components/ui/card"; import { Plus } from "lucide-react"; import { DashboardOverview } from "./_components/transaction-overview"; export default async function DashboardPage() { const [accounts, transactions] = await Promise.all([ getUserAccounts(), getDashboardData(), ]); const defaultAccount = accounts?.find((account) => account.isDefault); // Get budget for default account let budgetData = null; if (defaultAccount) { budgetData = await getCurrentBudget(defaultAccount.id); } return (
{/* Budget Progress */} {/* Dashboard Overview */} {/* Accounts Grid */}

Add New Account

{accounts.length > 0 && accounts?.map((account) => ( ))}
); } ================================================ FILE: app/(main)/layout.js ================================================ import React from "react"; const MainLayout = ({ children }) => { return
{children}
; }; export default MainLayout; ================================================ FILE: app/(main)/transaction/_components/recipt-scanner.jsx ================================================ "use client"; import { useRef, useEffect } from "react"; import { Camera, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import useFetch from "@/hooks/use-fetch"; import { scanReceipt } from "@/actions/transaction"; export function ReceiptScanner({ onScanComplete }) { const fileInputRef = useRef(null); const { loading: scanReceiptLoading, fn: scanReceiptFn, data: scannedData, } = useFetch(scanReceipt); const handleReceiptScan = async (file) => { if (file.size > 5 * 1024 * 1024) { toast.error("File size should be less than 5MB"); return; } await scanReceiptFn(file); }; useEffect(() => { if (scannedData && !scanReceiptLoading) { onScanComplete(scannedData); toast.success("Receipt scanned successfully"); } }, [scanReceiptLoading, scannedData]); return (
{ const file = e.target.files?.[0]; if (file) handleReceiptScan(file); }} />
); } ================================================ FILE: app/(main)/transaction/_components/transaction-form.jsx ================================================ "use client"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { CalendarIcon, Loader2 } from "lucide-react"; import { format } from "date-fns"; import { useRouter, useSearchParams } from "next/navigation"; import useFetch from "@/hooks/use-fetch"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Calendar } from "@/components/ui/calendar"; import { CreateAccountDrawer } from "@/components/create-account-drawer"; import { cn } from "@/lib/utils"; import { createTransaction, updateTransaction } from "@/actions/transaction"; import { transactionSchema } from "@/app/lib/schema"; import { ReceiptScanner } from "./recipt-scanner"; export function AddTransactionForm({ accounts, categories, editMode = false, initialData = null, }) { const router = useRouter(); const searchParams = useSearchParams(); const editId = searchParams.get("edit"); const { register, handleSubmit, formState: { errors }, watch, setValue, getValues, reset, } = useForm({ resolver: zodResolver(transactionSchema), defaultValues: editMode && initialData ? { type: initialData.type, amount: initialData.amount.toString(), description: initialData.description, accountId: initialData.accountId, category: initialData.category, date: new Date(initialData.date), isRecurring: initialData.isRecurring, ...(initialData.recurringInterval && { recurringInterval: initialData.recurringInterval, }), } : { type: "EXPENSE", amount: "", description: "", accountId: accounts.find((ac) => ac.isDefault)?.id, date: new Date(), isRecurring: false, }, }); const { loading: transactionLoading, fn: transactionFn, data: transactionResult, } = useFetch(editMode ? updateTransaction : createTransaction); const onSubmit = (data) => { const formData = { ...data, amount: parseFloat(data.amount), }; if (editMode) { transactionFn(editId, formData); } else { transactionFn(formData); } }; const handleScanComplete = (scannedData) => { if (scannedData) { setValue("amount", scannedData.amount.toString()); setValue("date", new Date(scannedData.date)); if (scannedData.description) { setValue("description", scannedData.description); } if (scannedData.category) { setValue("category", scannedData.category); } toast.success("Receipt scanned successfully"); } }; useEffect(() => { if (transactionResult?.success && !transactionLoading) { toast.success( editMode ? "Transaction updated successfully" : "Transaction created successfully" ); reset(); router.push(`/account/${transactionResult.data.accountId}`); } }, [transactionResult, transactionLoading, editMode]); const type = watch("type"); const isRecurring = watch("isRecurring"); const date = watch("date"); const filteredCategories = categories.filter( (category) => category.type === type ); return (
{/* Receipt Scanner - Only show in create mode */} {!editMode && } {/* Type */}
{errors.type && (

{errors.type.message}

)}
{/* Amount and Account */}
{errors.amount && (

{errors.amount.message}

)}
{errors.accountId && (

{errors.accountId.message}

)}
{/* Category */}
{errors.category && (

{errors.category.message}

)}
{/* Date */}
setValue("date", date)} disabled={(date) => date > new Date() || date < new Date("1900-01-01") } initialFocus /> {errors.date && (

{errors.date.message}

)}
{/* Description */}
{errors.description && (

{errors.description.message}

)}
{/* Recurring Toggle */}
Set up a recurring schedule for this transaction
setValue("isRecurring", checked)} />
{/* Recurring Interval */} {isRecurring && (
{errors.recurringInterval && (

{errors.recurringInterval.message}

)}
)} {/* Actions */}
); } ================================================ FILE: app/(main)/transaction/create/page.jsx ================================================ import { getUserAccounts } from "@/actions/dashboard"; import { defaultCategories } from "@/data/categories"; import { AddTransactionForm } from "../_components/transaction-form"; import { getTransaction } from "@/actions/transaction"; export default async function AddTransactionPage({ searchParams }) { const accounts = await getUserAccounts(); const editId = searchParams?.edit; let initialData = null; if (editId) { const transaction = await getTransaction(editId); initialData = transaction; } return (

Add Transaction

); } ================================================ FILE: app/api/inngest/route.js ================================================ import { serve } from "inngest/next"; import { inngest } from "@/lib/inngest/client"; import { checkBudgetAlerts, generateMonthlyReports, processRecurringTransaction, triggerRecurringTransactions, } from "@/lib/inngest/function"; export const { GET, POST, PUT } = serve({ client: inngest, functions: [ processRecurringTransaction, triggerRecurringTransactions, generateMonthlyReports, checkBudgetAlerts, ], }); ================================================ FILE: app/api/seed/route.js ================================================ import { seedTransactions } from "@/actions/seed"; export async function GET() { const result = await seedTransactions(); return Response.json(result); } ================================================ FILE: app/globals.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; body { font-family: Arial, Helvetica, sans-serif; } html { scroll-behavior: smooth; } @layer base { :root { --background: 0 0% 100%; --foreground: 0 0% 3.9%; --card: 0 0% 100%; --card-foreground: 0 0% 3.9%; --popover: 0 0% 100%; --popover-foreground: 0 0% 3.9%; --primary: 0 0% 9%; --primary-foreground: 0 0% 98%; --secondary: 0 0% 96.1%; --secondary-foreground: 0 0% 9%; --muted: 0 0% 96.1%; --muted-foreground: 0 0% 45.1%; --accent: 0 0% 96.1%; --accent-foreground: 0 0% 9%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; --border: 0 0% 89.8%; --input: 0 0% 89.8%; --ring: 0 0% 3.9%; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem; } .dark { --background: 0 0% 3.9%; --foreground: 0 0% 98%; --card: 0 0% 3.9%; --card-foreground: 0 0% 98%; --popover: 0 0% 3.9%; --popover-foreground: 0 0% 98%; --primary: 0 0% 98%; --primary-foreground: 0 0% 9%; --secondary: 0 0% 14.9%; --secondary-foreground: 0 0% 98%; --muted: 0 0% 14.9%; --muted-foreground: 0 0% 63.9%; --accent: 0 0% 14.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; --border: 0 0% 14.9%; --input: 0 0% 14.9%; --ring: 0 0% 83.1%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } @layer utilities { .gradient { @apply bg-gradient-to-br from-blue-600 to-purple-600; } .gradient-title { @apply gradient font-extrabold tracking-tighter pr-2 pb-2 text-transparent bg-clip-text; } } .hero-image-wrapper { perspective: 1000px; } .hero-image { /* transform: rotateX(20deg) scale(0.9) translateY(-50); */ transform: rotateX(15deg) scale(1); transition: transform 0.5s ease-out; will-change: transform; } .hero-image.scrolled { transform: rotateX(0deg) scale(1) translateY(40px); } @keyframes gradientMove { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } } /* Add this class */ .animate-gradient { background-size: 200% 200%; animation: gradientMove 3s ease infinite; } ================================================ FILE: app/layout.js ================================================ import { Inter } from "next/font/google"; import "./globals.css"; import Header from "@/components/header"; import { ClerkProvider } from "@clerk/nextjs"; import { Toaster } from "sonner"; const inter = Inter({ subsets: ["latin"] }); export const metadata = { title: "Welth", description: "One stop Finance Platform", }; export default function RootLayout({ children }) { return (
{children}

Made with 💗 by RoadsideCoder

); } ================================================ FILE: app/lib/schema.js ================================================ import { z } from "zod"; export const accountSchema = z.object({ name: z.string().min(1, "Name is required"), type: z.enum(["CURRENT", "SAVINGS"]), balance: z.string().min(1, "Initial balance is required"), isDefault: z.boolean().default(false), }); export const transactionSchema = z .object({ type: z.enum(["INCOME", "EXPENSE"]), amount: z.string().min(1, "Amount is required"), description: z.string().optional(), date: z.date({ required_error: "Date is required" }), accountId: z.string().min(1, "Account is required"), category: z.string().min(1, "Category is required"), isRecurring: z.boolean().default(false), recurringInterval: z .enum(["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]) .optional(), }) .superRefine((data, ctx) => { if (data.isRecurring && !data.recurringInterval) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Recurring interval is required for recurring transactions", path: ["recurringInterval"], }); } }); ================================================ FILE: app/not-found.jsx ================================================ import Link from "next/link"; import { Button } from "@/components/ui/button"; export default function NotFound() { return (

404

Page Not Found

Oops! The page you're looking for doesn't exist or has been moved.

); } ================================================ FILE: app/page.js ================================================ import React from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import Image from "next/image"; import { featuresData, howItWorksData, statsData, testimonialsData, } from "@/data/landing"; import HeroSection from "@/components/hero"; import Link from "next/link"; const LandingPage = () => { return (
{/* Hero Section */} {/* Stats Section */}
{statsData.map((stat, index) => (
{stat.value}
{stat.label}
))}
{/* Features Section */}

Everything you need to manage your finances

{featuresData.map((feature, index) => ( {feature.icon}

{feature.title}

{feature.description}

))}
{/* How It Works Section */}

How It Works

{howItWorksData.map((step, index) => (
{step.icon}

{step.title}

{step.description}

))}
{/* Testimonials Section */}

What Our Users Say

{testimonialsData.map((testimonial, index) => (
{testimonial.name}
{testimonial.name}
{testimonial.role}

{testimonial.quote}

))}
{/* CTA Section */}

Ready to Take Control of Your Finances?

Join thousands of users who are already managing their finances smarter with Welth

); }; export default LandingPage; ================================================ FILE: components/create-account-drawer.jsx ================================================ "use client"; import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Loader2 } from "lucide-react"; import useFetch from "@/hooks/use-fetch"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger, DrawerClose, } from "@/components/ui/drawer"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { createAccount } from "@/actions/dashboard"; import { accountSchema } from "@/app/lib/schema"; export function CreateAccountDrawer({ children }) { const [open, setOpen] = useState(false); const { register, handleSubmit, formState: { errors }, setValue, watch, reset, } = useForm({ resolver: zodResolver(accountSchema), defaultValues: { name: "", type: "CURRENT", balance: "", isDefault: false, }, }); const { loading: createAccountLoading, fn: createAccountFn, error, data: newAccount, } = useFetch(createAccount); const onSubmit = async (data) => { await createAccountFn(data); }; useEffect(() => { if (newAccount) { toast.success("Account created successfully"); reset(); setOpen(false); } }, [newAccount, reset]); useEffect(() => { if (error) { toast.error(error.message || "Failed to create account"); } }, [error]); return ( {children} Create New Account
{errors.name && (

{errors.name.message}

)}
{errors.type && (

{errors.type.message}

)}
{errors.balance && (

{errors.balance.message}

)}

This account will be selected by default for transactions

setValue("isDefault", checked)} />
); } ================================================ FILE: components/header.jsx ================================================ import React from "react"; import { Button } from "./ui/button"; import { PenBox, LayoutDashboard } from "lucide-react"; import Link from "next/link"; import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs"; import { checkUser } from "@/lib/checkUser"; import Image from "next/image"; const Header = async () => { await checkUser(); return (
); }; export default Header; ================================================ FILE: components/hero.jsx ================================================ "use client"; import React, { useEffect, useRef } from "react"; import Image from "next/image"; import { Button } from "@/components/ui/button"; import Link from "next/link"; const HeroSection = () => { const imageRef = useRef(null); useEffect(() => { const imageElement = imageRef.current; const handleScroll = () => { const scrollPosition = window.scrollY; const scrollThreshold = 100; if (scrollPosition > scrollThreshold) { imageElement.classList.add("scrolled"); } else { imageElement.classList.remove("scrolled"); } }; window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); }, []); return (

Manage Your Finances
with Intelligence

An AI-powered financial management platform that helps you track, analyze, and optimize your spending with real-time insights.

Dashboard Preview
); }; export default HeroSection; ================================================ FILE: components/ui/badge.jsx ================================================ import * as React from "react" import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils" const badgeVariants = cva( "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", outline: "text-foreground", }, }, defaultVariants: { variant: "default", }, } ) function Badge({ className, variant, ...props }) { return (
); } export { Badge, badgeVariants } ================================================ FILE: components/ui/button.jsx ================================================ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3 text-xs", lg: "h-10 rounded-md px-8", icon: "h-9 w-9", }, }, defaultVariants: { variant: "default", size: "default", }, } ); const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( ); } ); Button.displayName = "Button"; export { Button, buttonVariants }; ================================================ FILE: components/ui/calendar.jsx ================================================ "use client"; import * as React from "react" import { ChevronLeft, ChevronRight } from "lucide-react" import { DayPicker } from "react-day-picker" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" function Calendar({ className, classNames, showOutsideDays = true, ...props }) { return ( (.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" : "[&:has([aria-selected])]:rounded-md" ), day: cn( buttonVariants({ variant: "ghost" }), "h-8 w-8 p-0 font-normal aria-selected:opacity-100" ), day_range_start: "day-range-start", day_range_end: "day-range-end", day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", day_today: "bg-accent text-accent-foreground", day_outside: "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", day_disabled: "text-muted-foreground opacity-50", day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground", day_hidden: "invisible", ...classNames, }} components={{ IconLeft: ({ ...props }) => , IconRight: ({ ...props }) => , }} {...props} />) ); } Calendar.displayName = "Calendar" export { Calendar } ================================================ FILE: components/ui/card.jsx ================================================ import * as React from "react" import { cn } from "@/lib/utils" const Card = React.forwardRef(({ className, ...props }, ref) => (
)) Card.displayName = "Card" const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
)) CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
)) CardTitle.displayName = "CardTitle" const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
)) CardDescription.displayName = "CardDescription" const CardContent = React.forwardRef(({ className, ...props }, ref) => (
)) CardContent.displayName = "CardContent" const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
)) CardFooter.displayName = "CardFooter" export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } ================================================ FILE: components/ui/checkbox.jsx ================================================ "use client" import * as React from "react" import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import { Check } from "lucide-react" import { cn } from "@/lib/utils" const Checkbox = React.forwardRef(({ className, ...props }, ref) => ( )) Checkbox.displayName = CheckboxPrimitive.Root.displayName export { Checkbox } ================================================ FILE: components/ui/drawer.jsx ================================================ "use client" import * as React from "react" import { Drawer as DrawerPrimitive } from "vaul" import { cn } from "@/lib/utils" const Drawer = ({ shouldScaleBackground = true, ...props }) => ( ) Drawer.displayName = "Drawer" const DrawerTrigger = DrawerPrimitive.Trigger const DrawerPortal = DrawerPrimitive.Portal const DrawerClose = DrawerPrimitive.Close const DrawerOverlay = React.forwardRef(({ className, ...props }, ref) => ( )) DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName const DrawerContent = React.forwardRef(({ className, children, ...props }, ref) => (
{children} )) DrawerContent.displayName = "DrawerContent" const DrawerHeader = ({ className, ...props }) => (
) DrawerHeader.displayName = "DrawerHeader" const DrawerFooter = ({ className, ...props }) => (
) DrawerFooter.displayName = "DrawerFooter" const DrawerTitle = React.forwardRef(({ className, ...props }, ref) => ( )) DrawerTitle.displayName = DrawerPrimitive.Title.displayName const DrawerDescription = React.forwardRef(({ className, ...props }, ref) => ( )) DrawerDescription.displayName = DrawerPrimitive.Description.displayName export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, } ================================================ FILE: components/ui/dropdown-menu.jsx ================================================ "use client" import * as React from "react" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import { Check, ChevronRight, Circle } from "lucide-react" import { cn } from "@/lib/utils" const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuGroup = DropdownMenuPrimitive.Group const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( {children} )) DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( )) DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( )) DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => ( svg]:size-4 [&>svg]:shrink-0", inset && "pl-8", className )} {...props} /> )) DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( {children} )) DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( {children} )) DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( )) DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( )) DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName const DropdownMenuShortcut = ({ className, ...props }) => { return ( () ); } DropdownMenuShortcut.displayName = "DropdownMenuShortcut" export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, } ================================================ FILE: components/ui/input.jsx ================================================ import * as React from "react" import { cn } from "@/lib/utils" const Input = React.forwardRef(({ className, type, ...props }, ref) => { return ( () ); }) Input.displayName = "Input" export { Input } ================================================ FILE: components/ui/popover.jsx ================================================ "use client" import * as React from "react" import * as PopoverPrimitive from "@radix-ui/react-popover" import { cn } from "@/lib/utils" const Popover = PopoverPrimitive.Root const PopoverTrigger = PopoverPrimitive.Trigger const PopoverAnchor = PopoverPrimitive.Anchor const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( )) PopoverContent.displayName = PopoverPrimitive.Content.displayName export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } ================================================ FILE: components/ui/progress.jsx ================================================ "use client"; import * as React from "react"; import * as ProgressPrimitive from "@radix-ui/react-progress"; import { cn } from "@/lib/utils"; const Progress = React.forwardRef( ({ className, value, extraStyles, ...props }, ref) => ( ) ); Progress.displayName = ProgressPrimitive.Root.displayName; export { Progress }; ================================================ FILE: components/ui/select.jsx ================================================ "use client" import * as React from "react" import * as SelectPrimitive from "@radix-ui/react-select" import { Check, ChevronDown, ChevronUp } from "lucide-react" import { cn } from "@/lib/utils" const Select = SelectPrimitive.Root const SelectGroup = SelectPrimitive.Group const SelectValue = SelectPrimitive.Value const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => ( span]:line-clamp-1", className )} {...props}> {children} )) SelectTrigger.displayName = SelectPrimitive.Trigger.displayName const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => ( )) SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => ( )) SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => ( {children} )) SelectContent.displayName = SelectPrimitive.Content.displayName const SelectLabel = React.forwardRef(({ className, ...props }, ref) => ( )) SelectLabel.displayName = SelectPrimitive.Label.displayName const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => ( {children} )) SelectItem.displayName = SelectPrimitive.Item.displayName const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => ( )) SelectSeparator.displayName = SelectPrimitive.Separator.displayName export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, } ================================================ FILE: components/ui/sonner.jsx ================================================ "use client"; import { useTheme } from "next-themes" import { Toaster as Sonner } from "sonner" const Toaster = ({ ...props }) => { const { theme = "system" } = useTheme() return ( () ); } export { Toaster } ================================================ FILE: components/ui/switch.jsx ================================================ "use client" import * as React from "react" import * as SwitchPrimitives from "@radix-ui/react-switch" import { cn } from "@/lib/utils" const Switch = React.forwardRef(({ className, ...props }, ref) => ( )) Switch.displayName = SwitchPrimitives.Root.displayName export { Switch } ================================================ FILE: components/ui/table.jsx ================================================ import * as React from "react" import { cn } from "@/lib/utils" const Table = React.forwardRef(({ className, ...props }, ref) => (
)) Table.displayName = "Table" const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( )) TableHeader.displayName = "TableHeader" const TableBody = React.forwardRef(({ className, ...props }, ref) => ( )) TableBody.displayName = "TableBody" const TableFooter = React.forwardRef(({ className, ...props }, ref) => ( tr]:last:border-b-0", className)} {...props} /> )) TableFooter.displayName = "TableFooter" const TableRow = React.forwardRef(({ className, ...props }, ref) => ( )) TableRow.displayName = "TableRow" const TableHead = React.forwardRef(({ className, ...props }, ref) => (
[role=checkbox]]:translate-y-[2px]", className )} {...props} /> )) TableHead.displayName = "TableHead" const TableCell = React.forwardRef(({ className, ...props }, ref) => ( [role=checkbox]]:translate-y-[2px]", className )} {...props} /> )) TableCell.displayName = "TableCell" const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
)) TableCaption.displayName = "TableCaption" export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption, } ================================================ FILE: components/ui/tooltip.jsx ================================================ "use client" import * as React from "react" import * as TooltipPrimitive from "@radix-ui/react-tooltip" import { cn } from "@/lib/utils" const TooltipProvider = TooltipPrimitive.Provider const Tooltip = TooltipPrimitive.Root const TooltipTrigger = TooltipPrimitive.Trigger const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( )) TooltipContent.displayName = TooltipPrimitive.Content.displayName export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": false, "tailwind": { "config": "tailwind.config.js", "css": "app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: data/categories.js ================================================ export const defaultCategories = [ // Income Categories { id: "salary", name: "Salary", type: "INCOME", color: "#22c55e", // green-500 icon: "Wallet", }, { id: "freelance", name: "Freelance", type: "INCOME", color: "#06b6d4", // cyan-500 icon: "Laptop", }, { id: "investments", name: "Investments", type: "INCOME", color: "#6366f1", // indigo-500 icon: "TrendingUp", }, { id: "business", name: "Business", type: "INCOME", color: "#ec4899", // pink-500 icon: "Building", }, { id: "rental", name: "Rental", type: "INCOME", color: "#f59e0b", // amber-500 icon: "Home", }, { id: "other-income", name: "Other Income", type: "INCOME", color: "#64748b", // slate-500 icon: "Plus", }, // Expense Categories { id: "housing", name: "Housing", type: "EXPENSE", color: "#ef4444", // red-500 icon: "Home", subcategories: ["Rent", "Mortgage", "Property Tax", "Maintenance"], }, { id: "transportation", name: "Transportation", type: "EXPENSE", color: "#f97316", // orange-500 icon: "Car", subcategories: ["Fuel", "Public Transport", "Maintenance", "Parking"], }, { id: "groceries", name: "Groceries", type: "EXPENSE", color: "#84cc16", // lime-500 icon: "Shopping", }, { id: "utilities", name: "Utilities", type: "EXPENSE", color: "#06b6d4", // cyan-500 icon: "Zap", subcategories: ["Electricity", "Water", "Gas", "Internet", "Phone"], }, { id: "entertainment", name: "Entertainment", type: "EXPENSE", color: "#8b5cf6", // violet-500 icon: "Film", subcategories: ["Movies", "Games", "Streaming Services"], }, { id: "food", name: "Food", type: "EXPENSE", color: "#f43f5e", // rose-500 icon: "UtensilsCrossed", }, { id: "shopping", name: "Shopping", type: "EXPENSE", color: "#ec4899", // pink-500 icon: "ShoppingBag", subcategories: ["Clothing", "Electronics", "Home Goods"], }, { id: "healthcare", name: "Healthcare", type: "EXPENSE", color: "#14b8a6", // teal-500 icon: "HeartPulse", subcategories: ["Medical", "Dental", "Pharmacy", "Insurance"], }, { id: "education", name: "Education", type: "EXPENSE", color: "#6366f1", // indigo-500 icon: "GraduationCap", subcategories: ["Tuition", "Books", "Courses"], }, { id: "personal", name: "Personal Care", type: "EXPENSE", color: "#d946ef", // fuchsia-500 icon: "Smile", subcategories: ["Haircut", "Gym", "Beauty"], }, { id: "travel", name: "Travel", type: "EXPENSE", color: "#0ea5e9", // sky-500 icon: "Plane", }, { id: "insurance", name: "Insurance", type: "EXPENSE", color: "#64748b", // slate-500 icon: "Shield", subcategories: ["Life", "Home", "Vehicle"], }, { id: "gifts", name: "Gifts & Donations", type: "EXPENSE", color: "#f472b6", // pink-400 icon: "Gift", }, { id: "bills", name: "Bills & Fees", type: "EXPENSE", color: "#fb7185", // rose-400 icon: "Receipt", subcategories: ["Bank Fees", "Late Fees", "Service Charges"], }, { id: "other-expense", name: "Other Expenses", type: "EXPENSE", color: "#94a3b8", // slate-400 icon: "MoreHorizontal", }, ]; export const categoryColors = defaultCategories.reduce((acc, category) => { acc[category.id] = category.color; return acc; }, {}); ================================================ FILE: data/landing.js ================================================ import { BarChart3, Receipt, PieChart, CreditCard, Globe, Zap, } from "lucide-react"; // Stats Data export const statsData = [ { value: "50K+", label: "Active Users", }, { value: "$2B+", label: "Transactions Tracked", }, { value: "99.9%", label: "Uptime", }, { value: "4.9/5", label: "User Rating", }, ]; // Features Data export const featuresData = [ { icon: , title: "Advanced Analytics", description: "Get detailed insights into your spending patterns with AI-powered analytics", }, { icon: , title: "Smart Receipt Scanner", description: "Extract data automatically from receipts using advanced AI technology", }, { icon: , title: "Budget Planning", description: "Create and manage budgets with intelligent recommendations", }, { icon: , title: "Multi-Account Support", description: "Manage multiple accounts and credit cards in one place", }, { icon: , title: "Multi-Currency", description: "Support for multiple currencies with real-time conversion", }, { icon: , title: "Automated Insights", description: "Get automated financial insights and recommendations", }, ]; // How It Works Data export const howItWorksData = [ { icon: , title: "1. Create Your Account", description: "Get started in minutes with our simple and secure sign-up process", }, { icon: , title: "2. Track Your Spending", description: "Automatically categorize and track your transactions in real-time", }, { icon: , title: "3. Get Insights", description: "Receive AI-powered insights and recommendations to optimize your finances", }, ]; // Testimonials Data export const testimonialsData = [ { name: "Sarah Johnson", role: "Small Business Owner", image: "https://randomuser.me/api/portraits/women/75.jpg", quote: "Welth has transformed how I manage my business finances. The AI insights have helped me identify cost-saving opportunities I never knew existed.", }, { name: "Michael Chen", role: "Freelancer", image: "https://randomuser.me/api/portraits/men/75.jpg", quote: "The receipt scanning feature saves me hours each month. Now I can focus on my work instead of manual data entry and expense tracking.", }, { name: "Emily Rodriguez", role: "Financial Advisor", image: "https://randomuser.me/api/portraits/women/74.jpg", quote: "I recommend Welth to all my clients. The multi-currency support and detailed analytics make it perfect for international investors.", }, ]; ================================================ FILE: emails/template.jsx ================================================ import { Body, Container, Head, Heading, Html, Preview, Section, Text, } from "@react-email/components"; // Dummy data for preview const PREVIEW_DATA = { monthlyReport: { userName: "John Doe", type: "monthly-report", data: { month: "December", stats: { totalIncome: 5000, totalExpenses: 3500, byCategory: { housing: 1500, groceries: 600, transportation: 400, entertainment: 300, utilities: 700, }, }, insights: [ "Your housing expenses are 43% of your total spending - consider reviewing your housing costs.", "Great job keeping entertainment expenses under control this month!", "Setting up automatic savings could help you save 20% more of your income.", ], }, }, budgetAlert: { userName: "John Doe", type: "budget-alert", data: { percentageUsed: 85, budgetAmount: 4000, totalExpenses: 3400, }, }, }; export default function EmailTemplate({ userName = "", type = "monthly-report", data = {}, }) { if (type === "monthly-report") { return ( Your Monthly Financial Report Monthly Financial Report Hello {userName}, Here’s your financial summary for {data?.month}: {/* Main Stats */}
Total Income ${data?.stats.totalIncome}
Total Expenses ${data?.stats.totalExpenses}
Net ${data?.stats.totalIncome - data?.stats.totalExpenses}
{/* Category Breakdown */} {data?.stats?.byCategory && (
Expenses by Category {Object.entries(data?.stats.byCategory).map( ([category, amount]) => (
{category} ${amount}
) )}
)} {/* AI Insights */} {data?.insights && (
Welth Insights {data.insights.map((insight, index) => ( • {insight} ))}
)} Thank you for using Welth. Keep tracking your finances for better financial health!
); } if (type === "budget-alert") { return ( Budget Alert Budget Alert Hello {userName}, You’ve used {data?.percentageUsed.toFixed(1)}% of your monthly budget.
Budget Amount ${data?.budgetAmount}
Spent So Far ${data?.totalExpenses}
Remaining ${data?.budgetAmount - data?.totalExpenses}
); } } const styles = { body: { backgroundColor: "#f6f9fc", fontFamily: "-apple-system, sans-serif", }, container: { backgroundColor: "#ffffff", margin: "0 auto", padding: "20px", borderRadius: "5px", boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", }, title: { color: "#1f2937", fontSize: "32px", fontWeight: "bold", textAlign: "center", margin: "0 0 20px", }, heading: { color: "#1f2937", fontSize: "20px", fontWeight: "600", margin: "0 0 16px", }, text: { color: "#4b5563", fontSize: "16px", margin: "0 0 16px", }, section: { marginTop: "32px", padding: "20px", backgroundColor: "#f9fafb", borderRadius: "5px", border: "1px solid #e5e7eb", }, statsContainer: { margin: "32px 0", padding: "20px", backgroundColor: "#f9fafb", borderRadius: "5px", }, stat: { marginBottom: "16px", padding: "12px", backgroundColor: "#fff", borderRadius: "4px", boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)", }, row: { display: "flex", justifyContent: "space-between", padding: "12px 0", borderBottom: "1px solid #e5e7eb", }, footer: { color: "#6b7280", fontSize: "14px", textAlign: "center", marginTop: "32px", paddingTop: "16px", borderTop: "1px solid #e5e7eb", }, }; ================================================ FILE: hooks/use-fetch.js ================================================ import { useState } from "react"; import { toast } from "sonner"; const useFetch = (cb) => { const [data, setData] = useState(undefined); const [loading, setLoading] = useState(null); const [error, setError] = useState(null); const fn = async (...args) => { setLoading(true); setError(null); try { const response = await cb(...args); setData(response); setError(null); } catch (error) { setError(error); toast.error(error.message); } finally { setLoading(false); } }; return { data, loading, error, fn, setData }; }; export default useFetch; ================================================ FILE: jsconfig.json ================================================ { "compilerOptions": { "paths": { "@/*": ["./*"] } } } ================================================ FILE: lib/arcjet.js ================================================ import arcjet, { tokenBucket } from "@arcjet/next"; const aj = arcjet({ key: process.env.ARCJET_KEY, characteristics: ["userId"], // Track based on Clerk userId rules: [ // Rate limiting specifically for collection creation tokenBucket({ mode: "LIVE", refillRate: 10, // 10 collections interval: 3600, // per hour capacity: 10, // maximum burst capacity }), ], }); export default aj; ================================================ FILE: lib/checkUser.js ================================================ import { currentUser } from "@clerk/nextjs/server"; import { db } from "./prisma"; export const checkUser = async () => { const user = await currentUser(); if (!user) { return null; } try { const loggedInUser = await db.user.findUnique({ where: { clerkUserId: user.id, }, }); if (loggedInUser) { return loggedInUser; } const name = `${user.firstName} ${user.lastName}`; const newUser = await db.user.create({ data: { clerkUserId: user.id, name, imageUrl: user.imageUrl, email: user.emailAddresses[0].emailAddress, }, }); return newUser; } catch (error) { console.log(error.message); } }; ================================================ FILE: lib/inngest/client.js ================================================ import { Inngest } from "inngest"; export const inngest = new Inngest({ id: "finance-platform", // Unique app ID name: "Finance Platform", retryFunction: async (attempt) => ({ delay: Math.pow(2, attempt) * 1000, // Exponential backoff maxAttempts: 2, }), }); ================================================ FILE: lib/inngest/function.js ================================================ import { inngest } from "./client"; import { db } from "@/lib/prisma"; import EmailTemplate from "@/emails/template"; import { sendEmail } from "@/actions/send-email"; import { GoogleGenerativeAI } from "@google/generative-ai"; // 1. Recurring Transaction Processing with Throttling export const processRecurringTransaction = inngest.createFunction( { id: "process-recurring-transaction", name: "Process Recurring Transaction", throttle: { limit: 10, // Process 10 transactions period: "1m", // per minute key: "event.data.userId", // Throttle per user }, }, { event: "transaction.recurring.process" }, async ({ event, step }) => { // Validate event data if (!event?.data?.transactionId || !event?.data?.userId) { console.error("Invalid event data:", event); return { error: "Missing required event data" }; } await step.run("process-transaction", async () => { const transaction = await db.transaction.findUnique({ where: { id: event.data.transactionId, userId: event.data.userId, }, include: { account: true, }, }); if (!transaction || !isTransactionDue(transaction)) return; // Create new transaction and update account balance in a transaction await db.$transaction(async (tx) => { // Create new transaction await tx.transaction.create({ data: { type: transaction.type, amount: transaction.amount, description: `${transaction.description} (Recurring)`, date: new Date(), category: transaction.category, userId: transaction.userId, accountId: transaction.accountId, isRecurring: false, }, }); // Update account balance const balanceChange = transaction.type === "EXPENSE" ? -transaction.amount.toNumber() : transaction.amount.toNumber(); await tx.account.update({ where: { id: transaction.accountId }, data: { balance: { increment: balanceChange } }, }); // Update last processed date and next recurring date await tx.transaction.update({ where: { id: transaction.id }, data: { lastProcessed: new Date(), nextRecurringDate: calculateNextRecurringDate( new Date(), transaction.recurringInterval ), }, }); }); }); } ); // Trigger recurring transactions with batching export const triggerRecurringTransactions = inngest.createFunction( { id: "trigger-recurring-transactions", // Unique ID, name: "Trigger Recurring Transactions", }, { cron: "0 0 * * *" }, // Daily at midnight async ({ step }) => { const recurringTransactions = await step.run( "fetch-recurring-transactions", async () => { return await db.transaction.findMany({ where: { isRecurring: true, status: "COMPLETED", OR: [ { lastProcessed: null }, { nextRecurringDate: { lte: new Date(), }, }, ], }, }); } ); // Send event for each recurring transaction in batches if (recurringTransactions.length > 0) { const events = recurringTransactions.map((transaction) => ({ name: "transaction.recurring.process", data: { transactionId: transaction.id, userId: transaction.userId, }, })); // Send events directly using inngest.send() await inngest.send(events); } return { triggered: recurringTransactions.length }; } ); // 2. Monthly Report Generation async function generateFinancialInsights(stats, month) { const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); const prompt = ` Analyze this financial data and provide 3 concise, actionable insights. Focus on spending patterns and practical advice. Keep it friendly and conversational. Financial Data for ${month}: - Total Income: $${stats.totalIncome} - Total Expenses: $${stats.totalExpenses} - Net Income: $${stats.totalIncome - stats.totalExpenses} - Expense Categories: ${Object.entries(stats.byCategory) .map(([category, amount]) => `${category}: $${amount}`) .join(", ")} Format the response as a JSON array of strings, like this: ["insight 1", "insight 2", "insight 3"] `; try { const result = await model.generateContent(prompt); const response = result.response; const text = response.text(); const cleanedText = text.replace(/```(?:json)?\n?/g, "").trim(); return JSON.parse(cleanedText); } catch (error) { console.error("Error generating insights:", error); return [ "Your highest expense category this month might need attention.", "Consider setting up a budget for better financial management.", "Track your recurring expenses to identify potential savings.", ]; } } export const generateMonthlyReports = inngest.createFunction( { id: "generate-monthly-reports", name: "Generate Monthly Reports", }, { cron: "0 0 1 * *" }, // First day of each month async ({ step }) => { const users = await step.run("fetch-users", async () => { return await db.user.findMany({ include: { accounts: true }, }); }); for (const user of users) { await step.run(`generate-report-${user.id}`, async () => { const lastMonth = new Date(); lastMonth.setMonth(lastMonth.getMonth() - 1); const stats = await getMonthlyStats(user.id, lastMonth); const monthName = lastMonth.toLocaleString("default", { month: "long", }); // Generate AI insights const insights = await generateFinancialInsights(stats, monthName); await sendEmail({ to: user.email, subject: `Your Monthly Financial Report - ${monthName}`, react: EmailTemplate({ userName: user.name, type: "monthly-report", data: { stats, month: monthName, insights, }, }), }); }); } return { processed: users.length }; } ); // 3. Budget Alerts with Event Batching export const checkBudgetAlerts = inngest.createFunction( { name: "Check Budget Alerts" }, { cron: "0 */6 * * *" }, // Every 6 hours async ({ step }) => { const budgets = await step.run("fetch-budgets", async () => { return await db.budget.findMany({ include: { user: { include: { accounts: { where: { isDefault: true, }, }, }, }, }, }); }); for (const budget of budgets) { const defaultAccount = budget.user.accounts[0]; if (!defaultAccount) continue; // Skip if no default account await step.run(`check-budget-${budget.id}`, async () => { const startDate = new Date(); startDate.setDate(1); // Start of current month // Calculate total expenses for the default account only const expenses = await db.transaction.aggregate({ where: { userId: budget.userId, accountId: defaultAccount.id, // Only consider default account type: "EXPENSE", date: { gte: startDate, }, }, _sum: { amount: true, }, }); const totalExpenses = expenses._sum.amount?.toNumber() || 0; const budgetAmount = budget.amount; const percentageUsed = (totalExpenses / budgetAmount) * 100; // Check if we should send an alert if ( percentageUsed >= 80 && // Default threshold of 80% (!budget.lastAlertSent || isNewMonth(new Date(budget.lastAlertSent), new Date())) ) { await sendEmail({ to: budget.user.email, subject: `Budget Alert for ${defaultAccount.name}`, react: EmailTemplate({ userName: budget.user.name, type: "budget-alert", data: { percentageUsed, budgetAmount: parseInt(budgetAmount).toFixed(1), totalExpenses: parseInt(totalExpenses).toFixed(1), accountName: defaultAccount.name, }, }), }); // Update last alert sent await db.budget.update({ where: { id: budget.id }, data: { lastAlertSent: new Date() }, }); } }); } } ); function isNewMonth(lastAlertDate, currentDate) { return ( lastAlertDate.getMonth() !== currentDate.getMonth() || lastAlertDate.getFullYear() !== currentDate.getFullYear() ); } // Utility functions function isTransactionDue(transaction) { // If no lastProcessed date, transaction is due if (!transaction.lastProcessed) return true; const today = new Date(); const nextDue = new Date(transaction.nextRecurringDate); // Compare with nextDue date return nextDue <= today; } function calculateNextRecurringDate(date, interval) { const next = new Date(date); switch (interval) { case "DAILY": next.setDate(next.getDate() + 1); break; case "WEEKLY": next.setDate(next.getDate() + 7); break; case "MONTHLY": next.setMonth(next.getMonth() + 1); break; case "YEARLY": next.setFullYear(next.getFullYear() + 1); break; } return next; } async function getMonthlyStats(userId, month) { const startDate = new Date(month.getFullYear(), month.getMonth(), 1); const endDate = new Date(month.getFullYear(), month.getMonth() + 1, 0); const transactions = await db.transaction.findMany({ where: { userId, date: { gte: startDate, lte: endDate, }, }, }); return transactions.reduce( (stats, t) => { const amount = t.amount.toNumber(); if (t.type === "EXPENSE") { stats.totalExpenses += amount; stats.byCategory[t.category] = (stats.byCategory[t.category] || 0) + amount; } else { stats.totalIncome += amount; } return stats; }, { totalExpenses: 0, totalIncome: 0, byCategory: {}, transactionCount: transactions.length, } ); } ================================================ FILE: lib/prisma.js ================================================ import { PrismaClient } from "@prisma/client"; export const db = globalThis.prisma || new PrismaClient(); if (process.env.NODE_ENV !== "production") { globalThis.prisma = db; } // globalThis.prisma: This global variable ensures that the Prisma client instance is // reused across hot reloads during development. Without this, each time your application // reloads, a new instance of the Prisma client would be created, potentially leading // to connection issues. ================================================ FILE: lib/utils.js ================================================ import { clsx } from "clsx"; import { twMerge } from "tailwind-merge" export function cn(...inputs) { return twMerge(clsx(inputs)); } ================================================ FILE: middleware.js ================================================ import arcjet, { createMiddleware, detectBot, shield } from "@arcjet/next"; import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; const isProtectedRoute = createRouteMatcher([ "/dashboard(.*)", "/account(.*)", "/transaction(.*)", ]); // Create Arcjet middleware const aj = arcjet({ key: process.env.ARCJET_KEY, // characteristics: ["userId"], // Track based on Clerk userId rules: [ // Shield protection for content and security shield({ mode: "LIVE", }), detectBot({ mode: "LIVE", // will block requests. Use "DRY_RUN" to log only allow: [ "CATEGORY:SEARCH_ENGINE", // Google, Bing, etc "GO_HTTP", // For Inngest // See the full list at https://arcjet.com/bot-list ], }), ], }); // Create base Clerk middleware const clerk = clerkMiddleware(async (auth, req) => { const { userId } = await auth(); if (!userId && isProtectedRoute(req)) { const { redirectToSignIn } = await auth(); return redirectToSignIn(); } return NextResponse.next(); }); // Chain middlewares - ArcJet runs first, then Clerk export default createMiddleware(aj, clerk); export const config = { matcher: [ // Skip Next.js internals and all static files, unless found in search params "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", // Always run for API routes "/(api|trpc)(.*)", ], }; ================================================ FILE: next.config.mjs ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "randomuser.me", }, ], }, experimental: { serverActions: { bodySizeLimit: "5mb", }, }, }; export default nextConfig; ================================================ FILE: package.json ================================================ { "name": "finance-platform", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint", "email": "email dev", "postinstall": "prisma generate" }, "dependencies": { "@arcjet/next": "^1.0.0-alpha.34", "@clerk/nextjs": "^6.6.0", "@google/generative-ai": "^0.21.0", "@hookform/resolvers": "^3.9.1", "@prisma/client": "^6.0.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.4", "@react-email/components": "0.0.30", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "inngest": "^3.27.4", "lucide-react": "^0.462.0", "next": "15.0.5", "next-themes": "^0.4.3", "react": "^19.0.0-rc-66855b96-20241106", "react-day-picker": "^8.10.1", "react-dom": "^19.0.0-rc-66855b96-20241106", "react-hook-form": "^7.53.2", "react-spinners": "^0.14.1", "recharts": "^2.14.1", "resend": "^4.0.1", "sonner": "^1.7.0", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.1", "zod": "^3.23.8" }, "devDependencies": { "eslint": "^8", "eslint-config-next": "15.0.3", "postcss": "^8", "prisma": "^6.0.1", "react-email": "3.0.3", "tailwindcss": "^3.4.1" } } ================================================ FILE: postcss.config.mjs ================================================ /** @type {import('postcss-load-config').Config} */ const config = { plugins: { tailwindcss: {}, }, }; export default config; ================================================ FILE: prisma/migrations/20241204141034_init/migration.sql ================================================ -- CreateEnum CREATE TYPE "TransactionType" AS ENUM ('INCOME', 'EXPENSE'); -- CreateEnum CREATE TYPE "AccountType" AS ENUM ('CURRENT', 'SAVINGS'); -- CreateEnum CREATE TYPE "TransactionStatus" AS ENUM ('PENDING', 'COMPLETED', 'FAILED'); -- CreateEnum CREATE TYPE "BudgetPeriod" AS ENUM ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'); -- CreateEnum CREATE TYPE "RecurringInterval" AS ENUM ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'); -- CreateTable CREATE TABLE "users" ( "id" TEXT NOT NULL, "clerkUserId" TEXT NOT NULL, "email" TEXT NOT NULL, "name" TEXT, "imageUrl" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "users_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "accounts" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "type" "AccountType" NOT NULL, "balance" DECIMAL(65,30) NOT NULL DEFAULT 0, "currency" TEXT NOT NULL, "isDefault" BOOLEAN NOT NULL DEFAULT false, "userId" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "categories" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "type" "TransactionType" NOT NULL, "color" TEXT, "icon" TEXT, "userId" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "categories_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "transactions" ( "id" TEXT NOT NULL, "type" "TransactionType" NOT NULL, "amount" DECIMAL(65,30) NOT NULL, "currency" TEXT NOT NULL, "description" TEXT, "date" TIMESTAMP(3) NOT NULL, "receiptUrl" TEXT, "isRecurring" BOOLEAN NOT NULL DEFAULT false, "recurringInterval" "RecurringInterval", "nextRecurringDate" TIMESTAMP(3), "status" "TransactionStatus" NOT NULL DEFAULT 'COMPLETED', "notes" TEXT, "userId" TEXT NOT NULL, "categoryId" TEXT NOT NULL, "accountId" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "transactions_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "budgets" ( "id" TEXT NOT NULL, "amount" DECIMAL(65,30) NOT NULL, "currency" TEXT NOT NULL, "period" "BudgetPeriod" NOT NULL, "startDate" TIMESTAMP(3) NOT NULL, "endDate" TIMESTAMP(3), "userId" TEXT NOT NULL, "categoryId" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "budgets_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE UNIQUE INDEX "users_clerkUserId_key" ON "users"("clerkUserId"); -- CreateIndex CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); -- CreateIndex CREATE INDEX "accounts_userId_idx" ON "accounts"("userId"); -- CreateIndex CREATE INDEX "categories_userId_idx" ON "categories"("userId"); -- CreateIndex CREATE INDEX "transactions_userId_idx" ON "transactions"("userId"); -- CreateIndex CREATE INDEX "transactions_categoryId_idx" ON "transactions"("categoryId"); -- CreateIndex CREATE INDEX "transactions_accountId_idx" ON "transactions"("accountId"); -- CreateIndex CREATE INDEX "budgets_userId_idx" ON "budgets"("userId"); -- CreateIndex CREATE INDEX "budgets_categoryId_idx" ON "budgets"("categoryId"); -- AddForeignKey ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "categories" ADD CONSTRAINT "categories_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "transactions" ADD CONSTRAINT "transactions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "transactions" ADD CONSTRAINT "transactions_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "transactions" ADD CONSTRAINT "transactions_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "budgets" ADD CONSTRAINT "budgets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "budgets" ADD CONSTRAINT "budgets_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE; ================================================ FILE: prisma/migrations/20241205074927_remove_currency/migration.sql ================================================ /* Warnings: - You are about to drop the column `currency` on the `accounts` table. All the data in the column will be lost. - You are about to drop the column `currency` on the `budgets` table. All the data in the column will be lost. - You are about to drop the column `currency` on the `transactions` table. All the data in the column will be lost. */ -- AlterTable ALTER TABLE "accounts" DROP COLUMN "currency"; -- AlterTable ALTER TABLE "budgets" DROP COLUMN "currency"; -- AlterTable ALTER TABLE "transactions" DROP COLUMN "currency"; ================================================ FILE: prisma/migrations/20241205094020_remove_categories/migration.sql ================================================ /* Warnings: - You are about to drop the column `categoryId` on the `budgets` table. All the data in the column will be lost. - You are about to drop the column `categoryId` on the `transactions` table. All the data in the column will be lost. - You are about to drop the `categories` table. If the table is not empty, all the data it contains will be lost. - Added the required column `category` to the `budgets` table without a default value. This is not possible if the table is not empty. - Added the required column `category` to the `transactions` table without a default value. This is not possible if the table is not empty. */ -- DropForeignKey ALTER TABLE "budgets" DROP CONSTRAINT "budgets_categoryId_fkey"; -- DropForeignKey ALTER TABLE "categories" DROP CONSTRAINT "categories_userId_fkey"; -- DropForeignKey ALTER TABLE "transactions" DROP CONSTRAINT "transactions_categoryId_fkey"; -- DropIndex DROP INDEX "budgets_categoryId_idx"; -- DropIndex DROP INDEX "transactions_categoryId_idx"; -- AlterTable ALTER TABLE "budgets" DROP COLUMN "categoryId", ADD COLUMN "category" TEXT NOT NULL; -- AlterTable ALTER TABLE "transactions" DROP COLUMN "categoryId", ADD COLUMN "category" TEXT NOT NULL; -- DropTable DROP TABLE "categories"; ================================================ FILE: prisma/migrations/20241205094352_remove_categories/migration.sql ================================================ /* Warnings: - You are about to drop the column `category` on the `budgets` table. All the data in the column will be lost. */ -- AlterTable ALTER TABLE "budgets" DROP COLUMN "category"; ================================================ FILE: prisma/migrations/20241206121749_budget/migration.sql ================================================ /* Warnings: - You are about to drop the column `endDate` on the `budgets` table. All the data in the column will be lost. - You are about to drop the column `period` on the `budgets` table. All the data in the column will be lost. - You are about to drop the column `startDate` on the `budgets` table. All the data in the column will be lost. - A unique constraint covering the columns `[userId]` on the table `budgets` will be added. If there are existing duplicate values, this will fail. */ -- DropIndex DROP INDEX "budgets_userId_idx"; -- AlterTable ALTER TABLE "budgets" DROP COLUMN "endDate", DROP COLUMN "period", DROP COLUMN "startDate"; -- CreateIndex CREATE UNIQUE INDEX "budgets_userId_key" ON "budgets"("userId"); ================================================ FILE: prisma/migrations/20241208092553_budget/migration.sql ================================================ -- DropIndex DROP INDEX "budgets_userId_key"; -- AlterTable ALTER TABLE "budgets" ADD COLUMN "lastAlertSent" TIMESTAMP(3); -- AlterTable ALTER TABLE "transactions" ADD COLUMN "lastProcessed" TIMESTAMP(3); -- DropEnum DROP TYPE "BudgetPeriod"; -- CreateIndex CREATE INDEX "budgets_userId_idx" ON "budgets"("userId"); ================================================ FILE: prisma/migrations/20241208122341_budget/migration.sql ================================================ /* Warnings: - A unique constraint covering the columns `[userId]` on the table `budgets` will be added. If there are existing duplicate values, this will fail. */ -- CreateIndex CREATE UNIQUE INDEX "budgets_userId_key" ON "budgets"("userId"); ================================================ FILE: prisma/migrations/20241209133842_remove/migration.sql ================================================ /* Warnings: - You are about to drop the column `notes` on the `transactions` table. All the data in the column will be lost. */ -- AlterTable ALTER TABLE "transactions" DROP COLUMN "notes"; ================================================ FILE: prisma/migrations/migration_lock.toml ================================================ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) provider = "postgresql" ================================================ FILE: prisma/schema.prisma ================================================ generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") } model User { id String @id @default(uuid()) clerkUserId String @unique // clerk user id email String @unique name String? imageUrl String? transactions Transaction[] accounts Account[] budgets Budget[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("users") } model Account { id String @id @default(uuid()) name String type AccountType balance Decimal @default(0) // will ask inital balance while creating an account isDefault Boolean @default(false) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) transactions Transaction[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([userId]) @@map("accounts") } model Transaction { id String @id @default(uuid()) type TransactionType amount Decimal description String? date DateTime category String receiptUrl String? isRecurring Boolean @default(false) recurringInterval RecurringInterval? // Only used if isRecurring is true nextRecurringDate DateTime? // Next date for recurring transaction lastProcessed DateTime? // Last time this recurring transaction was processed status TransactionStatus @default(COMPLETED) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) accountId String account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([userId]) @@index([accountId]) @@map("transactions") } model Budget { id String @id @default(uuid()) amount Decimal lastAlertSent DateTime? // Track when the last alert was sent userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([userId]) @@map("budgets") } enum TransactionType { INCOME EXPENSE } enum AccountType { CURRENT SAVINGS } enum TransactionStatus { PENDING COMPLETED FAILED } enum RecurringInterval { DAILY WEEKLY MONTHLY YEARLY } ================================================ FILE: tailwind.config.js ================================================ /** @type {import('tailwindcss').Config} */ module.exports = { darkMode: ["class"], content: [ "./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { colors: { background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))', card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' }, popover: { DEFAULT: 'hsl(var(--popover))', foreground: 'hsl(var(--popover-foreground))' }, primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' }, secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' }, muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' }, accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' }, destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' }, border: 'hsl(var(--border))', input: 'hsl(var(--input))', ring: 'hsl(var(--ring))', chart: { '1': 'hsl(var(--chart-1))', '2': 'hsl(var(--chart-2))', '3': 'hsl(var(--chart-3))', '4': 'hsl(var(--chart-4))', '5': 'hsl(var(--chart-5))' } }, borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' } } }, plugins: [require("tailwindcss-animate")], };