├── .env.example ├── .gitignore ├── LICENSE.md ├── README.md ├── components.json ├── content └── introducing-acme-ai.mdx ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── prisma └── schema.prisma ├── public ├── author.png ├── dashboard.png ├── introducing.png └── og.png ├── src ├── actions │ ├── account │ │ ├── create-new-account.ts │ │ ├── delete-account.ts │ │ ├── get-account.ts │ │ ├── get-accounts.ts │ │ ├── process-csv-transactions.ts │ │ └── update-account.ts │ ├── assets │ │ ├── create-asset.ts │ │ ├── delete-asset.ts │ │ ├── get-asset.ts │ │ ├── get-assets-overview.ts │ │ └── update-asset.ts │ ├── banking │ │ ├── delete-bank-account.ts │ │ ├── get-account-details.ts │ │ ├── get-banking-overview.ts │ │ └── update-bank-account.ts │ ├── budgets │ │ ├── create-budget.ts │ │ ├── create-default-budget.ts │ │ ├── delete-budget.ts │ │ ├── get-budget-progress.ts │ │ ├── get-budget.ts │ │ ├── update-budget.ts │ │ └── update-category-budget.ts │ ├── categories │ │ ├── create-category.ts │ │ ├── create-default-budget.ts │ │ ├── delete-category.ts │ │ ├── get-categories-overview.ts │ │ ├── get-categories.ts │ │ ├── get-category-budgets.ts │ │ ├── manage-categories.ts │ │ ├── update-category.ts │ │ └── validate-transaction-category.ts │ ├── currency │ │ └── seed-currencies.ts │ ├── dashboard │ │ └── get-dashboard-overview.ts │ ├── investments │ │ ├── create-investment.ts │ │ ├── delete-investment.ts │ │ ├── get-investment.ts │ │ ├── get-investments-overview.ts │ │ └── update-investment.ts │ ├── liabilities │ │ ├── create-liability.ts │ │ ├── delete-liability.ts │ │ ├── get-liabilities-overview.ts │ │ ├── get-liability.ts │ │ └── update-liability.ts │ ├── savings │ │ ├── create-default-goals.ts │ │ ├── create-savings-goal.ts │ │ ├── delete-savings-goal.ts │ │ ├── get-savings-overview.ts │ │ └── update-savings-goal.ts │ ├── stats │ │ └── increment-user-stats.ts │ └── user │ │ ├── create-new-user.ts │ │ └── get-current-user.ts ├── app │ ├── (auth) │ │ ├── layout.tsx │ │ ├── login │ │ │ └── page.tsx │ │ └── signup │ │ │ └── page.tsx │ ├── (main) │ │ ├── assets │ │ │ └── page.tsx │ │ ├── banking │ │ │ ├── [id] │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── categories │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ └── page.tsx │ │ ├── investments │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── liabilities │ │ │ └── page.tsx │ │ ├── savings │ │ │ └── page.tsx │ │ └── template │ │ │ └── page.tsx │ ├── (marketing) │ │ └── blog │ │ │ ├── [slug] │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── og │ │ └── route.tsx │ ├── page.tsx │ └── sitemap.ts ├── assets │ └── fonts │ │ └── Inter-SemiBold.ttf ├── components │ ├── account-connection.tsx │ ├── app-sidebar.tsx │ ├── assets │ │ ├── add-asset.tsx │ │ ├── asset-allocation.tsx │ │ ├── asset-cards.tsx │ │ ├── asset-table.tsx │ │ └── total-assets-chart.tsx │ ├── avatar-circles.tsx │ ├── banking │ │ ├── bank-account-chart.tsx │ │ └── sidebar-right.tsx │ ├── blog-author.tsx │ ├── blog-card.tsx │ ├── breadcrumbs.tsx │ ├── categories │ │ ├── budget-summary.tsx │ │ ├── category-chart.tsx │ │ ├── category-list.tsx │ │ ├── category-select.tsx │ │ ├── category-trends.tsx │ │ ├── create-budget-button.tsx │ │ ├── edit-budget.tsx │ │ └── recent-transactions.tsx │ ├── dashboard │ │ ├── metric-cards.tsx │ │ └── top-charts.tsx │ ├── drawer.tsx │ ├── empty-placeholder.tsx │ ├── features-horizontal.tsx │ ├── features-vertical.tsx │ ├── icons.tsx │ ├── investments │ │ ├── add-investment.tsx │ │ ├── balance-chart.tsx │ │ ├── dashboard.tsx │ │ ├── portfolio-allocation.tsx │ │ └── positions-table.tsx │ ├── liabilities │ │ ├── add-liability.tsx │ │ ├── liability-allocation.tsx │ │ ├── liability-cards.tsx │ │ ├── liability-table.tsx │ │ └── total-liabilities-chart.tsx │ ├── magicui │ │ ├── blur-fade.tsx │ │ ├── border-beam.tsx │ │ ├── dot-pattern.tsx │ │ ├── flickering-grid.tsx │ │ ├── hero-video.tsx │ │ ├── marquee.tsx │ │ └── ripple.tsx │ ├── menu.tsx │ ├── nav-main.tsx │ ├── nav-projects.tsx │ ├── nav-secondary.tsx │ ├── nav-user.tsx │ ├── pie-chart.tsx │ ├── radial-chart.tsx │ ├── safari.tsx │ ├── savings │ │ ├── add-savings-goal.tsx │ │ ├── manage-savings-goals.tsx │ │ ├── savings-actions.tsx │ │ ├── savings-goals.tsx │ │ ├── savings-overview.tsx │ │ ├── savings-recommendations.tsx │ │ └── savings-tips.tsx │ ├── section.tsx │ ├── sections │ │ ├── blog.tsx │ │ ├── cta.tsx │ │ ├── faq.tsx │ │ ├── features.tsx │ │ ├── footer.tsx │ │ ├── header.tsx │ │ ├── hero.tsx │ │ ├── how-it-works.tsx │ │ ├── logos.tsx │ │ ├── pricing.tsx │ │ ├── problem.tsx │ │ ├── solution.tsx │ │ ├── testimonials-carousel.tsx │ │ └── testimonials.tsx │ ├── stock-cards.tsx │ ├── stock-detail.tsx │ ├── tailwind-indicator.tsx │ ├── theme-provider.tsx │ ├── theme-toggle.tsx │ ├── transactions │ │ ├── account-transactions.tsx │ │ ├── balance-tooltip.tsx │ │ ├── bank-account-overview.tsx │ │ └── bank-account-selector.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── navigation-menu.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ └── tooltip.tsx ├── hooks │ └── use-mobile.tsx ├── lib │ ├── blog.ts │ ├── config.tsx │ ├── config │ │ └── categories.ts │ ├── db.ts │ ├── hooks │ │ └── use-window-size.ts │ ├── utils.ts │ └── utils │ │ └── transaction-categorization.ts ├── middleware.ts └── schemas │ └── bank-account-schema.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Database connection URLs for Neon PostgreSQL 2 | DATABASE_URL= 3 | DATABASE_URL_UNPOOLED= 4 | 5 | # Clerk authentication keys 6 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 7 | CLERK_SECRET_KEY= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # content collection 40 | .content-collections 41 | 42 | # turbo 43 | .turbo 44 | 45 | # Prisma 46 | /prisma/*.db 47 | /prisma/migrations/ 48 | .env -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /content/introducing-acme-ai.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introducing Badget.ai 3 | publishedAt: "2024-08-29" 4 | summary: Introducing Badget.ai, a cutting-edge AI solution for modern businesses. 5 | author: "codehagen" 6 | image: "/introducing.png" 7 | --- 8 | 9 | We're excited to unveil **Badget.ai**, an innovative AI-powered platform designed to transform your business operations and skyrocket productivity. 🚀 10 | 11 | ## The Challenge We're Addressing 12 | 13 | In today's AI-driven world, businesses face several hurdles: 14 | 15 | - Overwhelming data analysis 16 | - Inefficient decision-making processes 17 | - Difficulty in predicting market trends 18 | 19 | Badget.ai tackles these challenges head-on, offering a sophisticated AI solution that simplifies complex business processes. 20 | 21 | ## Our Mission 22 | 23 | 1. **Accelerate Decision-Making**: By leveraging AI to analyze vast datasets, we help you make informed decisions faster. 24 | 2. **Enhance Forecasting**: Our advanced predictive models provide accurate insights into future trends. 25 | 3. **Optimize Operations**: With AI-driven recommendations, streamline your business processes effortlessly. 26 | 27 | ## Core Capabilities 28 | 29 | - **AI-Powered Dashboard**: Get real-time, AI-interpreted insights at a glance 30 | - **Predictive Analytics**: Forecast trends and make data-driven decisions 31 | - **Natural Language Processing**: Interact with your data using simple language queries 32 | - **Automated Reporting**: Generate comprehensive reports with a single click 33 | - **Customizable AI Models**: Tailor the AI to your specific industry needs 34 | 35 | ## Why Badget.ai Stands Out 36 | 37 | > "Badget.ai has revolutionized our strategic planning. It's like having a crystal ball for our business!" - John Smith, CFO of FutureTech 38 | 39 | Our AI solution isn't just a tool; it's your competitive edge. Here's how we compare: 40 | 41 | | Feature | Badget.ai | Traditional BI Tools | 42 | | ------------------------ | ------- | -------------------- | 43 | | AI-Powered Insights | ✅ | ❌ | 44 | | Predictive Capabilities | ✅ | ❌ | 45 | | Natural Language Queries | ✅ | ❌ | 46 | 47 | ## Embarking on Your AI Journey 48 | 49 | Getting started with Badget.ai is seamless: 50 | 51 | 1. Sign up for a demo 52 | 2. Integrate your data sources 53 | 3. Start unlocking AI-driven insights 54 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [{ hostname: "localhost" }, { hostname: "randomuser.me" }], 5 | }, 6 | eslint: { ignoreDuringBuilds: true }, 7 | typescript: { ignoreBuildErrors: true }, 8 | }; 9 | 10 | export default nextConfig; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "badget", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "prisma generate && next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "db:generate": "prisma generate", 11 | "db:push": "prisma db push", 12 | "db:studio": "prisma studio", 13 | "db:reset": "prisma db push --force-reset", 14 | "db:deploy": "prisma generate && prisma db push", 15 | "postinstall": "prisma generate" 16 | }, 17 | "dependencies": { 18 | "@clerk/nextjs": "^6.4.0", 19 | "@hookform/resolvers": "^3.9.1", 20 | "@prisma/client": "^5.22.0", 21 | "@radix-ui/react-accordion": "^1.2.0", 22 | "@radix-ui/react-alert-dialog": "^1.1.2", 23 | "@radix-ui/react-avatar": "^1.1.1", 24 | "@radix-ui/react-collapsible": "^1.1.1", 25 | "@radix-ui/react-dialog": "^1.1.2", 26 | "@radix-ui/react-dropdown-menu": "^2.1.2", 27 | "@radix-ui/react-label": "^2.1.0", 28 | "@radix-ui/react-navigation-menu": "^1.2.0", 29 | "@radix-ui/react-popover": "^1.1.2", 30 | "@radix-ui/react-progress": "^1.1.0", 31 | "@radix-ui/react-select": "^2.1.2", 32 | "@radix-ui/react-separator": "^1.1.0", 33 | "@radix-ui/react-slot": "^1.1.0", 34 | "@radix-ui/react-switch": "^1.1.0", 35 | "@radix-ui/react-tabs": "^1.1.1", 36 | "@radix-ui/react-tooltip": "^1.1.4", 37 | "class-variance-authority": "^0.7.0", 38 | "clerk": "^0.8.3", 39 | "clsx": "^2.1.1", 40 | "cmdk": "1.0.0", 41 | "date-fns": "^4.1.0", 42 | "embla-carousel-react": "^8.1.7", 43 | "framer-motion": "^11.3.21", 44 | "lucide-react": "^0.417.0", 45 | "next": "14.2.7", 46 | "next-themes": "^0.3.0", 47 | "prisma": "^5.22.0", 48 | "react": "^18.3.1", 49 | "react-day-picker": "8.10.1", 50 | "react-dom": "^18.3.1", 51 | "react-hook-form": "^7.53.2", 52 | "react-icons": "^5.2.1", 53 | "recharts": "^2.13.3", 54 | "rehype-pretty-code": "^0.13.2", 55 | "rehype-stringify": "^10.0.0", 56 | "remark-gfm": "^4.0.0", 57 | "remark-parse": "^11.0.0", 58 | "remark-rehype": "^11.1.0", 59 | "sonner": "^1.7.0", 60 | "tailwind-merge": "^2.4.0", 61 | "tailwindcss-animate": "^1.0.7", 62 | "unified": "^11.0.5", 63 | "vaul": "^0.9.1", 64 | "zod": "^3.23.8" 65 | }, 66 | "devDependencies": { 67 | "@tailwindcss/typography": "^0.5.13", 68 | "@types/node": "^20", 69 | "@types/react": "^18", 70 | "@types/react-dom": "^18", 71 | "eslint": "^8", 72 | "eslint-config-next": "14.2.7", 73 | "postcss": "^8", 74 | "tailwindcss": "^3.4.1", 75 | "typescript": "^5" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/author.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codehagen/Badget/44fd588e8856f9ba3ea276e06f143623fbc37585/public/author.png -------------------------------------------------------------------------------- /public/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codehagen/Badget/44fd588e8856f9ba3ea276e06f143623fbc37585/public/dashboard.png -------------------------------------------------------------------------------- /public/introducing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codehagen/Badget/44fd588e8856f9ba3ea276e06f143623fbc37585/public/introducing.png -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codehagen/Badget/44fd588e8856f9ba3ea276e06f143623fbc37585/public/og.png -------------------------------------------------------------------------------- /src/actions/account/delete-account.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | const deleteAccountSchema = z.object({ 9 | id: z.string().min(1, "Account ID is required"), 10 | }); 11 | 12 | export async function deleteAccount( 13 | input: z.infer 14 | ) { 15 | try { 16 | console.log("Starting account deletion process"); 17 | 18 | const { userId } = await auth(); 19 | if (!userId) { 20 | throw new Error("Unauthorized: No user found"); 21 | } 22 | 23 | const validatedFields = deleteAccountSchema.parse(input); 24 | 25 | // Verify account ownership 26 | const existingAccount = await prisma.bankAccount.findFirst({ 27 | where: { 28 | id: validatedFields.id, 29 | userId, 30 | }, 31 | }); 32 | 33 | if (!existingAccount) { 34 | throw new Error("Account not found or unauthorized"); 35 | } 36 | 37 | // Delete the account (cascade will handle related records) 38 | await prisma.bankAccount.delete({ 39 | where: { id: validatedFields.id }, 40 | }); 41 | 42 | revalidatePath("/dashboard"); 43 | return { success: true }; 44 | } catch (error) { 45 | console.error("Error in deleteAccount:", error); 46 | if (error instanceof z.ZodError) { 47 | return { success: false, error: error.errors }; 48 | } 49 | if (error instanceof Error) { 50 | return { success: false, error: error.message }; 51 | } 52 | return { 53 | success: false, 54 | error: "An unexpected error occurred while deleting the account", 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/actions/account/get-account.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { z } from "zod"; 6 | 7 | const getAccountSchema = z.object({ 8 | id: z.string().min(1, "Account ID is required"), 9 | }); 10 | 11 | export async function getAccount(input: z.infer) { 12 | try { 13 | console.log("Starting account fetch process"); 14 | 15 | const { userId } = await auth(); 16 | if (!userId) { 17 | throw new Error("Unauthorized: No user found"); 18 | } 19 | 20 | const validatedFields = getAccountSchema.parse(input); 21 | 22 | const account = await prisma.bankAccount.findFirst({ 23 | where: { 24 | id: validatedFields.id, 25 | userId, 26 | }, 27 | include: { 28 | Balance: { 29 | orderBy: { date: "desc" }, 30 | take: 1, 31 | }, 32 | Transaction: { 33 | orderBy: { date: "desc" }, 34 | take: 10, 35 | include: { 36 | category: true, 37 | currency: true, 38 | }, 39 | }, 40 | }, 41 | }); 42 | 43 | if (!account) { 44 | throw new Error("Account not found or unauthorized"); 45 | } 46 | 47 | return { success: true, account }; 48 | } catch (error) { 49 | console.error("Error in getAccount:", error); 50 | if (error instanceof z.ZodError) { 51 | return { success: false, error: error.errors }; 52 | } 53 | if (error instanceof Error) { 54 | return { success: false, error: error.message }; 55 | } 56 | return { 57 | success: false, 58 | error: "An unexpected error occurred while fetching the account", 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/actions/account/get-accounts.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | 6 | export async function getAccounts() { 7 | try { 8 | console.log("Starting accounts fetch process"); 9 | 10 | const { userId } = await auth(); 11 | if (!userId) { 12 | throw new Error("Unauthorized: No user found"); 13 | } 14 | 15 | const accounts = await prisma.bankAccount.findMany({ 16 | where: { userId }, 17 | include: { 18 | Balance: { 19 | orderBy: { date: "desc" }, 20 | take: 1, 21 | }, 22 | Transaction: { 23 | orderBy: { date: "desc" }, 24 | take: 1, 25 | }, 26 | resource: { 27 | select: { 28 | integration: { 29 | select: { 30 | name: true, 31 | logoUrl: true, 32 | }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | orderBy: { name: "asc" }, 38 | }); 39 | 40 | return { success: true, accounts }; 41 | } catch (error) { 42 | console.error("Error in getAccounts:", error); 43 | if (error instanceof Error) { 44 | return { success: false, error: error.message }; 45 | } 46 | return { 47 | success: false, 48 | error: "An unexpected error occurred while fetching accounts", 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/actions/account/process-csv-transactions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | 6 | interface CSVMapping { 7 | date: number; 8 | description: number; 9 | amount: number; 10 | } 11 | 12 | interface ProcessCSVInput { 13 | accountId: string; 14 | currencyIso: string; 15 | csvContent: string; 16 | mapping: CSVMapping; 17 | } 18 | 19 | export async function processCSVTransactions(input: ProcessCSVInput) { 20 | try { 21 | const { userId } = await auth(); 22 | if (!userId) { 23 | throw new Error("Unauthorized: No user found"); 24 | } 25 | 26 | const rows = input.csvContent 27 | .split("\n") 28 | .slice(1) // Skip header row 29 | .filter((row) => row.trim() !== ""); // Remove empty rows 30 | 31 | const transactions = rows.map((row) => { 32 | const columns = row.split(",").map((col) => col.trim()); 33 | 34 | // Parse date - assuming format YYYY-MM-DD, adjust as needed 35 | const dateStr = columns[input.mapping.date]; 36 | const date = new Date(dateStr); 37 | 38 | // Parse amount - convert to decimal 39 | const amountStr = columns[input.mapping.amount].replace(/[^0-9.-]/g, ""); 40 | const amount = parseFloat(amountStr); 41 | 42 | // Get description 43 | const description = columns[input.mapping.description]; 44 | 45 | return { 46 | accountId: input.accountId, 47 | currencyIso: input.currencyIso, 48 | amount, 49 | date, 50 | description, 51 | review: false, 52 | }; 53 | }); 54 | 55 | // Create transactions in batches of 100 56 | const batchSize = 100; 57 | for (let i = 0; i < transactions.length; i += batchSize) { 58 | const batch = transactions.slice(i, i + batchSize); 59 | await prisma.transaction.createMany({ 60 | data: batch, 61 | }); 62 | } 63 | 64 | // Update account balance 65 | const totalAmount = transactions.reduce((sum, t) => sum + t.amount, 0); 66 | await prisma.bankAccount.update({ 67 | where: { id: input.accountId }, 68 | data: { 69 | initialAmount: totalAmount, 70 | }, 71 | }); 72 | 73 | return { 74 | success: true, 75 | transactionCount: transactions.length, 76 | totalAmount, 77 | }; 78 | } catch (error) { 79 | console.error("Error processing CSV:", error); 80 | return { 81 | success: false, 82 | error: (error as Error).message, 83 | }; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/actions/account/update-account.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { AccountType } from "@prisma/client"; 7 | import { z } from "zod"; 8 | 9 | const updateAccountSchema = z.object({ 10 | id: z.string().min(1, "Account ID is required"), 11 | name: z.string().min(2, "Account name must be at least 2 characters"), 12 | accountNumber: z.string(), 13 | bankName: z.string(), 14 | currency: z.string().min(3, "Currency code must be 3 characters"), 15 | accountType: z.enum(["BANK", "CRYPTO", "INVESTMENT"]).default("BANK"), 16 | }); 17 | 18 | export async function updateAccount( 19 | input: z.infer 20 | ) { 21 | try { 22 | console.log("Starting account update process"); 23 | 24 | const { userId } = await auth(); 25 | if (!userId) { 26 | throw new Error("Unauthorized: No user found"); 27 | } 28 | 29 | // Get user's active workspace 30 | const user = await prisma.user.findUnique({ 31 | where: { id: userId }, 32 | include: { 33 | workspace: true, 34 | }, 35 | }); 36 | 37 | if (!user?.workspace) { 38 | throw new Error("No workspace found"); 39 | } 40 | 41 | const validatedFields = updateAccountSchema.parse(input); 42 | 43 | // Verify account ownership 44 | const existingAccount = await prisma.bankAccount.findFirst({ 45 | where: { 46 | id: validatedFields.id, 47 | userId, 48 | }, 49 | }); 50 | 51 | if (!existingAccount) { 52 | throw new Error("Account not found or unauthorized"); 53 | } 54 | 55 | // Update the account 56 | const account = await prisma.bankAccount.update({ 57 | where: { id: validatedFields.id }, 58 | data: { 59 | name: validatedFields.name, 60 | accountType: validatedFields.accountType as AccountType, 61 | originalId: validatedFields.accountNumber, 62 | originalPayload: { 63 | bankName: validatedFields.bankName, 64 | currency: validatedFields.currency, 65 | updatedAt: new Date(), 66 | }, 67 | }, 68 | }); 69 | 70 | revalidatePath("/dashboard"); 71 | return { success: true, account }; 72 | } catch (error) { 73 | console.error("Error in updateAccount:", error); 74 | if (error instanceof z.ZodError) { 75 | return { success: false, error: error.errors }; 76 | } 77 | if (error instanceof Error) { 78 | return { success: false, error: error.message }; 79 | } 80 | return { 81 | success: false, 82 | error: "An unexpected error occurred while updating the account", 83 | }; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/actions/assets/delete-asset.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | const deleteAssetSchema = z.object({ 9 | id: z.string().min(1, "Asset ID is required"), 10 | }); 11 | 12 | export async function deleteAsset(input: z.infer) { 13 | try { 14 | console.log("Starting asset deletion process"); 15 | 16 | const { userId } = await auth(); 17 | if (!userId) { 18 | throw new Error("Unauthorized: No user found"); 19 | } 20 | 21 | const validatedFields = deleteAssetSchema.parse(input); 22 | 23 | // Verify asset ownership 24 | const existingAsset = await prisma.asset.findFirst({ 25 | where: { 26 | id: validatedFields.id, 27 | userId: userId, 28 | }, 29 | }); 30 | 31 | if (!existingAsset) { 32 | throw new Error("Asset not found or unauthorized"); 33 | } 34 | 35 | // Delete the asset (cascade will handle related records) 36 | await prisma.asset.delete({ 37 | where: { id: validatedFields.id }, 38 | }); 39 | 40 | revalidatePath("/assets"); 41 | return { success: true }; 42 | } catch (error) { 43 | console.error("Error in deleteAsset:", error); 44 | if (error instanceof z.ZodError) { 45 | return { success: false, error: error.errors }; 46 | } 47 | if (error instanceof Error) { 48 | return { success: false, error: error.message }; 49 | } 50 | return { 51 | success: false, 52 | error: "An unexpected error occurred while deleting the asset", 53 | }; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/actions/assets/get-asset.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { z } from "zod"; 6 | 7 | const getAssetSchema = z.object({ 8 | id: z.string().min(1, "Asset ID is required"), 9 | }); 10 | 11 | export async function getAsset(input: z.infer) { 12 | try { 13 | console.log("Starting asset fetch process"); 14 | 15 | const { userId } = await auth(); 16 | if (!userId) { 17 | throw new Error("Unauthorized: No user found"); 18 | } 19 | 20 | const validatedFields = getAssetSchema.parse(input); 21 | 22 | const asset = await prisma.asset.findFirst({ 23 | where: { 24 | id: validatedFields.id, 25 | userId: userId, 26 | }, 27 | include: { 28 | valuations: { 29 | orderBy: { date: "desc" }, 30 | take: 1, 31 | }, 32 | transactions: { 33 | orderBy: { date: "desc" }, 34 | }, 35 | }, 36 | }); 37 | 38 | if (!asset) { 39 | throw new Error("Asset not found or unauthorized"); 40 | } 41 | 42 | return { success: true, asset }; 43 | } catch (error) { 44 | console.error("Error in getAsset:", error); 45 | if (error instanceof z.ZodError) { 46 | return { success: false, error: error.errors }; 47 | } 48 | if (error instanceof Error) { 49 | return { success: false, error: error.message }; 50 | } 51 | return { 52 | success: false, 53 | error: "An unexpected error occurred while fetching the asset", 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/actions/banking/delete-bank-account.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | const deleteBankAccountSchema = z.object({ 9 | id: z.string().min(1, "Account ID is required"), 10 | }); 11 | 12 | export async function deleteBankAccount( 13 | input: z.infer 14 | ) { 15 | try { 16 | const { userId } = await auth(); 17 | if (!userId) { 18 | throw new Error("Unauthorized: No user found"); 19 | } 20 | 21 | const validatedFields = deleteBankAccountSchema.parse(input); 22 | 23 | // Verify account ownership 24 | const existingAccount = await prisma.bankAccount.findFirst({ 25 | where: { 26 | id: validatedFields.id, 27 | userId, 28 | }, 29 | }); 30 | 31 | if (!existingAccount) { 32 | throw new Error("Account not found or unauthorized"); 33 | } 34 | 35 | // Delete the account (cascade will handle related records) 36 | await prisma.bankAccount.delete({ 37 | where: { id: validatedFields.id }, 38 | }); 39 | 40 | revalidatePath("/banking"); 41 | return { success: true }; 42 | } catch (error) { 43 | console.error("Error in deleteBankAccount:", error); 44 | if (error instanceof z.ZodError) { 45 | return { success: false, error: error.errors }; 46 | } 47 | if (error instanceof Error) { 48 | return { success: false, error: error.message }; 49 | } 50 | return { 51 | success: false, 52 | error: "An unexpected error occurred while deleting the account", 53 | }; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/actions/banking/get-account-details.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | 6 | interface AccountDetails { 7 | account: { 8 | id: string; 9 | name: string; 10 | type: string; 11 | balance: number; 12 | accountNumber?: string; 13 | lastUpdated: Date; 14 | }; 15 | transactions: { 16 | id: string; 17 | date: Date; 18 | description: string; 19 | amount: number; 20 | type: string; 21 | categoryValidated?: boolean; 22 | category?: { 23 | id: string; 24 | name: string; 25 | icon: string; 26 | }; 27 | }[]; 28 | } 29 | 30 | export async function getAccountDetails( 31 | accountId: string 32 | ): Promise<{ success: boolean; data?: AccountDetails; error?: string }> { 33 | try { 34 | const { userId } = await auth(); 35 | if (!userId) { 36 | throw new Error("Unauthorized"); 37 | } 38 | 39 | // Get account with latest balance 40 | const account = await prisma.bankAccount.findFirst({ 41 | where: { 42 | id: accountId, 43 | userId, 44 | }, 45 | include: { 46 | Balance: { 47 | orderBy: { date: "desc" }, 48 | take: 1, 49 | }, 50 | }, 51 | }); 52 | 53 | if (!account) { 54 | throw new Error("Account not found"); 55 | } 56 | 57 | // Get transactions 58 | const transactions = await prisma.transaction.findMany({ 59 | where: { 60 | accountId, 61 | }, 62 | include: { 63 | category: { 64 | select: { 65 | id: true, 66 | name: true, 67 | icon: true, 68 | }, 69 | }, 70 | }, 71 | orderBy: { 72 | date: "desc", 73 | }, 74 | }); 75 | 76 | return { 77 | success: true, 78 | data: { 79 | account: { 80 | id: account.id, 81 | name: account.name, 82 | type: account.accountType, 83 | balance: account.Balance[0]?.amount || 0, 84 | accountNumber: account.originalId || undefined, 85 | lastUpdated: account.Balance[0]?.date || account.updatedAt, 86 | }, 87 | transactions: transactions.map((tx) => ({ 88 | id: tx.id, 89 | date: tx.date, 90 | description: tx.description, 91 | amount: Number(tx.amount), 92 | type: tx.type, 93 | categoryValidated: tx.categoryValidated, 94 | category: tx.category 95 | ? { 96 | id: tx.category.id, 97 | name: tx.category.name, 98 | icon: tx.category.icon, 99 | } 100 | : undefined, 101 | })), 102 | }, 103 | }; 104 | } catch (error) { 105 | console.error("Error in getAccountDetails:", error); 106 | return { 107 | success: false, 108 | error: "Failed to fetch account details", 109 | }; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/actions/banking/update-bank-account.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { AccountType } from "@prisma/client"; 7 | import { z } from "zod"; 8 | import { updateBankAccountSchema } from "@/schemas/bank-account-schema"; 9 | 10 | interface UpdateBankAccountInput { 11 | id: string; 12 | name: string; 13 | accountType: AccountType; 14 | description?: string; 15 | } 16 | 17 | export async function updateBankAccount(input: UpdateBankAccountInput) { 18 | try { 19 | const { userId } = await auth(); 20 | if (!userId) throw new Error("Unauthorized"); 21 | 22 | const existingAccount = await prisma.bankAccount.findUnique({ 23 | where: { id: input.id }, 24 | }); 25 | 26 | const currentPayload = 27 | (existingAccount?.originalPayload as Record) || {}; 28 | 29 | const account = await prisma.bankAccount.update({ 30 | where: { 31 | id: input.id, 32 | userId, 33 | }, 34 | data: { 35 | name: input.name, 36 | accountType: input.accountType, 37 | originalPayload: { 38 | ...currentPayload, 39 | description: input.description, 40 | updatedAt: new Date(), 41 | }, 42 | }, 43 | }); 44 | 45 | revalidatePath("/banking"); 46 | return { success: true, account }; 47 | } catch (error) { 48 | console.error("Error in updateBankAccount:", error); 49 | return { success: false, error: "Failed to update account" }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/actions/budgets/create-budget.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { Prisma } from "@prisma/client"; 7 | import { z } from "zod"; 8 | 9 | const createBudgetSchema = z.object({ 10 | name: z.string().optional(), 11 | startDate: z.date(), 12 | endDate: z.date(), 13 | amount: z.string().min(1, "Amount is required"), 14 | categories: z.array( 15 | z.object({ 16 | categoryId: z.string(), 17 | amount: z.string().min(1, "Category amount is required"), 18 | }) 19 | ), 20 | }); 21 | 22 | export async function createBudget(input: z.infer) { 23 | try { 24 | const { userId } = await auth(); 25 | if (!userId) { 26 | throw new Error("Unauthorized: No user found"); 27 | } 28 | 29 | const validatedFields = createBudgetSchema.parse(input); 30 | const amount = new Prisma.Decimal(validatedFields.amount); 31 | 32 | // Create budget with categories 33 | const budget = await prisma.budget.create({ 34 | data: { 35 | name: validatedFields.name, 36 | startDate: validatedFields.startDate, 37 | endDate: validatedFields.endDate, 38 | amount, 39 | userId, 40 | categories: { 41 | create: validatedFields.categories.map((cat) => ({ 42 | amount: new Prisma.Decimal(cat.amount), 43 | category: { 44 | connect: { id: cat.categoryId }, 45 | }, 46 | })), 47 | }, 48 | }, 49 | include: { 50 | categories: { 51 | include: { 52 | category: true, 53 | }, 54 | }, 55 | }, 56 | }); 57 | 58 | revalidatePath("/categories"); 59 | return { success: true, budget }; 60 | } catch (error) { 61 | console.error("Error in createBudget:", error); 62 | if (error instanceof z.ZodError) { 63 | return { success: false, error: error.errors }; 64 | } 65 | if (error instanceof Error) { 66 | return { success: false, error: error.message }; 67 | } 68 | return { 69 | success: false, 70 | error: "An unexpected error occurred while creating the budget", 71 | }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/actions/budgets/create-default-budget.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { Prisma } from "@prisma/client"; 7 | 8 | const defaultCategoryBudgets = { 9 | Housing: 2000, 10 | Food: 800, 11 | Transport: 400, 12 | Utilities: 300, 13 | Entertainment: 200, 14 | Healthcare: 300, 15 | Other: 200, 16 | }; 17 | 18 | export async function createDefaultBudget() { 19 | try { 20 | const { userId } = await auth(); 21 | if (!userId) { 22 | throw new Error("Unauthorized: No user found"); 23 | } 24 | 25 | // Create categories if they don't exist 26 | for (const [name, amount] of Object.entries(defaultCategoryBudgets)) { 27 | const category = await prisma.category.upsert({ 28 | where: { 29 | name_userId: { 30 | name, 31 | userId, 32 | }, 33 | }, 34 | create: { 35 | name, 36 | icon: name, 37 | userId, 38 | }, 39 | update: {}, 40 | }); 41 | 42 | // Create or update budget for this category 43 | await prisma.budget.upsert({ 44 | where: { 45 | id: `default-${userId}`, 46 | }, 47 | create: { 48 | userId, 49 | amount: new Prisma.Decimal(amount), 50 | startDate: new Date(), 51 | endDate: new Date(new Date().setMonth(new Date().getMonth() + 1)), 52 | categories: { 53 | create: { 54 | amount: new Prisma.Decimal(amount), 55 | category: { 56 | connect: { id: category.id }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | update: { 62 | categories: { 63 | upsert: { 64 | where: { 65 | budgetId_categoryId: { 66 | budgetId: `default-${userId}`, 67 | categoryId: category.id, 68 | }, 69 | }, 70 | create: { 71 | amount: new Prisma.Decimal(amount), 72 | category: { 73 | connect: { id: category.id }, 74 | }, 75 | }, 76 | update: { 77 | amount: new Prisma.Decimal(amount), 78 | }, 79 | }, 80 | }, 81 | }, 82 | }); 83 | } 84 | 85 | revalidatePath("/categories"); 86 | return { success: true }; 87 | } catch (error) { 88 | console.error("Error in createDefaultBudget:", error); 89 | if (error instanceof Error) { 90 | return { success: false, error: error.message }; 91 | } 92 | return { 93 | success: false, 94 | error: "An unexpected error occurred while creating the default budget", 95 | }; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/actions/budgets/delete-budget.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | const deleteBudgetSchema = z.object({ 9 | id: z.string().min(1, "Budget ID is required"), 10 | }); 11 | 12 | export async function deleteBudget(input: z.infer) { 13 | try { 14 | const { userId } = await auth(); 15 | if (!userId) { 16 | throw new Error("Unauthorized: No user found"); 17 | } 18 | 19 | const validatedFields = deleteBudgetSchema.parse(input); 20 | 21 | // Verify budget ownership 22 | const existingBudget = await prisma.budget.findFirst({ 23 | where: { 24 | id: validatedFields.id, 25 | userId, 26 | }, 27 | }); 28 | 29 | if (!existingBudget) { 30 | throw new Error("Budget not found or unauthorized"); 31 | } 32 | 33 | await prisma.budget.delete({ 34 | where: { id: validatedFields.id }, 35 | }); 36 | 37 | revalidatePath("/categories"); 38 | return { success: true }; 39 | } catch (error) { 40 | console.error("Error in deleteBudget:", error); 41 | if (error instanceof z.ZodError) { 42 | return { success: false, error: error.errors }; 43 | } 44 | if (error instanceof Error) { 45 | return { success: false, error: error.message }; 46 | } 47 | return { 48 | success: false, 49 | error: "An unexpected error occurred while deleting the budget", 50 | }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/actions/budgets/get-budget.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { z } from "zod"; 6 | 7 | const getBudgetSchema = z.object({ 8 | id: z.string().min(1, "Budget ID is required"), 9 | }); 10 | 11 | export async function getBudget(input: z.infer) { 12 | try { 13 | const { userId } = await auth(); 14 | if (!userId) { 15 | throw new Error("Unauthorized: No user found"); 16 | } 17 | 18 | const validatedFields = getBudgetSchema.parse(input); 19 | 20 | const budget = await prisma.budget.findFirst({ 21 | where: { 22 | id: validatedFields.id, 23 | userId, 24 | }, 25 | include: { 26 | categories: { 27 | include: { 28 | category: { 29 | include: { 30 | transactions: { 31 | where: { 32 | date: { 33 | gte: new Date(), // Only transactions within budget period 34 | }, 35 | }, 36 | select: { 37 | amount: true, 38 | date: true, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }); 47 | 48 | if (!budget) { 49 | throw new Error("Budget not found or unauthorized"); 50 | } 51 | 52 | // Calculate progress for each category 53 | const budgetWithProgress = { 54 | ...budget, 55 | categories: budget.categories.map((cat) => ({ 56 | ...cat, 57 | spent: cat.category.transactions.reduce( 58 | (sum, tx) => sum + tx.amount.toNumber(), 59 | 0 60 | ), 61 | progress: ( 62 | (cat.category.transactions.reduce( 63 | (sum, tx) => sum + tx.amount.toNumber(), 64 | 0 65 | ) / 66 | cat.amount.toNumber()) * 67 | 100 68 | ).toFixed(1), 69 | remaining: 70 | cat.amount.toNumber() - 71 | cat.category.transactions.reduce( 72 | (sum, tx) => sum + tx.amount.toNumber(), 73 | 0 74 | ), 75 | })), 76 | }; 77 | 78 | return { success: true, budget: budgetWithProgress }; 79 | } catch (error) { 80 | console.error("Error in getBudget:", error); 81 | if (error instanceof z.ZodError) { 82 | return { success: false, error: error.errors }; 83 | } 84 | if (error instanceof Error) { 85 | return { success: false, error: error.message }; 86 | } 87 | return { 88 | success: false, 89 | error: "An unexpected error occurred while fetching the budget", 90 | }; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/actions/budgets/update-budget.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { Prisma } from "@prisma/client"; 7 | import { z } from "zod"; 8 | 9 | const updateBudgetSchema = z.object({ 10 | id: z.string().min(1, "Budget ID is required"), 11 | name: z.string().optional(), 12 | startDate: z.date(), 13 | endDate: z.date(), 14 | amount: z.string().min(1, "Amount is required"), 15 | categories: z.array( 16 | z.object({ 17 | id: z.string().optional(), 18 | categoryId: z.string(), 19 | amount: z.string().min(1, "Category amount is required"), 20 | }) 21 | ), 22 | }); 23 | 24 | export async function updateBudget(input: z.infer) { 25 | try { 26 | const { userId } = await auth(); 27 | if (!userId) { 28 | throw new Error("Unauthorized: No user found"); 29 | } 30 | 31 | const validatedFields = updateBudgetSchema.parse(input); 32 | 33 | // Verify budget ownership 34 | const existingBudget = await prisma.budget.findFirst({ 35 | where: { 36 | id: validatedFields.id, 37 | userId, 38 | }, 39 | include: { 40 | categories: true, 41 | }, 42 | }); 43 | 44 | if (!existingBudget) { 45 | throw new Error("Budget not found or unauthorized"); 46 | } 47 | 48 | const amount = new Prisma.Decimal(validatedFields.amount); 49 | 50 | // Update budget and handle category relationships 51 | const budget = await prisma.budget.update({ 52 | where: { id: validatedFields.id }, 53 | data: { 54 | name: validatedFields.name, 55 | startDate: validatedFields.startDate, 56 | endDate: validatedFields.endDate, 57 | amount, 58 | categories: { 59 | deleteMany: {}, // Remove existing relationships 60 | create: validatedFields.categories.map((cat) => ({ 61 | amount: new Prisma.Decimal(cat.amount), 62 | category: { 63 | connect: { id: cat.categoryId }, 64 | }, 65 | })), 66 | }, 67 | }, 68 | include: { 69 | categories: { 70 | include: { 71 | category: true, 72 | }, 73 | }, 74 | }, 75 | }); 76 | 77 | revalidatePath("/categories"); 78 | return { success: true, budget }; 79 | } catch (error) { 80 | console.error("Error in updateBudget:", error); 81 | if (error instanceof z.ZodError) { 82 | return { success: false, error: error.errors }; 83 | } 84 | if (error instanceof Error) { 85 | return { success: false, error: error.message }; 86 | } 87 | return { 88 | success: false, 89 | error: "An unexpected error occurred while updating the budget", 90 | }; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/actions/budgets/update-category-budget.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { Prisma } from "@prisma/client"; 7 | import { z } from "zod"; 8 | 9 | const updateCategoryBudgetSchema = z.object({ 10 | categoryId: z.string().min(1, "Category ID is required"), 11 | monthlyLimit: z.string().min(1, "Monthly limit is required"), 12 | description: z.string().optional(), 13 | }); 14 | 15 | export async function updateCategoryBudget( 16 | input: z.infer 17 | ) { 18 | try { 19 | const { userId } = await auth(); 20 | if (!userId) { 21 | throw new Error("Unauthorized: No user found"); 22 | } 23 | 24 | const validatedFields = updateCategoryBudgetSchema.parse(input); 25 | 26 | // Verify category ownership 27 | const category = await prisma.category.findFirst({ 28 | where: { 29 | id: validatedFields.categoryId, 30 | userId, 31 | }, 32 | include: { 33 | budgets: { 34 | include: { 35 | budget: true, 36 | }, 37 | }, 38 | }, 39 | }); 40 | 41 | if (!category) { 42 | throw new Error("Category not found or unauthorized"); 43 | } 44 | 45 | // Create or update budget 46 | const budget = await prisma.budget.upsert({ 47 | where: { 48 | id: category.budgets[0]?.budgetId || "new", 49 | }, 50 | create: { 51 | userId, 52 | amount: new Prisma.Decimal(validatedFields.monthlyLimit), 53 | startDate: new Date(), 54 | endDate: new Date(new Date().setMonth(new Date().getMonth() + 1)), 55 | categories: { 56 | create: { 57 | amount: new Prisma.Decimal(validatedFields.monthlyLimit), 58 | category: { 59 | connect: { id: validatedFields.categoryId }, 60 | }, 61 | }, 62 | }, 63 | }, 64 | update: { 65 | amount: new Prisma.Decimal(validatedFields.monthlyLimit), 66 | categories: { 67 | update: { 68 | where: { 69 | budgetId_categoryId: { 70 | budgetId: category.budgets[0].budgetId, 71 | categoryId: validatedFields.categoryId, 72 | }, 73 | }, 74 | data: { 75 | amount: new Prisma.Decimal(validatedFields.monthlyLimit), 76 | }, 77 | }, 78 | }, 79 | }, 80 | }); 81 | 82 | revalidatePath("/categories"); 83 | return { success: true, budget }; 84 | } catch (error) { 85 | if (error instanceof z.ZodError) { 86 | return { success: false, error: error.errors }; 87 | } 88 | if (error instanceof Error) { 89 | return { success: false, error: error.message }; 90 | } 91 | return { 92 | success: false, 93 | error: "An unexpected error occurred while updating the budget", 94 | }; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/actions/categories/create-category.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | const createCategorySchema = z.object({ 9 | name: z.string().min(2, "Category name must be at least 2 characters"), 10 | icon: z.string().min(1, "Icon is required"), 11 | }); 12 | 13 | export async function createCategory( 14 | input: z.infer 15 | ) { 16 | try { 17 | const { userId } = await auth(); 18 | if (!userId) { 19 | throw new Error("Unauthorized: No user found"); 20 | } 21 | 22 | const validatedFields = createCategorySchema.parse(input); 23 | 24 | // Check for duplicate category 25 | const existingCategory = await prisma.category.findFirst({ 26 | where: { 27 | name: validatedFields.name, 28 | userId, 29 | }, 30 | }); 31 | 32 | if (existingCategory) { 33 | throw new Error("Category already exists"); 34 | } 35 | 36 | const category = await prisma.category.create({ 37 | data: { 38 | name: validatedFields.name, 39 | icon: validatedFields.icon, 40 | userId, 41 | }, 42 | }); 43 | 44 | revalidatePath("/categories"); 45 | return { success: true, category }; 46 | } catch (error) { 47 | console.error("Error in createCategory:", error); 48 | if (error instanceof z.ZodError) { 49 | return { success: false, error: error.errors }; 50 | } 51 | if (error instanceof Error) { 52 | return { success: false, error: error.message }; 53 | } 54 | return { 55 | success: false, 56 | error: "An unexpected error occurred while creating the category", 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/actions/categories/create-default-budget.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { CATEGORIES } from "@/lib/config/categories"; 7 | 8 | export async function createDefaultBudget() { 9 | try { 10 | const { userId } = await auth(); 11 | if (!userId) throw new Error("Unauthorized"); 12 | 13 | // Create or update categories using our standard config 14 | const categories = await Promise.all( 15 | CATEGORIES.filter((cat) => cat.type === "DEBIT").map(async (category) => { 16 | return prisma.category.upsert({ 17 | where: { 18 | name_userId: { 19 | name: category.name, 20 | userId, 21 | }, 22 | }, 23 | update: {}, // No updates needed if exists 24 | create: { 25 | name: category.name, 26 | icon: category.id, 27 | userId, 28 | }, 29 | }); 30 | }) 31 | ); 32 | 33 | // Create default budget with these categories 34 | const budget = await prisma.budget.create({ 35 | data: { 36 | name: "Default Budget", 37 | startDate: new Date(), 38 | endDate: new Date(new Date().setMonth(new Date().getMonth() + 1)), 39 | amount: 5000, 40 | userId, 41 | categories: { 42 | create: categories.map((cat) => ({ 43 | amount: getDefaultBudgetAmount(cat.name), 44 | category: { 45 | connect: { id: cat.id }, 46 | }, 47 | })), 48 | }, 49 | }, 50 | }); 51 | 52 | revalidatePath("/categories"); 53 | return { success: true, budget }; 54 | } catch (error) { 55 | console.error("Error in createDefaultBudget:", error); 56 | return { success: false, error: "Failed to create default budget" }; 57 | } 58 | } 59 | 60 | // Helper function to get default budget amounts 61 | function getDefaultBudgetAmount(categoryName: string): number { 62 | const defaultAmounts: Record = { 63 | Housing: 2000, 64 | "Food": 800, 65 | Transportation: 400, 66 | Utilities: 300, 67 | Entertainment: 200, 68 | Healthcare: 300, 69 | Shopping: 400, 70 | Travel: 200, 71 | Subscriptions: 100, 72 | Other: 300, 73 | }; 74 | 75 | return defaultAmounts[categoryName] || 200; 76 | } 77 | -------------------------------------------------------------------------------- /src/actions/categories/delete-category.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | const deleteCategorySchema = z.object({ 9 | id: z.string().min(1, "Category ID is required"), 10 | }); 11 | 12 | export async function deleteCategory( 13 | input: z.infer 14 | ) { 15 | try { 16 | const { userId } = await auth(); 17 | if (!userId) { 18 | throw new Error("Unauthorized: No user found"); 19 | } 20 | 21 | const validatedFields = deleteCategorySchema.parse(input); 22 | 23 | // Verify category ownership 24 | const existingCategory = await prisma.category.findFirst({ 25 | where: { 26 | id: validatedFields.id, 27 | userId, 28 | }, 29 | }); 30 | 31 | if (!existingCategory) { 32 | throw new Error("Category not found or unauthorized"); 33 | } 34 | 35 | await prisma.category.delete({ 36 | where: { id: validatedFields.id }, 37 | }); 38 | 39 | revalidatePath("/categories"); 40 | return { success: true }; 41 | } catch (error) { 42 | console.error("Error in deleteCategory:", error); 43 | if (error instanceof z.ZodError) { 44 | return { success: false, error: error.errors }; 45 | } 46 | if (error instanceof Error) { 47 | return { success: false, error: error.message }; 48 | } 49 | return { 50 | success: false, 51 | error: "An unexpected error occurred while deleting the category", 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/actions/categories/get-categories.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | 6 | export async function getCategories() { 7 | try { 8 | const { userId } = await auth(); 9 | if (!userId) { 10 | throw new Error("Unauthorized: No user found"); 11 | } 12 | 13 | const categories = await prisma.category.findMany({ 14 | where: { userId }, 15 | orderBy: { name: "asc" }, 16 | }); 17 | 18 | return { success: true, categories }; 19 | } catch (error) { 20 | console.error("Error in getCategories:", error); 21 | if (error instanceof Error) { 22 | return { success: false, error: error.message }; 23 | } 24 | return { 25 | success: false, 26 | error: "An unexpected error occurred while fetching categories", 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/actions/categories/get-category-budgets.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { z } from "zod"; 6 | 7 | const getCategoryBudgetsSchema = z.object({ 8 | categoryId: z.string().min(1, "Category ID is required"), 9 | }); 10 | 11 | export async function getCategoryBudgets( 12 | input: z.infer 13 | ) { 14 | try { 15 | const { userId } = await auth(); 16 | if (!userId) { 17 | throw new Error("Unauthorized: No user found"); 18 | } 19 | 20 | const validatedFields = getCategoryBudgetsSchema.parse(input); 21 | 22 | const categoryBudgets = await prisma.categoryBudget.findMany({ 23 | where: { 24 | categoryId: validatedFields.categoryId, 25 | category: { 26 | userId, 27 | }, 28 | }, 29 | include: { 30 | budget: true, 31 | category: true, 32 | }, 33 | orderBy: { 34 | budget: { 35 | startDate: "desc", 36 | }, 37 | }, 38 | }); 39 | 40 | return { success: true, categoryBudgets }; 41 | } catch (error) { 42 | console.error("Error in getCategoryBudgets:", error); 43 | if (error instanceof z.ZodError) { 44 | return { success: false, error: error.errors }; 45 | } 46 | if (error instanceof Error) { 47 | return { success: false, error: error.message }; 48 | } 49 | return { 50 | success: false, 51 | error: "An unexpected error occurred while fetching category budgets", 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/actions/categories/update-category.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | const updateCategorySchema = z.object({ 9 | id: z.string().min(1, "Category ID is required"), 10 | name: z.string().min(2, "Category name must be at least 2 characters"), 11 | icon: z.string().min(1, "Icon is required"), 12 | }); 13 | 14 | export async function updateCategory( 15 | input: z.infer 16 | ) { 17 | try { 18 | const { userId } = await auth(); 19 | if (!userId) { 20 | throw new Error("Unauthorized: No user found"); 21 | } 22 | 23 | const validatedFields = updateCategorySchema.parse(input); 24 | 25 | // Verify category ownership 26 | const existingCategory = await prisma.category.findFirst({ 27 | where: { 28 | id: validatedFields.id, 29 | userId, 30 | }, 31 | }); 32 | 33 | if (!existingCategory) { 34 | throw new Error("Category not found or unauthorized"); 35 | } 36 | 37 | const category = await prisma.category.update({ 38 | where: { id: validatedFields.id }, 39 | data: { 40 | name: validatedFields.name, 41 | icon: validatedFields.icon, 42 | }, 43 | }); 44 | 45 | revalidatePath("/categories"); 46 | return { success: true, category }; 47 | } catch (error) { 48 | console.error("Error in updateCategory:", error); 49 | if (error instanceof z.ZodError) { 50 | return { success: false, error: error.errors }; 51 | } 52 | if (error instanceof Error) { 53 | return { success: false, error: error.message }; 54 | } 55 | return { 56 | success: false, 57 | error: "An unexpected error occurred while updating the category", 58 | }; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/actions/categories/validate-transaction-category.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | 6 | export async function validateTransactionCategory( 7 | transactionId: string, 8 | approved: boolean 9 | ) { 10 | try { 11 | const { userId } = await auth(); 12 | if (!userId) throw new Error("Unauthorized"); 13 | 14 | await prisma.transaction.update({ 15 | where: { id: transactionId }, 16 | data: { 17 | categoryValidated: true, 18 | // If not approved, clear the suggested category 19 | categoryId: approved ? undefined : null, 20 | }, 21 | }); 22 | 23 | return { success: true }; 24 | } catch (error) { 25 | console.error("Error validating category:", error); 26 | return { success: false, error: "Failed to validate category" }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/actions/currency/seed-currencies.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | 5 | const DEFAULT_CURRENCIES = [ 6 | { iso: "USD", symbol: "$", numericCode: 840 }, 7 | { iso: "EUR", symbol: "€", numericCode: 978 }, 8 | { iso: "GBP", symbol: "£", numericCode: 826 }, 9 | { iso: "NOK", symbol: "kr", numericCode: 578 }, 10 | ] as const; 11 | 12 | export async function seedCurrencies() { 13 | try { 14 | // Create all currencies in a single transaction 15 | await prisma.$transaction( 16 | DEFAULT_CURRENCIES.map((currency) => 17 | prisma.currency.upsert({ 18 | where: { iso: currency.iso }, 19 | update: {}, // No updates if exists 20 | create: currency, 21 | }) 22 | ) 23 | ); 24 | 25 | return { success: true }; 26 | } catch (error) { 27 | console.error("Error seeding currencies:", error); 28 | return { success: false, error: (error as Error).message }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/actions/investments/create-investment.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { InvestmentType, Prisma } from "@prisma/client"; 7 | import { z } from "zod"; 8 | 9 | const createInvestmentSchema = z.object({ 10 | name: z.string().min(2, { 11 | message: "Investment name must be at least 2 characters.", 12 | }), 13 | type: z.enum(["STOCKS", "CRYPTO", "ETF", "OTHER"], { 14 | required_error: "Please select an investment type", 15 | }), 16 | amount: z.string().min(1, "Amount is required"), 17 | shares: z.string().optional(), 18 | purchasePrice: z.string().optional(), 19 | currentPrice: z.string().optional(), 20 | description: z.string().optional(), 21 | }); 22 | 23 | export async function createInvestment( 24 | input: z.infer 25 | ) { 26 | try { 27 | const { userId } = await auth(); 28 | if (!userId) { 29 | throw new Error("Unauthorized: No user found"); 30 | } 31 | 32 | const validatedFields = createInvestmentSchema.parse(input); 33 | const amount = new Prisma.Decimal(validatedFields.amount); 34 | const purchasePrice = validatedFields.purchasePrice 35 | ? new Prisma.Decimal(validatedFields.purchasePrice) 36 | : null; 37 | const shares = validatedFields.shares 38 | ? new Prisma.Decimal(validatedFields.shares) 39 | : null; 40 | 41 | // Create the investment 42 | const investment = await prisma.investment.create({ 43 | data: { 44 | name: validatedFields.name, 45 | type: validatedFields.type as InvestmentType, 46 | amount, 47 | shares, 48 | purchasePrice, 49 | currentPrice: purchasePrice, 50 | description: validatedFields.description, 51 | userId, 52 | }, 53 | }); 54 | 55 | // Create initial valuation 56 | await prisma.investmentValuation.create({ 57 | data: { 58 | value: amount, 59 | date: new Date(), 60 | investmentId: investment.id, 61 | }, 62 | }); 63 | 64 | // Create initial transaction 65 | await prisma.investmentTransaction.create({ 66 | data: { 67 | type: "BUY", 68 | amount, 69 | shares, 70 | price: purchasePrice || amount, 71 | date: new Date(), 72 | description: `Initial purchase of ${validatedFields.name}`, 73 | investmentId: investment.id, 74 | }, 75 | }); 76 | 77 | revalidatePath("/investments"); 78 | return { success: true, investment }; 79 | } catch (error) { 80 | if (error instanceof z.ZodError) { 81 | return { success: false, error: error.errors }; 82 | } 83 | if (error instanceof Error) { 84 | return { success: false, error: error.message }; 85 | } 86 | return { 87 | success: false, 88 | error: "An unexpected error occurred while creating the investment", 89 | }; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/actions/investments/delete-investment.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | const deleteInvestmentSchema = z.object({ 9 | id: z.string().min(1, "Investment ID is required"), 10 | }); 11 | 12 | export async function deleteInvestment( 13 | input: z.infer 14 | ) { 15 | try { 16 | console.log("Starting investment deletion process"); 17 | 18 | const { userId } = await auth(); 19 | if (!userId) { 20 | throw new Error("Unauthorized: No user found"); 21 | } 22 | 23 | const validatedFields = deleteInvestmentSchema.parse(input); 24 | 25 | // Verify investment ownership 26 | const existingInvestment = await prisma.investment.findFirst({ 27 | where: { 28 | id: validatedFields.id, 29 | userId: userId, 30 | }, 31 | }); 32 | 33 | if (!existingInvestment) { 34 | throw new Error("Investment not found or unauthorized"); 35 | } 36 | 37 | // Delete the investment (cascade will handle related records) 38 | await prisma.investment.delete({ 39 | where: { id: validatedFields.id }, 40 | }); 41 | 42 | revalidatePath("/investments"); 43 | return { success: true }; 44 | } catch (error) { 45 | console.error("Error in deleteInvestment:", error); 46 | if (error instanceof z.ZodError) { 47 | return { success: false, error: error.errors }; 48 | } 49 | if (error instanceof Error) { 50 | return { success: false, error: error.message }; 51 | } 52 | return { 53 | success: false, 54 | error: "An unexpected error occurred while deleting the investment", 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/actions/investments/get-investment.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { z } from "zod"; 6 | 7 | const getInvestmentSchema = z.object({ 8 | id: z.string().min(1, "Investment ID is required"), 9 | }); 10 | 11 | export async function getInvestment( 12 | input: z.infer 13 | ) { 14 | try { 15 | console.log("Starting investment fetch process"); 16 | 17 | const { userId } = await auth(); 18 | if (!userId) { 19 | throw new Error("Unauthorized: No user found"); 20 | } 21 | 22 | const validatedFields = getInvestmentSchema.parse(input); 23 | 24 | const investment = await prisma.investment.findFirst({ 25 | where: { 26 | id: validatedFields.id, 27 | userId: userId, 28 | }, 29 | include: { 30 | valuations: { 31 | orderBy: { date: "desc" }, 32 | take: 1, 33 | }, 34 | transactions: { 35 | orderBy: { date: "desc" }, 36 | }, 37 | }, 38 | }); 39 | 40 | if (!investment) { 41 | throw new Error("Investment not found or unauthorized"); 42 | } 43 | 44 | return { success: true, investment }; 45 | } catch (error) { 46 | console.error("Error in getInvestment:", error); 47 | if (error instanceof z.ZodError) { 48 | return { success: false, error: error.errors }; 49 | } 50 | if (error instanceof Error) { 51 | return { success: false, error: error.message }; 52 | } 53 | return { 54 | success: false, 55 | error: "An unexpected error occurred while fetching the investment", 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/actions/investments/update-investment.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { InvestmentType, Prisma } from "@prisma/client"; 7 | import { z } from "zod"; 8 | 9 | const updateInvestmentSchema = z.object({ 10 | id: z.string().min(1, "Investment ID is required"), 11 | name: z.string().min(2, "Investment name must be at least 2 characters."), 12 | type: z.enum(["STOCKS", "CRYPTO", "ETF", "OTHER"]), 13 | amount: z.string().min(1, "Amount is required"), 14 | shares: z.string().optional(), 15 | currentPrice: z.string().optional(), 16 | description: z.string().optional(), 17 | }); 18 | 19 | export async function updateInvestment( 20 | input: z.infer 21 | ) { 22 | try { 23 | console.log("Starting investment update process"); 24 | 25 | const { userId } = await auth(); 26 | if (!userId) { 27 | throw new Error("Unauthorized: No user found"); 28 | } 29 | 30 | const validatedFields = updateInvestmentSchema.parse(input); 31 | 32 | // Verify investment ownership 33 | const existingInvestment = await prisma.investment.findFirst({ 34 | where: { 35 | id: validatedFields.id, 36 | userId: userId, 37 | }, 38 | }); 39 | 40 | if (!existingInvestment) { 41 | throw new Error("Investment not found or unauthorized"); 42 | } 43 | 44 | const newAmount = new Prisma.Decimal(validatedFields.amount); 45 | const newShares = validatedFields.shares 46 | ? new Prisma.Decimal(validatedFields.shares) 47 | : null; 48 | const newPrice = validatedFields.currentPrice 49 | ? new Prisma.Decimal(validatedFields.currentPrice) 50 | : null; 51 | 52 | // Update the investment 53 | const investment = await prisma.investment.update({ 54 | where: { id: validatedFields.id }, 55 | data: { 56 | name: validatedFields.name, 57 | type: validatedFields.type as InvestmentType, 58 | amount: newAmount, 59 | shares: newShares, 60 | currentPrice: newPrice, 61 | description: validatedFields.description, 62 | }, 63 | }); 64 | 65 | // Create new valuation if amount changed 66 | if (!existingInvestment.amount.equals(newAmount)) { 67 | await prisma.investmentValuation.create({ 68 | data: { 69 | value: newAmount, 70 | date: new Date(), 71 | investmentId: investment.id, 72 | }, 73 | }); 74 | } 75 | 76 | revalidatePath("/investments"); 77 | return { success: true, investment }; 78 | } catch (error) { 79 | console.error("Error in updateInvestment:", error); 80 | if (error instanceof z.ZodError) { 81 | return { success: false, error: error.errors }; 82 | } 83 | if (error instanceof Error) { 84 | return { success: false, error: error.message }; 85 | } 86 | return { 87 | success: false, 88 | error: "An unexpected error occurred while updating the investment", 89 | }; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/actions/liabilities/create-liability.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { LiabilityType, Prisma } from "@prisma/client"; 7 | import { z } from "zod"; 8 | 9 | const createLiabilitySchema = z.object({ 10 | name: z.string().min(2, { 11 | message: "Liability name must be at least 2 characters.", 12 | }), 13 | type: z.enum(["MORTGAGE", "CREDIT_CARD", "CAR_LOAN", "STUDENT_LOAN"], { 14 | required_error: "Please select a liability type", 15 | }), 16 | amount: z.string().min(1, "Amount is required"), 17 | interestRate: z.string().min(1, "Interest rate is required"), 18 | monthlyPayment: z.string().min(1, "Monthly payment is required"), 19 | startDate: z.date().optional(), 20 | endDate: z.date().optional(), 21 | description: z.string().optional(), 22 | }); 23 | 24 | export async function createLiability( 25 | input: z.infer 26 | ) { 27 | try { 28 | console.log("Starting liability creation process"); 29 | 30 | const { userId } = await auth(); 31 | if (!userId) { 32 | throw new Error("Unauthorized: No user found"); 33 | } 34 | 35 | const validatedFields = createLiabilitySchema.parse(input); 36 | const amount = new Prisma.Decimal(validatedFields.amount); 37 | const interestRate = new Prisma.Decimal(validatedFields.interestRate); 38 | const monthlyPayment = new Prisma.Decimal(validatedFields.monthlyPayment); 39 | 40 | // Create the liability 41 | const liability = await prisma.liability.create({ 42 | data: { 43 | name: validatedFields.name, 44 | type: validatedFields.type as LiabilityType, 45 | amount, 46 | interestRate, 47 | monthlyPayment, 48 | startDate: validatedFields.startDate, 49 | endDate: validatedFields.endDate, 50 | description: validatedFields.description, 51 | userId, 52 | }, 53 | }); 54 | 55 | // Create initial payment record 56 | await prisma.liabilityPayment.create({ 57 | data: { 58 | amount: monthlyPayment, 59 | date: new Date(), 60 | type: "REGULAR", 61 | description: `Initial payment setup for ${validatedFields.name}`, 62 | liabilityId: liability.id, 63 | }, 64 | }); 65 | 66 | revalidatePath("/liabilities"); 67 | return { success: true, liability }; 68 | } catch (error) { 69 | console.error("Error in createLiability:", error); 70 | if (error instanceof z.ZodError) { 71 | return { success: false, error: error.errors }; 72 | } 73 | if (error instanceof Error) { 74 | return { success: false, error: error.message }; 75 | } 76 | return { 77 | success: false, 78 | error: "An unexpected error occurred while creating the liability", 79 | }; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/actions/liabilities/delete-liability.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | const deleteLiabilitySchema = z.object({ 9 | id: z.string().min(1, "Liability ID is required"), 10 | }); 11 | 12 | export async function deleteLiability( 13 | input: z.infer 14 | ) { 15 | try { 16 | console.log("Starting liability deletion process"); 17 | 18 | const { userId } = await auth(); 19 | if (!userId) { 20 | throw new Error("Unauthorized: No user found"); 21 | } 22 | 23 | const validatedFields = deleteLiabilitySchema.parse(input); 24 | 25 | // Verify liability ownership 26 | const existingLiability = await prisma.liability.findFirst({ 27 | where: { 28 | id: validatedFields.id, 29 | userId: userId, 30 | }, 31 | }); 32 | 33 | if (!existingLiability) { 34 | throw new Error("Liability not found or unauthorized"); 35 | } 36 | 37 | // Delete the liability (cascade will handle related records) 38 | await prisma.liability.delete({ 39 | where: { id: validatedFields.id }, 40 | }); 41 | 42 | revalidatePath("/liabilities"); 43 | return { success: true }; 44 | } catch (error) { 45 | console.error("Error in deleteLiability:", error); 46 | if (error instanceof z.ZodError) { 47 | return { success: false, error: error.errors }; 48 | } 49 | if (error instanceof Error) { 50 | return { success: false, error: error.message }; 51 | } 52 | return { 53 | success: false, 54 | error: "An unexpected error occurred while deleting the liability", 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/actions/liabilities/get-liability.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { z } from "zod"; 6 | 7 | const getLiabilitySchema = z.object({ 8 | id: z.string().min(1, "Liability ID is required"), 9 | }); 10 | 11 | export async function getLiability(input: z.infer) { 12 | try { 13 | console.log("Starting liability fetch process"); 14 | 15 | const { userId } = await auth(); 16 | if (!userId) { 17 | throw new Error("Unauthorized: No user found"); 18 | } 19 | 20 | const validatedFields = getLiabilitySchema.parse(input); 21 | 22 | const liability = await prisma.liability.findFirst({ 23 | where: { 24 | id: validatedFields.id, 25 | userId: userId, 26 | }, 27 | include: { 28 | payments: { 29 | orderBy: { date: "desc" }, 30 | }, 31 | }, 32 | }); 33 | 34 | if (!liability) { 35 | throw new Error("Liability not found or unauthorized"); 36 | } 37 | 38 | return { success: true, liability }; 39 | } catch (error) { 40 | console.error("Error in getLiability:", error); 41 | if (error instanceof z.ZodError) { 42 | return { success: false, error: error.errors }; 43 | } 44 | if (error instanceof Error) { 45 | return { success: false, error: error.message }; 46 | } 47 | return { 48 | success: false, 49 | error: "An unexpected error occurred while fetching the liability", 50 | }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/actions/savings/create-default-goals.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { Prisma } from "@prisma/client"; 5 | 6 | const defaultGoals = [ 7 | { 8 | name: "Emergency Fund", 9 | target: 15000, 10 | type: "EMERGENCY_FUND", 11 | description: "3-6 months of living expenses for emergencies", 12 | priority: 1, 13 | }, 14 | { 15 | name: "Retirement", 16 | target: 500000, 17 | type: "RETIREMENT", 18 | description: "Long-term retirement savings goal", 19 | priority: 2, 20 | }, 21 | { 22 | name: "House Down Payment", 23 | target: 50000, 24 | type: "DOWN_PAYMENT", 25 | description: "Saving for a house down payment", 26 | priority: 3, 27 | }, 28 | ] as const; 29 | 30 | export async function createDefaultGoals(userId: string) { 31 | try { 32 | const goals = await Promise.all( 33 | defaultGoals.map((goal) => 34 | prisma.savingsGoal.create({ 35 | data: { 36 | name: goal.name, 37 | target: new Prisma.Decimal(goal.target), 38 | current: new Prisma.Decimal(0), 39 | type: goal.type, 40 | description: goal.description, 41 | priority: goal.priority, 42 | isDefault: true, 43 | userId, 44 | }, 45 | }) 46 | ) 47 | ); 48 | 49 | return { success: true, goals }; 50 | } catch (error) { 51 | console.error("Error creating default goals:", error); 52 | return { success: false, error: "Failed to create default goals" }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/actions/savings/create-savings-goal.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { Prisma, GoalType } from "@prisma/client"; 7 | import { z } from "zod"; 8 | 9 | const createSavingsGoalSchema = z.object({ 10 | name: z.string().min(2, { 11 | message: "Goal name must be at least 2 characters.", 12 | }), 13 | type: z.enum(["EMERGENCY_FUND", "RETIREMENT", "DOWN_PAYMENT", "CUSTOM"], { 14 | required_error: "Please select a goal type", 15 | }), 16 | target: z.string().min(1, "Target amount is required"), 17 | deadline: z.date().optional(), 18 | description: z.string().optional(), 19 | }); 20 | 21 | export async function createSavingsGoal( 22 | input: z.infer 23 | ) { 24 | try { 25 | const { userId } = await auth(); 26 | if (!userId) { 27 | throw new Error("Unauthorized: No user found"); 28 | } 29 | 30 | const validatedFields = createSavingsGoalSchema.parse(input); 31 | 32 | // Get existing goals count for priority 33 | const existingGoals = await prisma.savingsGoal.count({ 34 | where: { userId }, 35 | }); 36 | 37 | // Create the goal 38 | const goal = await prisma.savingsGoal.create({ 39 | data: { 40 | name: validatedFields.name, 41 | type: validatedFields.type as GoalType, 42 | target: new Prisma.Decimal(validatedFields.target), 43 | current: new Prisma.Decimal(0), 44 | deadline: validatedFields.deadline, 45 | description: validatedFields.description, 46 | priority: existingGoals + 1, 47 | userId, 48 | }, 49 | }); 50 | 51 | // Create initial progress entry 52 | await prisma.savingsProgress.create({ 53 | data: { 54 | amount: new Prisma.Decimal(0), 55 | date: new Date(), 56 | goalId: goal.id, 57 | }, 58 | }); 59 | 60 | revalidatePath("/savings"); 61 | return { success: true, goal }; 62 | } catch (error) { 63 | console.error("Error in createSavingsGoal:", error); 64 | if (error instanceof z.ZodError) { 65 | return { success: false, error: error.errors }; 66 | } 67 | if (error instanceof Error) { 68 | return { success: false, error: error.message }; 69 | } 70 | return { 71 | success: false, 72 | error: "An unexpected error occurred while creating the savings goal", 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/actions/savings/delete-savings-goal.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | export async function deleteSavingsGoal(goalId: string) { 9 | try { 10 | const { userId } = await auth(); 11 | if (!userId) { 12 | throw new Error("Unauthorized: No user found"); 13 | } 14 | 15 | if (!goalId) { 16 | throw new Error("Goal ID is required"); 17 | } 18 | 19 | // Verify goal ownership 20 | const goal = await prisma.savingsGoal.findFirst({ 21 | where: { 22 | id: goalId, 23 | userId, 24 | }, 25 | include: { 26 | progress: true, 27 | }, 28 | }); 29 | 30 | if (!goal) { 31 | throw new Error("Goal not found or unauthorized"); 32 | } 33 | 34 | // Delete the goal (cascade will handle progress entries) 35 | await prisma.savingsGoal.delete({ 36 | where: { id: goalId }, 37 | }); 38 | 39 | revalidatePath("/savings"); 40 | return { success: true }; 41 | } catch (error) { 42 | console.error("Error in deleteSavingsGoal:", error); 43 | if (error instanceof Error) { 44 | return { success: false, error: error.message }; 45 | } 46 | return { 47 | success: false, 48 | error: "An unexpected error occurred while deleting the savings goal", 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/actions/savings/update-savings-goal.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { Prisma } from "@prisma/client"; 7 | import { z } from "zod"; 8 | 9 | // Simplified schema to match what we're actually sending 10 | const updateSavingsGoalSchema = z.object({ 11 | id: z.string().min(1, "Goal ID is required"), 12 | name: z.string().min(2, "Goal name must be at least 2 characters."), 13 | target: z.string().min(1, "Target amount is required"), 14 | description: z.string().optional().nullable(), 15 | }); 16 | 17 | export async function updateSavingsGoal(input: { 18 | id: string; 19 | name: string; 20 | target: string; 21 | description?: string | null; 22 | }) { 23 | try { 24 | const { userId } = await auth(); 25 | if (!userId) { 26 | throw new Error("Unauthorized: No user found"); 27 | } 28 | 29 | console.log("Updating goal with input:", input); 30 | 31 | // Validate input 32 | const validatedFields = updateSavingsGoalSchema.parse(input); 33 | 34 | // Verify goal ownership 35 | const goal = await prisma.savingsGoal.findFirst({ 36 | where: { 37 | id: validatedFields.id, 38 | userId, 39 | }, 40 | }); 41 | 42 | if (!goal) { 43 | throw new Error("Goal not found or unauthorized"); 44 | } 45 | 46 | // Update the goal 47 | const updatedGoal = await prisma.savingsGoal.update({ 48 | where: { id: validatedFields.id }, 49 | data: { 50 | name: validatedFields.name, 51 | target: new Prisma.Decimal(validatedFields.target), 52 | description: validatedFields.description, 53 | }, 54 | }); 55 | 56 | // Create new progress entry if target changed 57 | if (!goal.target.equals(new Prisma.Decimal(validatedFields.target))) { 58 | await prisma.savingsProgress.create({ 59 | data: { 60 | amount: goal.current, 61 | date: new Date(), 62 | goalId: goal.id, 63 | }, 64 | }); 65 | } 66 | 67 | revalidatePath("/savings"); 68 | return { success: true, goal: updatedGoal }; 69 | } catch (error) { 70 | console.error("Error in updateSavingsGoal:", error); 71 | if (error instanceof z.ZodError) { 72 | return { success: false, error: error.errors }; 73 | } 74 | if (error instanceof Error) { 75 | return { success: false, error: error.message }; 76 | } 77 | return { 78 | success: false, 79 | error: "An unexpected error occurred while updating the savings goal", 80 | }; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/actions/user/create-new-user.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth, currentUser } from "@clerk/nextjs/server"; 5 | import { User } from "@prisma/client"; 6 | 7 | interface CreateUserInput { 8 | id: string; 9 | email: string | null; 10 | name: string | null; 11 | image?: string | null; 12 | } 13 | 14 | export async function createNewUser(): Promise { 15 | try { 16 | // Get the authenticated user from Clerk 17 | const { userId } = await auth(); 18 | if (!userId) { 19 | console.error("No authenticated user found"); 20 | return null; 21 | } 22 | 23 | // Get the user details from Clerk 24 | const clerkUser = await currentUser(); 25 | if (!clerkUser) { 26 | console.error("Could not get user details from Clerk"); 27 | return null; 28 | } 29 | 30 | // Check if user already exists in our database 31 | const existingUser = await prisma.user.findUnique({ 32 | where: { id: userId }, 33 | }); 34 | 35 | if (existingUser) { 36 | console.log("User already exists in database"); 37 | return existingUser; 38 | } 39 | 40 | // Create new user in our database 41 | const newUser = await prisma.$transaction(async (tx) => { 42 | const createdUser = await tx.user.create({ 43 | data: { 44 | id: userId, 45 | email: clerkUser.emailAddresses[0]?.emailAddress ?? null, 46 | name: `${clerkUser.firstName ?? ""} ${clerkUser.lastName ?? ""}`.trim() || null, 47 | image: clerkUser.imageUrl, 48 | // Add any additional default fields here 49 | plan: "basic", // From your schema default 50 | credits: 3, // From your schema default 51 | language: "english", // From your schema default 52 | }, 53 | }); 54 | 55 | // Create default workspace 56 | await tx.workspace.create({ 57 | data: { 58 | name: `${createdUser.name ?? "My"}'s Workspace`, 59 | users: { 60 | connect: { id: createdUser.id }, 61 | }, 62 | }, 63 | }); 64 | 65 | return createdUser; 66 | }); 67 | 68 | console.log("Created new user:", newUser.id); 69 | return newUser; 70 | } catch (error) { 71 | console.error("Error creating new user:", error); 72 | throw error; // Let the caller handle the error 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/actions/user/get-current-user.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { prisma } from "@/lib/db"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | 6 | export async function getCurrentUser() { 7 | try { 8 | const { userId } = await auth(); 9 | if (!userId) return null; 10 | 11 | const user = await prisma.user.findUnique({ 12 | where: { id: userId }, 13 | select: { 14 | id: true, 15 | name: true, 16 | email: true, 17 | image: true, 18 | }, 19 | }); 20 | 21 | return user; 22 | } catch (error) { 23 | console.error("Error fetching current user:", error); 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | interface MarketingLayoutProps { 2 | children: React.ReactNode; 3 | } 4 | 5 | export default async function Layout({ children }: MarketingLayoutProps) { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { Icons } from "@/components/icons"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Card, 7 | CardContent, 8 | CardDescription, 9 | CardHeader, 10 | CardTitle, 11 | } from "@/components/ui/card"; 12 | import { Input } from "@/components/ui/input"; 13 | import { Label } from "@/components/ui/label"; 14 | 15 | export default function LoginForm() { 16 | return ( 17 | 18 | 19 | Login 20 | 21 | Enter your email below to login to your account 22 | 23 | 24 | 25 |
26 | 30 | 34 |
35 |
36 | 37 |
38 |
39 | 40 | Or continue with 41 | 42 |
43 |
44 | 45 |
46 | 47 | 53 |
54 |
55 |
56 | 57 | 58 | Forgot your password? 59 | 60 |
61 | 62 |
63 | 66 |
67 |
68 | Don't have an account?{" "} 69 | 70 | Sign up 71 | 72 |
73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/app/(auth)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { Input } from "@/components/ui/input"; 12 | import { Label } from "@/components/ui/label"; 13 | 14 | export default function LoginForm() { 15 | return ( 16 | 17 | 18 | Sign Up 19 | 20 | Enter your information to create an account 21 | 22 | 23 | 24 |
25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 |
34 |
35 |
36 | 37 | 43 |
44 |
45 | 46 | 47 |
48 | 51 | 54 |
55 |
56 | Already have an account?{" "} 57 | 58 | Sign in 59 | 60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/(main)/banking/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getAccountDetails } from "@/actions/banking/get-account-details"; 4 | import { notFound, useRouter } from "next/navigation"; 5 | import AccountTransactions from "@/components/transactions/account-transactions"; 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 7 | import { formatDistanceToNow } from "date-fns"; 8 | import { BalanceTooltip } from "@/components/transactions/balance-tooltip"; 9 | import { useEffect, useState } from "react"; 10 | 11 | interface BankingDetailsPageProps { 12 | params: { 13 | id: string; 14 | }; 15 | } 16 | 17 | export default function BankingDetailsPage({ 18 | params, 19 | }: BankingDetailsPageProps) { 20 | const [data, setData] = useState(null); 21 | const [loading, setLoading] = useState(true); 22 | 23 | const fetchData = async () => { 24 | const result = await getAccountDetails(params.id); 25 | if (!result.success || !result.data) { 26 | notFound(); 27 | } 28 | setData(result.data); 29 | setLoading(false); 30 | }; 31 | 32 | useEffect(() => { 33 | fetchData(); 34 | }, [params.id]); 35 | 36 | if (loading) { 37 | return
Loading...
; // Or a proper loading component 38 | } 39 | 40 | const { account, transactions } = data; 41 | 42 | return ( 43 |
44 |
45 |

{account.name}

46 |
47 | 48 |
49 | 50 | 51 | Balance 52 | 58 | 59 | 60 |
61 | {account.balance.toLocaleString("en-US", { 62 | style: "currency", 63 | currency: "USD", 64 | })} 65 |
66 |

67 | Last updated{" "} 68 | {formatDistanceToNow(account.lastUpdated, { addSuffix: true })} 69 |

70 |
71 |
72 |
73 | 74 | 78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/app/(main)/banking/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/actions/user/get-current-user"; 2 | import { getBankingOverview } from "@/actions/banking/get-banking-overview"; 3 | import BankAccountOverview from "@/components/transactions/bank-account-overview"; 4 | import BankAccountSelector from "@/components/transactions/bank-account-selector"; 5 | import { redirect } from "next/navigation"; 6 | import { EmptyPlaceholder } from "@/components/empty-placeholder"; 7 | import { Wallet } from "lucide-react"; 8 | import { AddBankAccountComponent } from "@/components/account-connection"; 9 | 10 | export default async function BankingPage() { 11 | const user = await getCurrentUser(); 12 | if (!user) redirect("/sign-in"); 13 | 14 | const { success, data, error } = await getBankingOverview(); 15 | console.log(data); 16 | 17 | if (!success || !data || data.bankAccounts.length === 0) { 18 | return ( 19 |
20 |
21 |

Bank Accounts

22 |
23 | 24 |
25 |
26 | 27 | 28 | No bank accounts yet 29 | 30 | Connect your bank accounts to start tracking your finances. 31 | 32 | 33 |
34 | ); 35 | } 36 | 37 | return ( 38 |
39 |
40 |

Bank Accounts

41 |
42 | 43 |
44 |
45 | 46 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/actions/user/get-current-user"; 2 | import { getDashboardOverview } from "@/actions/dashboard/get-dashboard-overview"; 3 | import { AddBankAccountComponent } from "@/components/account-connection"; 4 | import { MetricCards } from "@/components/dashboard/metric-cards"; 5 | import { TopCharts } from "@/components/dashboard/top-charts"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export default async function DashboardPage() { 9 | const user = await getCurrentUser(); 10 | if (!user) redirect("/sign-in"); 11 | 12 | const { success, data, error } = await getDashboardOverview(); 13 | 14 | return ( 15 |
16 |
17 |

18 | Hey {user?.name?.split(" ")[0]}, Welcome back 👋 19 |

20 |
21 | 22 |
23 |
24 | 25 | 26 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { AppSidebar } from "@/components/app-sidebar"; 2 | import { 3 | SidebarProvider, 4 | SidebarTrigger, 5 | SidebarInset, 6 | } from "@/components/ui/sidebar"; 7 | import { Separator } from "@/components/ui/separator"; 8 | import { Breadcrumbs } from "@/components/breadcrumbs"; 9 | 10 | interface DashboardLayoutProps { 11 | children?: React.ReactNode; 12 | params: { id: string }; 13 | } 14 | 15 | export default function DashboardLayout({ 16 | children, 17 | params, 18 | }: DashboardLayoutProps) { 19 | return ( 20 | 21 | 22 | 23 |
24 |
25 | 26 | 27 | 28 |
29 |
30 |
{children}
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/app/(main)/savings/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/actions/user/get-current-user"; 2 | import { getSavingsOverview } from "@/actions/savings/get-savings-overview"; 3 | import { SavingsGoals } from "@/components/savings/savings-goals"; 4 | import { SavingsOverview } from "@/components/savings/savings-overview"; 5 | import { SavingsRecommendations } from "@/components/savings/savings-recommendations"; 6 | import { SavingsActions } from "@/components/savings/savings-actions"; 7 | import { redirect } from "next/navigation"; 8 | import { EmptyPlaceholder } from "@/components/empty-placeholder"; 9 | import { PiggyBank } from "lucide-react"; 10 | import { AddSavingsGoal } from "@/components/savings/add-savings-goal"; 11 | 12 | export default async function SavingsPage() { 13 | const user = await getCurrentUser(); 14 | if (!user) redirect("/sign-in"); 15 | 16 | const { success, data, error } = await getSavingsOverview(); 17 | 18 | if (!success || !data || data.savingsGoals.length === 0) { 19 | return ( 20 |
21 |
22 |

Your savings

23 |
24 | 25 | 26 | No savings goals yet 27 | 28 | Create your first savings goal to start tracking your progress. 29 | 30 | 31 | 32 |
33 | ); 34 | } 35 | 36 | return ( 37 |
38 |
39 |

40 | Your savings (under development) 41 |

42 | 43 |
44 | 45 |
46 | 47 | 48 | 49 |
50 | 51 |
52 | 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/app/(main)/template/page.tsx: -------------------------------------------------------------------------------- 1 | import { AppSidebar } from "@/components/app-sidebar"; 2 | import { 3 | Breadcrumb, 4 | BreadcrumbItem, 5 | BreadcrumbLink, 6 | BreadcrumbList, 7 | BreadcrumbPage, 8 | BreadcrumbSeparator, 9 | } from "@/components/ui/breadcrumb"; 10 | import { Separator } from "@/components/ui/separator"; 11 | import { 12 | SidebarInset, 13 | SidebarProvider, 14 | SidebarTrigger, 15 | } from "@/components/ui/sidebar"; 16 | 17 | export default function Page() { 18 | return ( 19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/(marketing)/blog/layout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/sections/footer"; 2 | import Header from "@/components/sections/header"; 3 | 4 | interface MarketingLayoutProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | export default async function Layout({ children }: MarketingLayoutProps) { 9 | return ( 10 | <> 11 |
12 |
{children}
13 |