diff --git a/app/(dashboard)/budgets/_components/AutoSuggestBudgetButton.tsx b/app/(dashboard)/budgets/_components/AutoSuggestBudgetButton.tsx new file mode 100644 index 0000000..fe58277 --- /dev/null +++ b/app/(dashboard)/budgets/_components/AutoSuggestBudgetButton.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Sparkles } from "lucide-react"; +import { toast } from "sonner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface AutoSuggestBudgetButtonProps { + month: number; + year: number; +} + +export default function AutoSuggestBudgetButton({ + month, + year, +}: AutoSuggestBudgetButtonProps) { + const queryClient = useQueryClient(); + + const autoSuggestMutation = useMutation({ + mutationFn: async () => { + const response = await fetch("/api/budgets/auto-suggest", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + month, + year, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || error.error || "Failed to auto-suggest budget"); + } + + return response.json(); + }, + onSuccess: (data) => { + toast.success("Budget Auto-Suggested โœจ", { + description: data.message, + }); + queryClient.invalidateQueries({ queryKey: ["budget-progress"] }); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); + + return ( + + + + + + +

Calculates your 3-month average to build your budget instantly.

+

Discretionary spending is cut by 5% to boost savings.

+
+
+
+ ); +} diff --git a/app/(dashboard)/budgets/_components/BudgetChart.tsx b/app/(dashboard)/budgets/_components/BudgetChart.tsx index 538f4e6..09e6da7 100644 --- a/app/(dashboard)/budgets/_components/BudgetChart.tsx +++ b/app/(dashboard)/budgets/_components/BudgetChart.tsx @@ -73,7 +73,17 @@ export default function BudgetChart({ }} > - + ( + initialAmount !== "" ? initialAmount.toString() : "" + ); + const queryClient = useQueryClient(); + + useEffect(() => { + setValue(initialAmount !== "" ? initialAmount.toString() : ""); + }, [initialAmount]); + + const mutation = useMutation({ + mutationFn: async (amount: number) => { + const response = await fetch("/api/budgets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + category, + categoryIcon, + amount, + month, + year, + }), + }); + if (!response.ok) throw new Error("Failed to save budget"); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["budgets", year] }); + queryClient.invalidateQueries({ queryKey: ["budget-progress"] }); + toast.success(`Saved ${category} budget for ${MONTHS[month]} ${year}`); + }, + onError: () => { + toast.error("Failed to save budget"); + setValue(initialAmount !== "" ? initialAmount.toString() : ""); + }, + }); + + const handleBlur = () => { + if (value === "" && initialAmount === "") return; + if (value !== "" && parseFloat(value) === initialAmount) return; + + if (value === "") { + // Technically should delete if empty, but for now we skip or can set to 0 + return; + } + + const numValue = parseFloat(value); + if (!isNaN(numValue) && numValue >= 0) { + // Optimistically update or just start mutation + mutation.mutate(numValue); + } else { + toast.error("Invalid amount"); + setValue(initialAmount !== "" ? initialAmount.toString() : ""); + } + }; + + return ( + setValue(e.target.value)} + onBlur={handleBlur} + disabled={disabled} + className={`h-8 w-16 md:w-24 text-right text-sm ${value !== "" && "font-semibold"} ${disabled && "bg-muted/30 cursor-not-allowed opacity-50"}`} + placeholder="-" + /> + ); +} + +export default function BudgetGridView({ + userSettings, + year, +}: BudgetGridViewProps) { + const { data: budgets, isFetching: budgetsFetching } = useQuery({ + queryKey: ["budgets", year], + queryFn: () => + fetch(`/api/budgets?year=${year}`).then((res) => res.json()), + }); + + const { data: categories, isFetching: categoriesFetching } = useQuery< + Category[] + >({ + queryKey: ["categories"], + queryFn: () => + fetch(`/api/categories`).then((res) => res.json()), + }); + + const isLoading = budgetsFetching || categoriesFetching; + + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth(); + + // We only want expense categories ideally, or whatever categories user has budgets for + const userCategories = (categories || []).filter(c => c.type === "expense"); + + // also add categories that have budgets but might be deleted/different type + const activeCategoryNames = new Set(userCategories.map(c => c.name)); + const missingCategories = (budgets || []).filter(b => !activeCategoryNames.has(b.category)); + + const allCategoriesMap = new Map(); + userCategories.forEach(c => allCategoriesMap.set(c.name, c)); + missingCategories.forEach(b => { + if(!allCategoriesMap.has(b.category)) { + allCategoriesMap.set(b.category, { name: b.category, icon: b.categoryIcon }); + } + }); + + const allCategories = Array.from(allCategoriesMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + + const formatter = GetFormatterForCurrency(userSettings.currency); + + return ( + +
+ + + + + Category + {MONTHS.map((monthStr, mIndex) => ( + + {monthStr} + + ))} + Total + + + + {allCategories.map((cat) => { + let yearlyTotal = 0; + return ( + + +
+ {cat.icon} + {cat.name} +
+
+ {MONTHS.map((_, month) => { + const budget = budgets?.find( + (b) => b.category === cat.name && b.month === month + ); + if (budget) yearlyTotal += budget.amount; + return ( + + currentYear || (year === currentYear && month > currentMonth)} + /> + + ); + })} + + {formatter.format(yearlyTotal)} + +
+ ); + })} + {allCategories.length === 0 && ( + + + No expense categories found. Create categories first to use grid view. + + + )} +
+
+ +
+
+
+ ); +} diff --git a/app/(dashboard)/budgets/_components/BudgetProgressCards.tsx b/app/(dashboard)/budgets/_components/BudgetProgressCards.tsx index 87ebe51..d9a86b5 100644 --- a/app/(dashboard)/budgets/_components/BudgetProgressCards.tsx +++ b/app/(dashboard)/budgets/_components/BudgetProgressCards.tsx @@ -92,7 +92,7 @@ export default function BudgetProgressCards({ {dataAvailable ? ( <> -
+
{budgetProgress.map((budget) => ( - - {budget.categoryIcon} - {budget.category} + + {budget.categoryIcon} + {budget.category}
{budget.isOverBudget && ( diff --git a/app/(dashboard)/budgets/_components/BudgetSummaryCard.tsx b/app/(dashboard)/budgets/_components/BudgetSummaryCard.tsx index 6e3f18e..c7c80ce 100644 --- a/app/(dashboard)/budgets/_components/BudgetSummaryCard.tsx +++ b/app/(dashboard)/budgets/_components/BudgetSummaryCard.tsx @@ -130,7 +130,7 @@ export default function BudgetSummaryCard({ -
+
{/* Total Budget */}

Total Budget

@@ -239,7 +239,7 @@ export default function BudgetSummaryCard({ {/* Available to Budget Section */}
= 0 ? "border-emerald-500/50 bg-emerald-50 dark:bg-emerald-950/20" : "border-orange-500/50 bg-orange-50 dark:bg-orange-950/20" diff --git a/app/(dashboard)/budgets/_components/BudgetsContent.tsx b/app/(dashboard)/budgets/_components/BudgetsContent.tsx index 0b217fc..d403df9 100644 --- a/app/(dashboard)/budgets/_components/BudgetsContent.tsx +++ b/app/(dashboard)/budgets/_components/BudgetsContent.tsx @@ -9,6 +9,9 @@ import { useState } from "react"; import CreateBudgetDialog from "./CreateBudgetDialog"; import BudgetTemplatesDialog from "./BudgetTemplatesDialog"; import BudgetProgressCards from "./BudgetProgressCards"; +import SweepBanner from "./SweepBanner"; +import AutoSuggestBudgetButton from "./AutoSuggestBudgetButton"; +import BudgetGridView from "./BudgetGridView"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -18,7 +21,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Lock, Unlock, DownloadIcon, FileText, ChevronDown } from "lucide-react"; +import { Lock, Unlock, DownloadIcon, FileText, ChevronDown, LayoutGrid, CreditCard } from "lucide-react"; import { mkConfig, generateCsv, download } from "export-to-csv"; import { exportBudgetToPDF } from "@/lib/pdf-export"; import { @@ -37,6 +40,7 @@ export default function BudgetsContent({ userSettings }: BudgetsContentProps) { const [selectedMonth, setSelectedMonth] = useState(currentDate.getMonth()); const [selectedYear, setSelectedYear] = useState(currentDate.getFullYear()); const [isFrozen, setIsFrozen] = useState(false); + const [viewMode, setViewMode] = useState<"card" | "grid">("card"); const queryClient = useQueryClient(); @@ -127,127 +131,160 @@ export default function BudgetsContent({ userSettings }: BudgetsContentProps) { return ( <>
-
-
-

Budget Goals

-

+

+
+

Budget Goals

+

Set spending limits and track your progress

-
-
- setSelectedMonth(parseInt(value))} + > + + + + + {months.map((month, index) => { + const isFutureMonth = selectedYear === currentDate.getFullYear() && index > currentDate.getMonth(); + return ( + + {month} + + ); + })} + + + + - - -
+ ))} + + +
- -
+
- - - - - - Create Budget - Create - - } - month={selectedMonth} - year={selectedYear} - /> - - - - - - - - - Export CSV - - - Export PDF - - -
- -
+ + +
+ + + + + + + + + Create Budget + Create + + } + month={selectedMonth} + year={selectedYear} + /> + + + + + + + + + + Export CSV + + + Export PDF + + + +
+
+
- + {viewMode === "card" ? ( + + ) : ( + + )}
); diff --git a/app/(dashboard)/budgets/_components/SweepBanner.tsx b/app/(dashboard)/budgets/_components/SweepBanner.tsx new file mode 100644 index 0000000..3f25896 --- /dev/null +++ b/app/(dashboard)/budgets/_components/SweepBanner.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { UserSettings } from "@prisma/client"; +import { GetFormatterForCurrency, GetPrivacyMask } from "@/lib/helper"; +import { usePrivacyMode } from "@/components/providers/PrivacyProvider"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Coins, PartyPopper, ArrowRight, Wallet } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface SweepBannerProps { + userSettings: UserSettings; + month: number; + year: number; +} + +export default function SweepBanner({ userSettings, month, year }: SweepBannerProps) { + const { isPrivacyMode } = usePrivacyMode(); + const queryClient = useQueryClient(); + const [isOpen, setIsOpen] = useState(false); + const [selectedGoalId, setSelectedGoalId] = useState(""); + + const formatter = useMemo(() => { + return GetFormatterForCurrency(userSettings.currency); + }, [userSettings.currency]); + + // Check if today is near end of month or start of next + const now = new Date(); + const currentMonth = now.getMonth(); + const currentYear = now.getFullYear(); + + // We only show the banner if we are looking at the current month or previous month + // and we are within 3 days of the month boundary + const isRelevantTime = useMemo(() => { + const today = now.getDate(); + const lastDayOfMonth = new Date(currentYear, currentMonth + 1, 0).getDate(); + + // Last 3 days of current month + if (month === currentMonth && year === currentYear && today >= lastDayOfMonth - 2) { + return true; + } + + // First 3 days of current month (checking previous month's leftovers) + const prevMonth = currentMonth === 0 ? 11 : currentMonth - 1; + const prevYear = currentMonth === 0 ? currentYear - 1 : currentYear; + if (month === prevMonth && year === prevYear && today <= 3) { + return true; + } + + return false; + }, [month, year, currentMonth, currentYear]); + + const { data: budgetProgress, isLoading: isBudgetLoading } = useQuery({ + queryKey: ["budget-progress", month, year], + queryFn: () => + fetch(`/api/budgets/progress?month=${month}&year=${year}`).then((res) => + res.json() + ), + enabled: isRelevantTime, + }); + + const { data: savingsGoals, isLoading: isGoalsLoading } = useQuery({ + queryKey: ["savings-goals"], + queryFn: () => fetch("/api/savings-goals").then((res) => res.json()), + enabled: isOpen, + }); + + const unspentTotal = useMemo(() => { + if (!budgetProgress) return 0; + return budgetProgress.reduce((acc: number, b: any) => { + const remaining = b.budgetAmount - b.spent; + return acc + (remaining > 0 ? remaining : 0); + }, 0); + }, [budgetProgress]); + + const sweepMutation = useMutation({ + mutationFn: async (goalId: string) => { + const response = await fetch("/api/budgets/sweep", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + month, + year, + goalId, + amount: unspentTotal, + }), + }); + + if (!response.ok) { + throw new Error("Failed to sweep funds"); + } + + return response.json(); + }, + onSuccess: () => { + toast.success("Funds swept successfully! ๐ŸŽ‰"); + setIsOpen(false); + queryClient.invalidateQueries({ queryKey: ["budget-progress"] }); + queryClient.invalidateQueries({ queryKey: ["savings-goals"] }); + queryClient.invalidateQueries({ queryKey: ["balance"] }); + }, + onError: () => { + toast.error("Something went wrong"); + }, + }); + + if (!isRelevantTime || unspentTotal <= 0) return null; + + return ( + +
+ +
+ + +
+
+
+ +
+
+

+ You saved {isPrivacyMode ? GetPrivacyMask(formatter) : formatter.format(unspentTotal)} this month! +

+

+ Great job coming in under budget! Put your leftovers to work and reach your goals faster. +

+
+
+ + + + + + + + Sweep Funds to Savings + + Move your unspent budget of {formatter.format(unspentTotal)} into a specific savings goal. + + + +
+
+ + +
+ +
+
+ Amount to sweep: + + {formatter.format(unspentTotal)} + +
+
+ + This will be recorded as a savings contribution. +
+
+
+ + + + + +
+
+
+
+
+ ); +} diff --git a/app/(dashboard)/manage/_components/WorkspaceJoinQRCode.tsx b/app/(dashboard)/manage/_components/WorkspaceJoinQRCode.tsx index aeaaf02..88d5a81 100644 --- a/app/(dashboard)/manage/_components/WorkspaceJoinQRCode.tsx +++ b/app/(dashboard)/manage/_components/WorkspaceJoinQRCode.tsx @@ -80,7 +80,7 @@ export function WorkspaceJoinQRCode({ workspaceId }: { workspaceId: string }) { Join QR - +
@@ -95,112 +95,118 @@ export function WorkspaceJoinQRCode({ workspaceId }: { workspaceId: string }) { -
- {/* Role Selector */} -
- - -
+
+ {/* 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 */} - + +
-
-

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(); - });