Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions sample_expenses.csv
Original file line number Diff line number Diff line change
@@ -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,,
247 changes: 247 additions & 0 deletions src/app/api/expenses/import/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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)];
}
10 changes: 10 additions & 0 deletions src/app/expenses/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import ExpenseManager from "@/components/ExpenseManager";
import React from "react";

function Expenses() {
return <div className="uppercase">
<ExpenseManager />
</div>;
}

export default Expenses;
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default function RootLayout({
</SignedIn>
</div>
</div>
<div className="p-6">{children}</div>
<div className="flex-1 p-6 overflow-auto">{children}</div>
</div>
</div>
<Toaster />
Expand Down
4 changes: 3 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CurrentFinances from "@/components/dashboard/CurrentFinaces";
import { Button } from "@/components/ui/button";
import {
Card,
Expand All @@ -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";
Expand Down Expand Up @@ -160,7 +162,7 @@ export default async function Home() {
availableBalance >= 0 ? "text-white" : "text-red-500"
}`}
>
£{availableBalance.toLocaleString()}
£{<CurrentFinances />}
</p>
</div>
<PoundSterling
Expand Down
Loading
Loading