diff --git a/sample_expenses.csv b/sample_expenses.csv new file mode 100644 index 0000000..11c645d --- /dev/null +++ b/sample_expenses.csv @@ -0,0 +1,9 @@ +Date,Merchant Name,Description,Amount,Category,Notes,Account Provider,Account Name,Status,Sub Type +2025-09-05,Tesco,TESCO EXTRA BIRMINGHAM,-45.67,Shopping,,Monzo,Monzo,, +2025-09-04,Costa Coffee,COSTA COFFEE BIRMINGHAM,-3.85,Food & Drink,,Monzo,Monzo,, +2025-09-03,Uber,UBER TRIP 12345,-12.50,Transportation,,Monzo,Monzo,, +2025-09-02,Amazon,AMAZON PRIME VIDEO,-8.99,Entertainment,,Monzo,Monzo,, +2025-09-01,Sainsbury's,SAINSBURYS BIRMINGHAM,-23.45,Shopping,,Monzo,Monzo,, +2025-08-31,McDonald's,MCDONALDS RESTAURANT,-7.89,Food & Drink,,Monzo,Monzo,, +2025-08-30,Shell,SHELL PETROL STATION,-55.00,Transportation,,TSB,TSB,, +2025-08-29,Netflix,NETFLIX SUBSCRIPTION,-11.99,Entertainment,,TSB,TSB,, diff --git a/src/app/api/expenses/import/route.ts b/src/app/api/expenses/import/route.ts new file mode 100644 index 0000000..5a2fa59 --- /dev/null +++ b/src/app/api/expenses/import/route.ts @@ -0,0 +1,247 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { TransactionType, CategoryKind } from "../../../../../generated/prisma"; +import { tracer } from "@/lib/telemetry"; +import { Decimal } from "@prisma/client/runtime/library"; + +interface CSVImportRequest { + csvData: string; + dateColumn: string; + amountColumn: string; + categoryColumn: string; + merchantColumn?: string; + descriptionColumn?: string; +} + +export async function POST(request: NextRequest) { + return tracer.startActiveSpan("api.expenses.import.POST", async (span) => { + try { + span.setAttributes({ + "http.method": "POST", + "http.route": "/api/expenses/import", + }); + + const user = await getCurrentUser(); + if (!user) { + span.setAttributes({ + "http.status_code": 401, + "auth.result": "unauthorized", + }); + span.end(); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { + csvData, + dateColumn, + amountColumn, + categoryColumn, + merchantColumn, + descriptionColumn, + }: CSVImportRequest = await request.json(); + + // Parse CSV data + const lines = csvData.trim().split("\n"); + if (lines.length < 2) { + return NextResponse.json( + { error: "CSV must have at least a header and one data row" }, + { status: 400 } + ); + } + + const headers = lines[0].split(",").map((h) => h.trim()); + const dateColumnIndex = headers.indexOf(dateColumn); + const amountColumnIndex = headers.indexOf(amountColumn); + const categoryColumnIndex = headers.indexOf(categoryColumn); + const merchantColumnIndex = merchantColumn + ? headers.indexOf(merchantColumn) + : -1; + const descriptionColumnIndex = descriptionColumn + ? headers.indexOf(descriptionColumn) + : -1; + + if ( + dateColumnIndex === -1 || + amountColumnIndex === -1 || + categoryColumnIndex === -1 + ) { + return NextResponse.json( + { + error: "Required columns not found in CSV", + missing: { + date: dateColumnIndex === -1, + amount: amountColumnIndex === -1, + category: categoryColumnIndex === -1, + }, + }, + { status: 400 } + ); + } + + const dataLines = lines.slice(1); + const transactions = []; + const categoriesMap = new Map(); + const skippedRows: string[] = []; + + // Get current date for filtering (last 90 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 90); + + span.setAttributes({ + "csv.total_rows": dataLines.length, + "csv.headers_count": headers.length, + }); + + for (let i = 0; i < dataLines.length; i++) { + const row = dataLines[i]; + const columns = row.split(",").map((c) => c.trim()); + + if (columns.length !== headers.length) { + skippedRows.push(`Row ${i + 2}: Column count mismatch`); + continue; + } + + try { + // Parse date + const dateStr = columns[dateColumnIndex]; + const transactionDate = new Date(dateStr); + + if (isNaN(transactionDate.getTime())) { + skippedRows.push(`Row ${i + 2}: Invalid date format: ${dateStr}`); + continue; + } + + // Only import transactions from the last 30 days + if (transactionDate < thirtyDaysAgo) { + continue; + } + + // Parse amount (handle negative values and currency symbols) + const amountStr = columns[amountColumnIndex].replace(/[£$€,]/g, ""); + const amount = parseFloat(amountStr); + + if (isNaN(amount)) { + skippedRows.push( + `Row ${i + 2}: Invalid amount: ${columns[amountColumnIndex]}` + ); + continue; + } + + // Make sure it's treated as an expense (positive amount) + const expenseAmount = Math.abs(amount); + + const categoryName = columns[categoryColumnIndex]; + const merchant = + merchantColumnIndex !== -1 + ? columns[merchantColumnIndex] + : undefined; + const description = + descriptionColumnIndex !== -1 + ? columns[descriptionColumnIndex] + : undefined; + + // Create or find category + let categoryId: string | undefined; + if (categoryName && categoryName !== "") { + if (!categoriesMap.has(categoryName)) { + // Create category if it doesn't exist + const existingCategory = await db.category.findFirst({ + where: { + userId: user.id, + name: categoryName, + kind: CategoryKind.EXPENSE, + }, + }); + + if (existingCategory) { + categoriesMap.set(categoryName, existingCategory.id); + } else { + const newCategory = await db.category.create({ + data: { + userId: user.id, + name: categoryName, + kind: CategoryKind.EXPENSE, + color: getRandomColor(), + }, + }); + categoriesMap.set(categoryName, newCategory.id); + } + } + categoryId = categoriesMap.get(categoryName); + } + + transactions.push({ + userId: user.id, + type: TransactionType.EXPENSE, + amount: new Decimal(expenseAmount), + occurredAt: transactionDate.toISOString(), + description: + [merchant, description].filter(Boolean).join(" - ") || undefined, + categoryId, + }); + } catch (error) { + skippedRows.push(`Row ${i + 2}: ${(error as Error).message}`); + continue; + } + } + + // Bulk create transactions + let importedCount = 0; + if (transactions.length > 0) { + const result = await db.transaction.createMany({ + data: transactions, + skipDuplicates: true, + }); + importedCount = result.count; + } + + span.setAttributes({ + "transactions.created": importedCount, + "transactions.skipped": skippedRows.length, + "categories.created": categoriesMap.size, + success: true, + }); + + span.end(); + + return NextResponse.json({ + success: true, + imported: importedCount, + skipped: skippedRows.length, + skippedReasons: skippedRows, + categoriesCreated: categoriesMap.size, + message: `Successfully imported ${importedCount} expense transactions from the last 30 days`, + }); + } catch (error) { + span.recordException(error as Error); + span.setAttributes({ + error: true, + "error.message": (error as Error).message, + }); + span.end(); + + console.error("CSV import error:", error); + return NextResponse.json( + { error: "Failed to import CSV data" }, + { status: 500 } + ); + } + }); +} + +function getRandomColor(): string { + const colors = [ + "#ef4444", // red + "#f97316", // orange + "#eab308", // yellow + "#22c55e", // green + "#06b6d4", // cyan + "#3b82f6", // blue + "#8b5cf6", // violet + "#ec4899", // pink + "#f59e0b", // amber + "#10b981", // emerald + ]; + return colors[Math.floor(Math.random() * colors.length)]; +} diff --git a/src/app/expenses/page.tsx b/src/app/expenses/page.tsx new file mode 100644 index 0000000..bfb10a7 --- /dev/null +++ b/src/app/expenses/page.tsx @@ -0,0 +1,10 @@ +import ExpenseManager from "@/components/ExpenseManager"; +import React from "react"; + +function Expenses() { + return
+ +
; +} + +export default Expenses; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6a79c53..2878269 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -134,7 +134,7 @@ export default function RootLayout({ -
{children}
+
{children}
diff --git a/src/app/page.tsx b/src/app/page.tsx index c195ed7..7e63351 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,4 @@ +import CurrentFinances from "@/components/dashboard/CurrentFinaces"; import { Button } from "@/components/ui/button"; import { Card, @@ -6,6 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useIncomeTransactions } from "@/hooks/useFinancialData"; import { SignedIn, SignedOut, SignUpButton } from "@clerk/nextjs"; import { currentUser } from "@clerk/nextjs/server"; import { Calendar, PoundSterling, Target, TrendingDown } from "lucide-react"; @@ -160,7 +162,7 @@ export default async function Home() { availableBalance >= 0 ? "text-white" : "text-red-500" }`} > - £{availableBalance.toLocaleString()} + £{}

(null); + + // TanStack Query hooks + const { data: categories = [], isLoading: categoriesLoading } = + useCategories(); + const { data: transactions = [], isLoading: transactionsLoading } = + useExpenseTransactions(); + const { data: expenseCategories = [] } = useExpenseCategories(); + + // Mutations + const createCategoryMutation = useCreateCategory({ + onSuccess: () => { + tracer.startActiveSpan("ui.expense_category_created", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "category_created", + }); + span.end(); + }); + setCategoryForm({ name: "", color: "#ef4444" }); + setShowCategoryForm(false); + toast.success("Category created successfully"); + }, + onError: (error) => { + tracer.startActiveSpan("ui.expense_category_creation_failed", (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "category_creation_failed", + "error.message": error.message, + }); + span.end(); + }); + console.error("Failed to create category:", error); + toast.error("Failed to create category"); + }, + }); + + const createTransactionMutation = useCreateTransaction({ + onSuccess: () => { + tracer.startActiveSpan("ui.expense_transaction_created", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "transaction_created", + }); + span.end(); + }); + setTransactionForm({ + amount: "", + occurredAt: new Date().toISOString().split("T")[0], + description: "", + categoryId: "", + }); + setShowExpenseForm(false); + toast.success("Expense added successfully"); + }, + onError: (error) => { + tracer.startActiveSpan( + "ui.expense_transaction_creation_failed", + (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "transaction_creation_failed", + "error.message": error.message, + }); + span.end(); + } + ); + console.error("Failed to create transaction:", error); + toast.error("Failed to add expense"); + }, + }); + + const deleteTransactionMutation = useDeleteTransaction({ + onSuccess: () => { + tracer.startActiveSpan("ui.expense_transaction_deleted", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "transaction_deleted", + }); + span.end(); + }); + toast.success("Transaction deleted"); + }, + onError: (error) => { + tracer.startActiveSpan( + "ui.expense_transaction_deletion_failed", + (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "transaction_deletion_failed", + "error.message": error.message, + }); + span.end(); + } + ); + console.error("Failed to delete transaction:", error); + toast.error("Failed to delete transaction"); + }, + }); + + const importExpensesMutation = useImportExpenses({ + onSuccess: (result) => { + tracer.startActiveSpan("ui.expenses_imported", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "expenses_imported", + imported_count: result.imported, + skipped_count: result.skipped, + categories_created: result.categoriesCreated, + }); + span.end(); + }); + setShowImportForm(false); + setImportForm({ + dateColumn: "Date", + amountColumn: "Amount", + categoryColumn: "Category", + merchantColumn: "", + descriptionColumn: "", + }); + if (fileRef.current) fileRef.current.value = ""; + toast.success(result.message); + }, + onError: (error) => { + tracer.startActiveSpan("ui.expenses_import_failed", (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "expenses_import_failed", + "error.message": error.message, + }); + span.end(); + }); + console.error("Failed to import expenses:", error); + toast.error("Failed to import expenses"); + }, + }); + + const setupMutation = useSetupDefaults({ + onSuccess: (result) => { + tracer.startActiveSpan("ui.expense_setup_completed", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "setup_completed", + categories_created: result.categoriesCreated, + was_skipped: result.skipped, + }); + span.end(); + }); + toast.success("Default expense categories created"); + }, + onError: (error) => { + tracer.startActiveSpan("ui.expense_setup_failed", (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "setup_failed", + "error.message": error.message, + }); + span.end(); + }); + console.error("Failed to setup defaults:", error); + toast.error("Failed to setup defaults"); + }, + }); + + // Form state + const [transactionForm, setTransactionForm] = React.useState({ + amount: "", + occurredAt: new Date().toISOString().split("T")[0], + description: "", + categoryId: "", + }); + + const [categoryForm, setCategoryForm] = React.useState({ + name: "", + color: "#ef4444", + }); + + const [importForm, setImportForm] = React.useState({ + dateColumn: "Date", + amountColumn: "Amount", + categoryColumn: "Category", + merchantColumn: "Merchant Name", + descriptionColumn: "Description", + }); + + // Track component mounting for telemetry + React.useEffect(() => { + tracer.startActiveSpan("ui.expense_manager_mounted", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "mounted", + "categories.count": categories.length, + "transactions.count": transactions.length, + }); + span.end(); + }); + }, [categories.length, transactions.length]); + + const handleCreateCategory = (e: React.FormEvent) => { + e.preventDefault(); + createCategoryMutation.mutate({ + name: categoryForm.name, + kind: CategoryKind.EXPENSE, + color: categoryForm.color, + }); + }; + + const handleCreateTransaction = (e: React.FormEvent) => { + e.preventDefault(); + createTransactionMutation.mutate({ + type: TransactionType.EXPENSE, + amount: parseFloat(transactionForm.amount), + occurredAt: new Date(transactionForm.occurredAt).toISOString(), + description: transactionForm.description || undefined, + categoryId: transactionForm.categoryId || undefined, + }); + }; + + const handleImportCSV = (e: React.FormEvent) => { + e.preventDefault(); + + const file = fileRef.current?.files?.[0]; + if (!file) { + toast.error("Please select a CSV file"); + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const csvData = event.target?.result as string; + if (!csvData) { + toast.error("Failed to read CSV file"); + return; + } + + importExpensesMutation.mutate({ + csvData, + dateColumn: importForm.dateColumn, + amountColumn: importForm.amountColumn, + categoryColumn: importForm.categoryColumn, + merchantColumn: importForm.merchantColumn || undefined, + descriptionColumn: importForm.descriptionColumn || undefined, + }); + }; + + reader.readAsText(file); + }; + + const handleSetupDefaults = () => { + setupMutation.mutate(); + }; + + const isLoading = + categoriesLoading || + transactionsLoading || + createCategoryMutation.isPending || + createTransactionMutation.isPending || + importExpensesMutation.isPending || + setupMutation.isPending; + + return ( +
+ {/* Header */} + + +
+

Expense Management

+

+ Manage your expense categories and transactions. You can also + import a CSV file of your expenses. +

+
+
+ {expenseCategories.length === 0 && ( + + )} + + + +
+
+
+ + {/* Category Form */} + {showCategoryForm && ( + + + Add New Expense Category + + +
+
+ + + setCategoryForm((prev) => ({ + ...prev, + name: e.target.value, + })) + } + required + /> +
+
+ + + setCategoryForm((prev) => ({ + ...prev, + color: e.target.value, + })) + } + className="w-12 h-10 border" + /> +
+ + +
+
+
+ )} + + {/* Expense Transaction Form */} + {showExpenseForm && ( + + + + Add New Expense Transaction + + + +
+
+ + + setTransactionForm((prev) => ({ + ...prev, + amount: e.target.value, + })) + } + required + /> +
+ +
+ + + setTransactionForm((prev) => ({ + ...prev, + occurredAt: e.target.value, + })) + } + required + /> +
+ +
+ + +
+ +
+ +