+ {/* Left Column (Controls & Info) */}
+
+ {/* Role Selector */}
+
+
+
+
- {/* QR Code Container */}
-
-
-
-
- {isLoading || isRefetching ? (
-
-
-
- ) : data?.inviteLink ? (
-
-
-
- ) : (
-
- Failed to generate QR code.
-
- )}
+
+
Scan with your phone's camera
+
+ Anyone who scans this will be added as a{" "}
+ {role.toLowerCase()}.
+
+
- {/* Floating scan indicator */}
-
+
+ {copied ? (
+
+ ) : (
+
+ )}
+ Copy Link
+
+
-
-
Scan with your phone's camera
-
- Anyone who scans this will be added as a{" "}
- {role.toLowerCase()}.
-
-
+ {/* Right Column (QR Code) */}
+
+
+
+
+
+ {isLoading || isRefetching ? (
+
+
+
+ ) : data?.inviteLink ? (
+
+
+
+ ) : (
+
+ Failed to generate QR code.
+
+ )}
-
-
-
+ {/* Floating scan indicator */}
+
+ SCAN TO JOIN
+
+
+
diff --git a/app/api/budgets/auto-suggest/route.ts b/app/api/budgets/auto-suggest/route.ts
new file mode 100644
index 0000000..d011087
--- /dev/null
+++ b/app/api/budgets/auto-suggest/route.ts
@@ -0,0 +1,146 @@
+import prisma from "@/lib/prisma";
+import { currentUser } from "@clerk/nextjs/server";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+import { getActiveWorkspace, logActivity } from "@/lib/workspaces";
+
+const luxuryCategories = [
+ "Shopping",
+ "Entertainment",
+ "Travel",
+ "Dining",
+ "Luxury",
+ "Misc",
+];
+
+export async function POST(request: Request) {
+ const user = await currentUser();
+ if (!user) {
+ redirect("/sign-in");
+ }
+
+ const workspace = await getActiveWorkspace(user.id);
+ if (!workspace)
+ return Response.json({ error: "No active workspace" }, { status: 400 });
+ if (workspace.role === "VIEWER")
+ return Response.json(
+ { error: "Viewers cannot create budgets" },
+ { status: 403 },
+ );
+
+ const body = await request.json();
+
+ const bodySchema = z.object({
+ month: z.number().min(0).max(11),
+ year: z.number(),
+ });
+
+ const parsedBody = bodySchema.safeParse(body);
+
+ if (!parsedBody.success) {
+ return Response.json(parsedBody.error, { status: 400 });
+ }
+
+ const { month, year } = parsedBody.data;
+
+ const targetDate = new Date(year, month, 1);
+ const startDate = new Date(targetDate);
+ startDate.setMonth(startDate.getMonth() - 3);
+
+ const transactions = await prisma.transaction.findMany({
+ where: {
+ workspaceId: workspace.id,
+ type: "expense",
+ date: {
+ gte: startDate,
+ lt: targetDate,
+ },
+ deletedAt: null,
+ },
+ });
+
+ if (transactions.length === 0) {
+ return Response.json(
+ { message: "Not enough transaction history yet." },
+ { status: 400 },
+ );
+ }
+
+ const categoryTotals: Record
= {};
+
+ for (const t of transactions) {
+ if (!categoryTotals[t.category]) {
+ categoryTotals[t.category] = { total: 0, icon: t.categoryIcon };
+ }
+ categoryTotals[t.category].total += t.amount;
+ }
+
+ let createdCount = 0;
+ let reducedCategories = [];
+
+ for (const [category, data] of Object.entries(categoryTotals)) {
+ let average = data.total / 3;
+
+ if (luxuryCategories.includes(category)) {
+ average = average * 0.95;
+ reducedCategories.push(category);
+ }
+
+ average = Math.ceil(average);
+
+ if (average <= 0) continue;
+
+ await prisma.budget.upsert({
+ where: {
+ userId_category_month_year: {
+ userId: user.id,
+ category,
+ month,
+ year,
+ },
+ },
+ update: {
+ amount: average,
+ categoryIcon: data.icon,
+ workspaceId: workspace.id,
+ deletedAt: null,
+ },
+ create: {
+ userId: user.id,
+ workspaceId: workspace.id,
+ category,
+ categoryIcon: data.icon,
+ amount: average,
+ month,
+ year,
+ },
+ });
+ createdCount++;
+ }
+
+ const userName = user.firstName
+ ? `${user.firstName}${user.lastName ? ` ${user.lastName}` : ""}`
+ : user.emailAddresses[0].emailAddress.split("@")[0];
+
+ await logActivity({
+ workspaceId: workspace.id,
+ userId: user.id,
+ type: "BUDGET_UPDATED",
+ description: `${userName} used Auto-Suggest to create ${createdCount} budgets for ${
+ month + 1
+ }/${year}`,
+ metadata: { userName, month, year, createdCount },
+ });
+
+ let message = `Auto-suggested ${createdCount} budget categories based on your 3-month average.`;
+ if (reducedCategories.length > 0) {
+ message += ` Discretionary spending (${reducedCategories.join(", ")}) was reduced by 5% to boost savings โจ`;
+ }
+
+ return Response.json(
+ {
+ message,
+ },
+ { status: 201 },
+ );
+}
diff --git a/app/api/budgets/route.ts b/app/api/budgets/route.ts
index 9d670d9..3978030 100644
--- a/app/api/budgets/route.ts
+++ b/app/api/budgets/route.ts
@@ -19,7 +19,7 @@ export async function GET(request: Request) {
const year = searchParams.get("year");
const querySchema = z.object({
- month: z.string(),
+ month: z.string().optional().nullable(),
year: z.string(),
});
@@ -29,12 +29,16 @@ export async function GET(request: Request) {
return Response.json(queryParams.error, { status: 400 });
}
+ const whereClause: any = {
+ ...(workspaceId ? { workspaceId } : { userId: user.id }),
+ year: parseInt(queryParams.data.year),
+ };
+ if (queryParams.data.month) {
+ whereClause.month = parseInt(queryParams.data.month);
+ }
+
const budgets = await prisma.budget.findMany({
- where: {
- ...(workspaceId ? { workspaceId } : { userId: user.id }),
- month: parseInt(queryParams.data.month),
- year: parseInt(queryParams.data.year),
- },
+ where: whereClause,
orderBy: {
category: "asc",
},
diff --git a/app/api/budgets/sweep/route.ts b/app/api/budgets/sweep/route.ts
new file mode 100644
index 0000000..4d9c723
--- /dev/null
+++ b/app/api/budgets/sweep/route.ts
@@ -0,0 +1,140 @@
+import prisma from "@/lib/prisma";
+import { currentUser } from "@clerk/nextjs/server";
+import { getActiveWorkspace } from "@/lib/workspaces";
+import { z } from "zod";
+
+export async function POST(request: Request) {
+ const user = await currentUser();
+ if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
+
+ const workspace = await getActiveWorkspace();
+ const workspaceId = workspace?.id;
+
+ const body = await request.json();
+ const schema = z.object({
+ month: z.number(),
+ year: z.number(),
+ goalId: z.string(),
+ amount: z.number().positive()
+ });
+
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) return Response.json(parsed.error, { status: 400 });
+
+ const { month, year, goalId, amount } = parsed.data;
+
+ const goal = await prisma.savingsGoal.findUnique({ where: { id: goalId } });
+ if (!goal || (goal.userId !== user.id && goal.workspaceId !== workspaceId)) {
+ return Response.json({ error: "Goal not found" }, { status: 404 });
+ }
+
+ try {
+ await prisma.$transaction(async (tx) => {
+ // Determine the date for the sweep transaction
+ // If we are sweeping a past month, use the last day of that month.
+ // If the current date is still in the same month being viewed, use today.
+ const now = new Date();
+ let sweepDate = now;
+ if (year < now.getFullYear() || (year === now.getFullYear() && month < now.getMonth())) {
+ sweepDate = new Date(year, month + 1, 0, 23, 59, 59);
+ }
+
+ const sweepDay = sweepDate.getDate();
+ const sweepMonth = sweepDate.getMonth();
+ const sweepYear = sweepDate.getFullYear();
+
+ // 1. Create a transaction of type "expense" to represent the sweep out of general funds
+ await tx.transaction.create({
+ data: {
+ userId: user.id,
+ workspaceId: workspaceId,
+ amount: amount,
+ date: sweepDate,
+ description: `End of Month Sweep (${month + 1}/${year}) -> ${goal.name}`,
+ type: "expense",
+ category: goal.category || "Savings", // Or specifically point to savings
+ categoryIcon: goal.icon || "๐ฏ",
+ }
+ });
+
+ // 2. Add goal contribution
+ const userName = user.firstName ? `${user.firstName}${user.lastName ? ` ${user.lastName}` : ""}` : user.emailAddresses[0].emailAddress.split("@")[0];
+
+ await tx.goalContribution.create({
+ data: {
+ goalId,
+ userId: user.id,
+ userName,
+ userImage: user.imageUrl,
+ amount,
+ createdAt: sweepDate,
+ }
+ });
+
+ // 3. Update goal balance
+ const newAmount = goal.currentAmount + amount;
+ await tx.savingsGoal.update({
+ where: { id: goalId },
+ data: {
+ currentAmount: newAmount,
+ isCompleted: newAmount >= goal.targetAmount
+ }
+ });
+
+ // 4. Update monthly history
+ await tx.monthlyHistory.upsert({
+ where: {
+ day_month_year_userId: {
+ userId: user.id,
+ day: sweepDay,
+ month: sweepMonth,
+ year: sweepYear,
+ },
+ },
+ create: {
+ userId: user.id,
+ workspaceId,
+ day: sweepDay,
+ month: sweepMonth,
+ year: sweepYear,
+ expense: amount,
+ income: 0,
+ },
+ update: {
+ expense: {
+ increment: amount,
+ },
+ },
+ });
+
+ // 5. Update year history
+ await tx.yearHistory.upsert({
+ where: {
+ month_year_userId: {
+ userId: user.id,
+ month: sweepMonth,
+ year: sweepYear,
+ },
+ },
+ create: {
+ userId: user.id,
+ workspaceId,
+ month: sweepMonth,
+ year: sweepYear,
+ expense: amount,
+ income: 0,
+ },
+ update: {
+ expense: {
+ increment: amount,
+ },
+ },
+ });
+ });
+
+ return Response.json({ success: true, message: "Funds swept successfully" });
+ } catch (e) {
+ console.error("Failed to sweep funds", e);
+ return Response.json({ error: "Failed to sweep funds" }, { status: 500 });
+ }
+}
diff --git a/package.json b/package.json
index c5b6847..149a708 100644
--- a/package.json
+++ b/package.json
@@ -1,103 +1,103 @@
{
- "name": "budgetbuddy",
- "version": "2.0.0",
- "private": true,
- "scripts": {
- "dev": "next dev",
- "build": "next build",
- "start": "next start",
- "lint": "next lint",
- "postinstall": "prisma generate"
- },
- "dependencies": {
- "@clerk/nextjs": "^6.36.7",
- "@clerk/themes": "^2.4.55",
- "@emoji-mart/data": "^1.2.1",
- "@emoji-mart/react": "^1.1.1",
- "@google/genai": "^1.39.0",
- "@hookform/resolvers": "^3.10.0",
- "@prisma/client": "^6.19.1",
- "@radix-ui/react-accordion": "^1.2.12",
- "@radix-ui/react-alert-dialog": "^1.1.15",
- "@radix-ui/react-aspect-ratio": "^1.1.8",
- "@radix-ui/react-avatar": "^1.1.11",
- "@radix-ui/react-checkbox": "^1.3.3",
- "@radix-ui/react-collapsible": "^1.1.12",
- "@radix-ui/react-context-menu": "^2.2.16",
- "@radix-ui/react-dialog": "^1.1.15",
- "@radix-ui/react-dropdown-menu": "^2.1.16",
- "@radix-ui/react-hover-card": "^1.1.15",
- "@radix-ui/react-icons": "^1.3.2",
- "@radix-ui/react-label": "^2.1.8",
- "@radix-ui/react-menubar": "^1.1.16",
- "@radix-ui/react-navigation-menu": "^1.2.14",
- "@radix-ui/react-popover": "^1.1.15",
- "@radix-ui/react-progress": "^1.1.8",
- "@radix-ui/react-radio-group": "^1.3.8",
- "@radix-ui/react-scroll-area": "^1.2.10",
- "@radix-ui/react-select": "^2.2.6",
- "@radix-ui/react-separator": "^1.1.8",
- "@radix-ui/react-slider": "^1.3.6",
- "@radix-ui/react-slot": "^1.2.4",
- "@radix-ui/react-switch": "^1.2.6",
- "@radix-ui/react-tabs": "^1.1.13",
- "@radix-ui/react-toast": "^1.2.15",
- "@radix-ui/react-toggle": "^1.1.10",
- "@radix-ui/react-toggle-group": "^1.1.11",
- "@radix-ui/react-tooltip": "^1.2.8",
- "@sendgrid/mail": "^8.1.6",
- "@tailwindcss/typography": "^0.5.19",
- "@tanstack/react-query": "^5.90.16",
- "@tanstack/react-table": "^8.21.3",
- "@uploadthing/react": "^7.3.3",
- "canvas-confetti": "^1.9.4",
- "class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
- "cmdk": "^1.1.1",
- "date-fns": "^4.1.0",
- "embla-carousel-react": "^8.6.0",
- "emoji-mart": "^5.6.0",
- "export-to-csv": "^1.4.0",
- "framer-motion": "^12.26.2",
- "groq-sdk": "^0.37.0",
- "input-otp": "^1.4.2",
- "jspdf": "^4.0.0",
- "jspdf-autotable": "^5.0.7",
- "lucide-react": "^0.562.0",
- "next": "^15.5.9",
- "next-themes": "^0.4.6",
- "openai": "^6.17.0",
- "papaparse": "^5.5.3",
- "qrcode.react": "^4.2.0",
- "react": "^19",
- "react-countup": "^6.5.3",
- "react-day-picker": "^9.13.0",
- "react-dom": "^19",
- "react-hook-form": "^7.70.0",
- "react-markdown": "^10.1.0",
- "react-resizable-panels": "^4.3.3",
- "recharts": "^2.15.3",
- "sonner": "^2.0.7",
- "tailwind-merge": "^2.6.0",
- "tailwindcss-animate": "^1.0.7",
- "uploadthing": "^7.7.4",
- "vaul": "^1.1.2",
- "zod": "^3.25.76",
- "zustand": "^5.0.11"
- },
- "devDependencies": {
- "@tanstack/react-query-devtools": "^5.91.2",
- "@types/canvas-confetti": "^1.9.0",
- "@types/node": "^22",
- "@types/papaparse": "^5.5.2",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "dotenv-cli": "^8.0.0",
- "eslint": "^9",
- "eslint-config-next": "^15.3.4",
- "postcss": "^8",
- "prisma": "^6.19.1",
- "tailwindcss": "^3.4.19",
- "typescript": "^5"
- }
+ "name": "budgetbuddy",
+ "version": "2.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "postinstall": "prisma generate"
+ },
+ "dependencies": {
+ "@clerk/nextjs": "^6.36.7",
+ "@clerk/themes": "^2.4.55",
+ "@emoji-mart/data": "^1.2.1",
+ "@emoji-mart/react": "^1.1.1",
+ "@google/genai": "^1.39.0",
+ "@hookform/resolvers": "^3.10.0",
+ "@prisma/client": "^6.19.1",
+ "@radix-ui/react-accordion": "^1.2.12",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
+ "@radix-ui/react-aspect-ratio": "^1.1.8",
+ "@radix-ui/react-avatar": "^1.1.11",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-context-menu": "^2.2.16",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-hover-card": "^1.1.15",
+ "@radix-ui/react-icons": "^1.3.2",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-menubar": "^1.1.16",
+ "@radix-ui/react-navigation-menu": "^1.2.14",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-progress": "^1.1.8",
+ "@radix-ui/react-radio-group": "^1.3.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slider": "^1.3.6",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-toast": "^1.2.15",
+ "@radix-ui/react-toggle": "^1.1.10",
+ "@radix-ui/react-toggle-group": "^1.1.11",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@sendgrid/mail": "^8.1.6",
+ "@tailwindcss/typography": "^0.5.19",
+ "@tanstack/react-query": "^5.90.16",
+ "@tanstack/react-table": "^8.21.3",
+ "@uploadthing/react": "^7.3.3",
+ "canvas-confetti": "^1.9.4",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "date-fns": "^4.1.0",
+ "embla-carousel-react": "^8.6.0",
+ "emoji-mart": "^5.6.0",
+ "export-to-csv": "^1.4.0",
+ "framer-motion": "^12.26.2",
+ "groq-sdk": "^0.37.0",
+ "input-otp": "^1.4.2",
+ "jspdf": "^4.0.0",
+ "jspdf-autotable": "^5.0.7",
+ "lucide-react": "^0.562.0",
+ "next": "^15.5.9",
+ "next-themes": "^0.4.6",
+ "openai": "^6.17.0",
+ "papaparse": "^5.5.3",
+ "qrcode.react": "^4.2.0",
+ "react": "^19",
+ "react-countup": "^6.5.3",
+ "react-day-picker": "^9.13.0",
+ "react-dom": "^19",
+ "react-hook-form": "^7.70.0",
+ "react-markdown": "^10.1.0",
+ "react-resizable-panels": "^4.3.3",
+ "recharts": "^2.15.3",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "uploadthing": "^7.7.4",
+ "vaul": "^1.1.2",
+ "zod": "^3.25.76",
+ "zustand": "^5.0.11"
+ },
+ "devDependencies": {
+ "@tanstack/react-query-devtools": "^5.91.2",
+ "@types/canvas-confetti": "^1.9.0",
+ "@types/node": "^22",
+ "@types/papaparse": "^5.5.2",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "dotenv-cli": "^8.0.0",
+ "eslint": "^9",
+ "eslint-config-next": "^15.3.4",
+ "postcss": "^8",
+ "prisma": "^6.19.1",
+ "tailwindcss": "^3.4.19",
+ "typescript": "^5"
+ }
}
diff --git a/scripts/seed-templates.ts b/scripts/seed-templates.ts
deleted file mode 100644
index 2009c23..0000000
--- a/scripts/seed-templates.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-const { PrismaClient } = require("@prisma/client");
-const prisma = new PrismaClient();
-
-async function main() {
- console.log("Seeding default budget templates...");
-
- const templates = [
- {
- name: "50/30/20 Rule (Starter)",
- description: "Allocates 50% for Needs, 30% for Wants, and 20% for Savings/Debt. Based on a $2,000 monthly budget.",
- isSystem: true,
- entries: [
- { category: "Housing", categoryIcon: "๐ ", amount: 600 },
- { category: "Utilities", categoryIcon: "๐", amount: 200 },
- { category: "Groceries", categoryIcon: "๐", amount: 200 },
- { category: "Dining Out", categoryIcon: "๐", amount: 300 },
- { category: "Entertainment", categoryIcon: "๐ฌ", amount: 300 },
- { category: "Savings", categoryIcon: "๐ฐ", amount: 400 },
- ],
- },
- {
- name: "Zero-Based Budget (Student)",
- description: "Every dollar has a job. Optimized for students with lower income but fixed costs.",
- isSystem: true,
- entries: [
- { category: "Rent/Dorm", categoryIcon: "๐ซ", amount: 500 },
- { category: "Textbooks/Study", categoryIcon: "๐", amount: 100 },
- { category: "Food/Dining", categoryIcon: "๐", amount: 250 },
- { category: "Transport", categoryIcon: "๐", amount: 50 },
- { category: "Personal Care", categoryIcon: "๐งผ", amount: 50 },
- { category: "Emergency Fund", categoryIcon: "๐จ", amount: 50 },
- ],
- },
- {
- name: "Freelancer/Business Budget",
- description: "Designed for those with irregular income, focusing on taxes and software first.",
- isSystem: true,
- entries: [
- { category: "Taxes (30%)", categoryIcon: "๐๏ธ", amount: 900 },
- { category: "Software/Subscriptions", categoryIcon: "๐ป", amount: 150 },
- { category: "Marketing", categoryIcon: "๐ฃ", amount: 200 },
- { category: "Office/Coworking", categoryIcon: "๐ข", amount: 300 },
- { category: "Professional Dev", categoryIcon: "๐", amount: 100 },
- { category: "Personal Pay", categoryIcon: "๐ฆ", amount: 1350 },
- ],
- },
- ];
-
- for (const t of templates) {
- const { entries, ...templateData } = t;
- await prisma.budgetTemplate.upsert({
- where: { id: `system-${t.name.toLowerCase().replace(/\s+/g, "-")}` }, // Temporary ID logic for upsert if we had static IDs
- // Actually BudgetTemplate doesn't have a unique name yet, I'll just create if not exists by name
- update: {},
- create: {
- userId: "system",
- ...templateData,
- id: undefined, // Let it generate UUID
- entries: {
- create: entries,
- },
- },
- });
- }
-
- console.log("Default templates seeded successfully!");
-}
-
-main()
- .catch((e) => {
- console.error(e);
- process.exit(1);
- })
- .finally(async () => {
- await prisma.$disconnect();
- });