From 0624352bbe40854fe5f857e3fb9031865c016a53 Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Tue, 31 Mar 2026 12:16:47 -0500 Subject: [PATCH 01/19] Upgraded the UI for transactions table - combined the features from user data input and visualization into one --- app/client/Dockerfile.base | 2 +- app/client/src/api/DashboardAPI.ts | 86 +++ app/client/src/api/Transaction.ts | 4 +- app/client/src/components/AccountForm.tsx | 11 +- .../src/components/TransactionTable.tsx | 700 ++++++++++++++++-- app/client/src/pages/DashboardPage.tsx | 8 +- app/client/src/types/Transaction.ts | 3 +- app/server/services/ts/user/src/index.ts | 230 +++++- .../services/ts/user/src/logic/goals.ts | 16 +- .../ts/user/src/logic/transactions.ts | 49 +- .../ts/user/src/queries/transactions.ts | 187 ++++- .../services/ts/user/src/routes/account.ts | 4 +- 12 files changed, 1181 insertions(+), 119 deletions(-) diff --git a/app/client/Dockerfile.base b/app/client/Dockerfile.base index a5d1461..92b7333 100644 --- a/app/client/Dockerfile.base +++ b/app/client/Dockerfile.base @@ -2,7 +2,7 @@ FROM oven/bun:1.3.9-slim WORKDIR /app -COPY package.json bun.lockb . +COPY package.json bun.lockb ./ RUN --mount=type=cache,target=/root/.bun/install/cache \ bun install diff --git a/app/client/src/api/DashboardAPI.ts b/app/client/src/api/DashboardAPI.ts index 076d83e..fe5e48a 100644 --- a/app/client/src/api/DashboardAPI.ts +++ b/app/client/src/api/DashboardAPI.ts @@ -4,6 +4,7 @@ import type { Transaction } from "@/types/Transaction"; import { instance } from "./config"; import type { BudgetWithExpenditure } from "@/types/BudgetWithExpenditure"; import type { SnapshotData } from "@/types/AggregatedSnapshot"; +import type { MinimizedAccount } from "@/types/AccountType"; async function getTransactions(): Promise { try { @@ -28,9 +29,11 @@ async function getTransactions(): Promise { sender: response.data[i]["sender"], recipient: response.data[i]["recipient"], description: response.data[i]["description"], + account_name: response.data[i]["account_name"], }); } + // console.log("Fetched transactions:", output); return output; } catch (error) { console.error("Error fetching transactions data:", error); @@ -38,6 +41,89 @@ async function getTransactions(): Promise { } } +//deletes a single transaciton based on id - returns true if successful, false otherwise +export async function deleteTransaction( + transactionId: number, +): Promise { + try { + const response = await instance.delete( + `/table/transactions?tid=${transactionId}`, + ); + if (response.status !== 200) { + console.error("Failed to delete transaction", response.status); + return false; + } + return true; + } catch (error) { + console.error("Error deleting transaction:", error); + return false; + } +} + +//updates a single transaciton based on id - returns true if successful, false otherwise +export async function updateTransaction( + transaction: Transaction, +): Promise { + try { + const response = await instance.put( + `/table/transactions?tid=${transaction.id}`, + transaction, + ); + if (response.status !== 200) { + console.error("Failed to update transaction", response.status); + return false; + } + return true; + } catch (error) { + console.error("Error updating transaction:", error); + return false; + } +} + +//creates a single transaciton - returns the created transaction with id if successful, throws error otherwise +export async function createTransaction( + transaction: Transaction, +): Promise { + try { + const response = await instance.post( + `/table/transactions?fid=${transaction.financialAccount_id}`, + transaction, + ); + if (response.status !== 200) { + throw new Error(`Failed to create transaction: ${response.statusText}`); + } + return response.data; + } catch (error) { + console.error("Error creating transaction:", error); + throw error; + } +} + +//gets a map of account ids to account names for a user id +export async function getAccountIdsForUser(): Promise< + MinimizedAccount[] | null +> { + try { + const response = await instance.get(`/table/transactions/accounts`); + if (response.status !== 200) { + throw new Error( + `Failed to fetch account IDs for user: ${response.statusText}`, + ); + } + if (!response.data) { + return null; + } + const output: MinimizedAccount[] = []; + for (const account of response.data) { + output.push({ id: account.id, name: account.name }); + } + return output; + } catch (error) { + console.error("Error fetching account IDs for user:", error); + throw error; + } +} + export async function getSnapshotData(): Promise { try { const response = await instance.get("/table/snapshot"); diff --git a/app/client/src/api/Transaction.ts b/app/client/src/api/Transaction.ts index 3153898..b6c0b59 100644 --- a/app/client/src/api/Transaction.ts +++ b/app/client/src/api/Transaction.ts @@ -5,7 +5,7 @@ import type { TransactionDraft } from "@/utils/ConvertTransaction"; const requestUrl = "http://localhost:3000/api/transactions"; -//Sends a GET request to get the list of user transactions for the account +//Sends a GET request to get the list of user transactions for the account - no need for this export async function getTransactions( session: AuthSession, financialAccount_id: number, @@ -32,7 +32,7 @@ export async function getTransactions( } } -//Can send multiple transactions in a push request +//Can send multiple transactions in a push request - no need for this export async function postTranscations( session: AuthSession, trans: Transaction, diff --git a/app/client/src/components/AccountForm.tsx b/app/client/src/components/AccountForm.tsx index 7ee8513..441225c 100644 --- a/app/client/src/components/AccountForm.tsx +++ b/app/client/src/components/AccountForm.tsx @@ -37,13 +37,13 @@ export default function PopupForm({ const accountBalance = Number(balance); if ( - (accountType === "SAVING" || accountType === "CREDIT_CARD") && + (accountType === "SAVINGS" || accountType === "CREDIT_CARD") && formInput.subType ) { subtype = formInput.subType; } - if (accountType === accountCategory.SAVING) { + if (accountType === accountCategory.SAVINGS) { interest = formInput.interest; } @@ -202,9 +202,10 @@ export default function PopupForm({ id="type" name="type" className="formSelect" - onChange={(event) => - {setAccountType(event.target.value as typeofAccount); setFormInput({ ...formInput, ["subType"]: "" })} - } + onChange={(event) => { + setAccountType(event.target.value as typeofAccount); + setFormInput({ ...formInput, ["subType"]: "" }); + }} > {accountCat.map((category) => ( diff --git a/app/client/src/components/TransactionTable.tsx b/app/client/src/components/TransactionTable.tsx index 3070169..f2ea96d 100644 --- a/app/client/src/components/TransactionTable.tsx +++ b/app/client/src/components/TransactionTable.tsx @@ -1,115 +1,655 @@ -import { getTransactions } from "@/api/DashboardAPI"; -import type { Transaction } from "@/types/Transaction"; -import { useEffect, useState } from "react"; -import { AiOutlineTransaction } from "react-icons/ai"; +import { useState, useEffect, useRef, useMemo } from "react"; +import { getAccountIdsForUser, getTransactions } from "../api/DashboardAPI"; +import { + deleteTransaction, + updateTransaction, + createTransaction, +} from "../api/DashboardAPI.ts"; +import type { Transaction } from "../types/Transaction"; +import { + AiOutlineTransaction, + AiOutlineSearch, + AiOutlinePlus, + AiOutlineClose, +} from "react-icons/ai"; +import { FiEdit2 } from "react-icons/fi"; import NoItemState from "./NoItemState"; +import type { MinimizedAccount } from "@/types/AccountType.ts"; -function TransactionTable() { - const [transactions, setTransactions] = useState(null); +interface TransactionTableProps { + initialLimit?: number; + loadMoreIncrement?: number; +} + +interface EditableTransaction extends Transaction { + isEditing?: boolean; + isExpanded?: boolean; +} +//the initial limit is how many transactions we load at first, the loadMoreIncrement is how many more we load each time we click load more +function TransactionTable({ + initialLimit = 100, + loadMoreIncrement = 50, +}: TransactionTableProps) { + const [allTransactions, setAllTransactions] = useState( + [], + ); + const [displayLimit, setDisplayLimit] = useState(initialLimit); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [showAddForm, setShowAddForm] = useState(false); + const [editingValues, setEditingValues] = useState<{ + [key: number]: Partial; + }>({}); + const tableContainerRef = useRef(null); + const [userAccounts, setUserAccounts] = useState([]); //will only be using the account id and account name in this table + const [selectedAccountId, setSelectedAccountId] = useState(""); + + //fetches all transactions on component load useEffect(() => { const fetchTransactions = async () => { try { + setIsLoading(true); const txs = await getTransactions(); - //console.log("Fetched transactions:", txs); - setTransactions(txs); + setAllTransactions( + (txs || []).map((tx) => ({ ...tx, isExpanded: false })), + ); } catch (error) { console.error("Error fetching transactions:", error); + } finally { + setIsLoading(false); } }; fetchTransactions(); }, []); - const noTransactionFound = ( - } - /> - ); + useEffect(() => { + const fetchAccounts = async () => { + try { + const accounts = await getAccountIdsForUser(); + setUserAccounts(accounts as MinimizedAccount[]); + if (accounts && accounts.length > 0) { + setSelectedAccountId(accounts[0].id); //default to first account + } + } catch (error) { + console.error("Error fetching accounts:", error); + } + }; + fetchAccounts(); + }, []); + + // client side search - this just looks for the search term in all columns of each row. + const filteredTransactions = useMemo(() => { + if (!searchTerm.trim()) return allTransactions; + + const term = searchTerm.toLowerCase(); + return allTransactions.filter( + (tx) => + tx.description?.toLowerCase().includes(term) || + tx.category?.toLowerCase().includes(term) || + tx.sender?.toLowerCase().includes(term) || + tx.recipient?.toLowerCase().includes(term) || + tx.account_name?.toLowerCase().includes(term) || + new Date(tx.date).toLocaleDateString().includes(term) || + tx.amount.toString().includes(term), + ); + }, [allTransactions, searchTerm]); + + const displayedTransactions = filteredTransactions.slice(0, displayLimit); + const hasMore = displayLimit < filteredTransactions.length; + + // format helpers + const formatAmount = (amount: number): string => { + return amount < 0 + ? `-$${Math.abs(amount).toFixed(2)}` + : `$${amount.toFixed(2)}`; + }; + + const formatCategoryLabel = (category: string): string => { + return category + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + }; + + //expand/collapse row + const toggleExpand = (transactionId: number) => { + setAllTransactions((prev) => + prev.map((tx) => + tx.id === transactionId ? { ...tx, isExpanded: !tx.isExpanded } : tx, + ), + ); + + //initialize editing values when expanding + const tx = allTransactions.find((t) => t.id === transactionId); + if (tx && !editingValues[transactionId]) { + setEditingValues((prev) => ({ + ...prev, + [transactionId]: { + date: tx.date, + description: tx.description || "", + category: tx.category, + amount: tx.amount, + account_name: tx.account_name, + sender: tx.sender || "", + recipient: tx.recipient || "", + }, + })); + } + }; + + //handle field changes in expanded edit form - this is exactly like goals + const handleFieldChange = ( + transactionId: number, + field: string, + value: string | number, + ) => { + setEditingValues((prev) => ({ + ...prev, + [transactionId]: { + ...prev[transactionId], + [field]: value, + }, + })); + }; + + // save edited transaction + const handleSaveEdit = async (transactionId: number) => { + const updates = editingValues[transactionId]; + if (!updates) return; + + const existingTx = allTransactions.find((tx) => tx.id === transactionId); + if (!existingTx) return; + + const payload = { + ...existingTx, + ...updates, + id: transactionId, + } as Transaction; + + try { + const success = await updateTransaction(payload); + if (!success) throw new Error("Update failed"); + + setAllTransactions((prev) => + prev.map((tx) => + tx.id === transactionId + ? { ...tx, ...updates, isExpanded: false } + : tx, + ), + ); + setEditingValues((prev) => { + const newState = { ...prev }; + delete newState[transactionId]; + return newState; + }); + } catch (error) { + console.error("Failed to update transaction:", error); + alert("Failed to update transaction"); + } + }; + + //delete transaction + const handleDelete = async (transactionId: number) => { + if (!confirm("Are you sure you want to delete this transaction?")) return; + + try { + await deleteTransaction(transactionId); + setAllTransactions((prev) => + prev.filter((tx) => tx.id !== transactionId), + ); + } catch (error) { + console.error("Failed to delete transaction:", error); + alert("Failed to delete transaction"); + } + }; + + //add new transaction + const [newTransaction, setNewTransaction] = useState>({ + date: new Date().toISOString().split("T")[0], + description: "", + category: "", + amount: 0, + sender: "", + recipient: "", + }); - const transactionTable = - transactions && transactions.length > 0 ? ( + const handleAddTransaction = async () => { + if ( + !newTransaction.date || + !newTransaction.category || + newTransaction.amount === undefined + ) { + alert("Please fill in required fields (date, category, amount)"); + return; + } + + if (!selectedAccountId) { + alert("Please select an account"); + return; + } + + try { + const created = await createTransaction({ + ...newTransaction, + financialAccount_id: selectedAccountId as number, + } as Transaction); + setAllTransactions((prev) => [ + { ...created, isExpanded: false }, + ...prev, + ]); + setNewTransaction({ + date: new Date().toISOString().split("T")[0], + description: "", + category: "", + amount: 0, + sender: "", + recipient: "", + }); + setSelectedAccountId(userAccounts[0]?.id || ""); + setShowAddForm(false); + } catch (error) { + console.error("Failed to create transaction:", error); + alert("Failed to create transaction"); + } + }; + + if (isLoading) { + return ( +
+
Loading transactions...
+
+ ); + } + + if (allTransactions.length === 0) { + return ( + } + /> + ); + } + + return ( +
+ {/* search Bar */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-black/50 border border-green-500/20 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-green-500" + /> +
+ +
+ + {/* add Transaction Form */} + {showAddForm && ( +
+
+

New Transaction

+ +
+
+ + setNewTransaction({ ...newTransaction, date: e.target.value }) + } + className="bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white" + /> + + setNewTransaction({ + ...newTransaction, + description: e.target.value, + }) + } + className="bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white" + /> + + setNewTransaction({ + ...newTransaction, + category: e.target.value, + }) + } + className="bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white" + /> + + setNewTransaction({ + ...newTransaction, + amount: parseFloat(e.target.value), + }) + } + className="bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white" + /> + + {/* Account Selector */} + + + + setNewTransaction({ ...newTransaction, sender: e.target.value }) + } + className="bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white" + /> + + setNewTransaction({ + ...newTransaction, + recipient: e.target.value, + }) + } + className="bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white" + /> +
+
+ +
+
+ )} + + {/* transactions Table */}
- {/* Header */} -
+ {/* header */} +
Date
Description
Category
Amount
-
From
-
To
+
Account
+
From/To
+
- {/* Body */} + {/* body */}
- {transactions.map((tx, index) => ( -
- {/* Date */} -
- {new Date(tx.date).toLocaleDateString()} -
+ {displayedTransactions.map((tx, index) => { + const isExpanded = tx.isExpanded; + const editValues = editingValues[tx.id] || {}; - {/* Description */} -
- {tx.description || "N/A"} -
+ return ( +
+ {/* main row */} +
toggleExpand(tx.id)} + > +
+ {new Date(tx.date).toLocaleDateString()} +
+
+ {tx.description || "N/A"} +
+
+ {formatCategoryLabel(tx.category)} +
+
+ {formatAmount(tx.amount)} +
+
+ {tx.account_name || "N/A"} +
+
+ {tx.sender + ? `From: ${tx.sender}` + : tx.recipient + ? `To: ${tx.recipient}` + : "-"} +
+
+ +
+
- {/* Category */} -
- {formatCategoryLabel(tx.category)} + {/* expanded edit row */} + {isExpanded && ( +
+
+
+ + + handleFieldChange(tx.id, "date", e.target.value) + } + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+ + + handleFieldChange( + tx.id, + "description", + e.target.value, + ) + } + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+ + + handleFieldChange( + tx.id, + "category", + e.target.value, + ) + } + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+ + + handleFieldChange( + tx.id, + "amount", + parseFloat(e.target.value), + ) + } + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+ + + handleFieldChange( + tx.id, + "account_name", + e.target.value, + ) + } + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+ + + handleFieldChange(tx.id, "sender", e.target.value) + } + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+ + + handleFieldChange( + tx.id, + "recipient", + e.target.value, + ) + } + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+
+ + +
+
+ )}
- - {/* Amount */} -
- {tx.amount < 0 - ? `-$${Math.abs(tx.amount).toFixed(2)}` - : `$${tx.amount.toFixed(2)}`} -
- - {/* From */} -
{tx.sender}
- - {/* To */} -
{tx.recipient}
-
- ))} + ); + })}
- ) : null; - return transactions && transactions.length === 0 - ? noTransactionFound - : transactionTable; -} -//split on underscore, capitalize first letter of each word, then join with space -function formatCategoryLabel(category: string): string { - return category - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); + {/* load more button */} + {hasMore && ( +
+ +
+ )} +
+ ); } export default TransactionTable; diff --git a/app/client/src/pages/DashboardPage.tsx b/app/client/src/pages/DashboardPage.tsx index 37237ac..e4d9458 100644 --- a/app/client/src/pages/DashboardPage.tsx +++ b/app/client/src/pages/DashboardPage.tsx @@ -17,7 +17,7 @@ import BudgetExpenditureChart from "@/components/BudgetExpenditureChart"; import SnapshotSection from "@/components/SnapshotSection"; import AccountList from "@/components/AccountList"; -import TransactionList from "@/components/TransactionList"; +// import TransactionList from "@/components/TransactionList"; Chart.register( PointElement, @@ -82,13 +82,13 @@ function DashboardPage({ session }: DashboardPageProps) {

Recent Transactions

- +

Account List

{session && } -

Transaction List

- {session && } + {/*

Transaction List

+ {session && } */} ); } diff --git a/app/client/src/types/Transaction.ts b/app/client/src/types/Transaction.ts index 1620df8..528e9fd 100644 --- a/app/client/src/types/Transaction.ts +++ b/app/client/src/types/Transaction.ts @@ -3,8 +3,9 @@ export type Transaction = { financialAccount_id: number; amount: number; category: string; - date: Date; // ISO format date string + date: string; // ISO format date string - used to be Date sender: string; recipient: string; description?: string; + account_name?: string; }; diff --git a/app/server/services/ts/user/src/index.ts b/app/server/services/ts/user/src/index.ts index 451824c..9a70c8c 100644 --- a/app/server/services/ts/user/src/index.ts +++ b/app/server/services/ts/user/src/index.ts @@ -4,7 +4,10 @@ import { authenticateJWT } from "./utils/auth.ts"; import { generateDateRange } from "./utils/dates.ts"; import { validatePeriod } from "./validation.ts"; import { getExpensesChartData } from "./logic/expenses.ts"; -import { getTransactionsData } from "./logic/transactions.ts"; +import { + getAccountIDsForUser, + getTransactionsData, +} from "./logic/transactions.ts"; import { getSnapshotData } from "./logic/snapshot.ts"; import { onExit } from "@/hooks"; import { buildCorsConfig } from "@/expressUtils"; @@ -16,6 +19,7 @@ import { getUserGoals, getUserGoalById, } from "./logic/goals.ts"; +import { getUserProfileId } from "./queries/goals.ts"; import type { GoalType, UpdateGoalInput } from "./types/Goals.ts"; import { getGoalCountByProfileId } from "./queries/goals.ts"; import { accountsRouter } from "./routes/account.js"; @@ -23,6 +27,12 @@ import { profilesRouter } from "./routes/profile.js"; import { transactionsRouter } from "./routes/transaction.js"; import { debtRouter } from "./routes/debt.ts"; import { savingRouter } from "./routes/saving.ts"; +import { + createTransaction, + deleteTransactionQuery, + updateTransactionQuery, +} from "./queries/transactions.ts"; +import type { Transaction } from "./types/Transaction.ts"; const app = express(); app.use(buildCorsConfig()); @@ -47,18 +57,18 @@ const server = app.listen(PORT, () => { }); process.on("SIGTERM", () => cleanup); - async function cleanup() { - try{ - server.close() - await pool.end() - } catch(error){ - console.log(error) +async function cleanup() { + try { + server.close(); + await pool.end(); + } catch (error) { + console.log(error); } } -app.get("/charts/expenses", async (req, res) => { -process.on("SIGTERM", () => server.close()); -}) +// app.get("/charts/expenses", async (req, res) => { //why does this exist +// process.on("SIGTERM", () => server.close()); +// }) app.get( "/charts/expenses", @@ -109,6 +119,202 @@ app.get( }, ); +app.get( + "/table/transactions/accounts", + async (req: express.Request, res: express.Response) => { + try { + const userId = authenticateJWT(req); + const accounts = await getAccountIDsForUser(pool, userId); //get account IDs and names + res.json(accounts); + } catch (error) { + console.error("Error fetching transactions:", error); + if (error instanceof Error && error.message.includes("Authorization")) { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ error: "Failed to fetch transactions" }); + } + } + }, +); + +//create a new transaction through the table +app.post( + "table/transactions", + async (req: express.Request, res: express.Response) => { + try { + const userId = authenticateJWT(req); + if (!userId) { + return res.status(401).json({ error: "User not authenticated" }); + } + //extract financial account id as query param + const financialAccountId = req.query.fid as string; + if (!financialAccountId) { + return res.status(400).json({ error: "Missing financial account ID" }); + } + + //validate request body + if (!req.body) { + return res.status(400).json({ error: "Invalid request body" }); + } + + let description = null; + let category = null; + let amount = null; + let date = null; + let sender = null; + let recipient = null; + + if (req.body.description) description = req.body.description; + if (req.body.category) category = req.body.category; + if (req.body.amount) amount = req.body.amount; + if (req.body.date) date = req.body.date; + if (req.body.sender) sender = req.body.sender; + if (req.body.recipient) recipient = req.body.recipient; + + if ( + !description || + !category || + !amount || + !date || + !sender || + !recipient + ) { + return res.status(400).json({ + error: "Missing required fields for creating a transaction", + }); + } + + const transactionData: Transaction = { + id: 0, //this is ignored in creation because it is auto generated + financialAccount_id: financialAccountId as unknown as number, + amount: amount, + category: category, + description: description, + sender: sender, + recipient: recipient, + date: date, + constructor: { + name: "RowDataPacket", + }, + }; + + const transaction = await createTransaction( + pool, + financialAccountId as unknown as number, + transactionData, + ); + + res.json(transaction); + } catch (error) { + console.error("Error creating transaction:", error); + if (error instanceof Error && error.message.includes("Authorization")) { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ error: "Failed to create transaction" }); + } + } + }, +); + +//delete a transaction through the table +app.delete( + "table/transactions", + async (req: express.Request, res: express.Response) => { + try { + const userId = authenticateJWT(req); + if (!userId) { + return res.status(401).json({ error: "User not authenticated" }); + } + + //extract transaction id as query param + const transactionId = req.query.tid as unknown as number; + if (!transactionId) { + return res.status(400).json({ error: "Missing transaction ID" }); + } + + //get user profile id + const userProfileId = await getUserProfileId(pool, userId); + if (!userProfileId) { + return res.status(404).json({ error: "User profile not found" }); + } + + //delete the transaciton + const deleted = await deleteTransactionQuery( + pool, + transactionId, + userProfileId, + ); + if (!deleted) { + return res.status(404).json({ error: "Transaction not found" }); + } + } catch (error) { + console.error("Error deleting transaction:", error); + if (error instanceof Error && error.message.includes("Authorization")) { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ error: "Failed to delete transaction" }); + } + } + }, +); + +//update a transaction through the table +app.patch( + "table/transactions", + async (req: express.Request, res: express.Response) => { + try { + const userId = authenticateJWT(req); + if (!userId) { + return res.status(401).json({ error: "User not authenticated" }); + } + + //extract transaction id as query param + const transactionId = req.query.tid as unknown as number; + if (!transactionId) { + return res.status(400).json({ error: "Missing transaction ID" }); + } + + //validate request body + if (!req.body) { + return res.status(400).json({ error: "Invalid request body" }); + } + + //need profile id to verify ownership of the transaction via account + const userProfileId = await getUserProfileId(pool, userId); + if (!userProfileId) { + return res.status(404).json({ error: "User profile not found" }); + } + + const updates: Partial = {}; + if (req.body.description !== undefined) + updates.description = req.body.description; + if (req.body.category !== undefined) updates.category = req.body.category; + if (req.body.amount !== undefined) updates.amount = req.body.amount; + if (req.body.date !== undefined) updates.date = req.body.date; + if (req.body.sender !== undefined) updates.sender = req.body.sender; + if (req.body.recipient !== undefined) + updates.recipient = req.body.recipient; + + const transaction = await updateTransactionQuery( + pool, + transactionId, + userProfileId, + updates, + ); + if (!transaction) { + return res.status(404).json({ error: "Transaction not found" }); + } + } catch (error) { + console.error("Error updating transaction:", error); + if (error instanceof Error && error.message.includes("Authorization")) { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ error: "Failed to update transaction" }); + } + } + }, +); + app.get( "/table/snapshot", async (req: express.Request, res: express.Response) => { @@ -219,7 +425,7 @@ app.patch("/goals", async (req: express.Request, res: express.Response) => { // const goalId = parseInt(req.query.gid); let goalId = null; - if (req.query.gid) goalId = parseInt(req.query.gid); + if (req.query.gid) goalId = req.query.gid as unknown as number; //parseInt() if (!goalId) { return res.status(404).json({ error: "Missing goal ID" }); @@ -285,7 +491,7 @@ app.delete("/goals", async (req: express.Request, res: express.Response) => { let goalId = null; - if (req.query.gid) goalId = parseInt(req.query.gid); + if (req.query.gid) goalId = req.query.gid as unknown as number; //parseInt() if (!goalId) { return res.status(404).json({ error: "Missing goal ID" }); diff --git a/app/server/services/ts/user/src/logic/goals.ts b/app/server/services/ts/user/src/logic/goals.ts index 5fd2f97..f37ad1f 100644 --- a/app/server/services/ts/user/src/logic/goals.ts +++ b/app/server/services/ts/user/src/logic/goals.ts @@ -99,14 +99,14 @@ export async function enrichGoalWithProgress( 100, ); - console.log( - "enriching goal with progress, cur amount:", - current_amount, - "progress percentage:", - progress_percentage, - " because target is:", - goal.target, - ); + // console.log( + // "enriching goal with progress, cur amount:", + // current_amount, + // "progress percentage:", + // progress_percentage, + // " because target is:", + // goal.target, + // ); return { ...goal, diff --git a/app/server/services/ts/user/src/logic/transactions.ts b/app/server/services/ts/user/src/logic/transactions.ts index bb1e4c5..9acc5a8 100644 --- a/app/server/services/ts/user/src/logic/transactions.ts +++ b/app/server/services/ts/user/src/logic/transactions.ts @@ -1,14 +1,37 @@ -import type { Pool } from "mysql2/promise"; -import { getAllTransactionsQuery } from "../queries/transactions.ts"; +import type { Pool, RowDataPacket } from "mysql2/promise"; +import { + getAllTransactionsQuery, + getTransactionAccountNames, +} from "../queries/transactions.ts"; import type { Transaction } from "../types/Transaction.ts"; +//returns a key value map of account id to account name +export async function getAccountIDsForUser( + pool: Pool, + userId: string, +): Promise> { + const query = ` + SELECT fa.id, fa.name + FROM finus.financialAccount fa + JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id + JOIN finus.finusAccount_profile uap ON pfa.profile_id = uap.profile_id + WHERE uap.account_id = ? + `; + const [rows] = await pool.query(query, [userId]); + const output = new Map(); + for (const row of rows) { + output.set(row.id, row.name); + } + return output; +} + export async function getTransactionsData( pool: Pool, userId: string, ): Promise { const transactions = await getAllTransactionsQuery(pool, userId); - // Enrich transactions with sender/recipient names if missing + //enrich transactions with sender/recipient names if missing if (transactions.length > 0) { const first_name = transactions[0].first_name || ""; const last_name = transactions[0].last_name || ""; @@ -24,5 +47,25 @@ export async function getTransactionsData( }); } + //get a list of all the financial account ids in the transactions and fetch the names of those accounts + const financialAccountIds = Array.from( + new Set(transactions.map((t) => t.financialAccount_id)), + ); + const accountIdToNameMap: Record = {}; + if (financialAccountIds.length > 0) { + const accountNames = await getTransactionAccountNames( + pool, + financialAccountIds, + ); + console.log("account names fetched for transactions:", accountNames); + Object.assign(accountIdToNameMap, accountNames); + } + + //enrich transactions with account names + transactions.forEach((transaction) => { + transaction.account_name = + accountIdToNameMap[transaction.financialAccount_id] || "Unknown Account"; + }); + return transactions; } diff --git a/app/server/services/ts/user/src/queries/transactions.ts b/app/server/services/ts/user/src/queries/transactions.ts index 88e8243..ce6cb6e 100644 --- a/app/server/services/ts/user/src/queries/transactions.ts +++ b/app/server/services/ts/user/src/queries/transactions.ts @@ -1,5 +1,6 @@ -import type { Pool } from "mysql2/promise"; +import type { Pool, ResultSetHeader, RowDataPacket } from "mysql2/promise"; import type { Transaction } from "../types/Transaction.ts"; +interface TransactionRow extends RowDataPacket, Transaction {} export async function getAllTransactionsQuery( pool: Pool, @@ -21,6 +22,23 @@ export async function getAllTransactionsQuery( return rows; } +export async function getTransactionAccountNames( + pool: Pool, + financialAccountIds: number[], +): Promise> { + if (financialAccountIds.length === 0) { + return {}; + } + const query = `SELECT id, name FROM finus.financialAccount WHERE id IN (${financialAccountIds.join(", ")});`; + + const [rows] = await pool.query(query); + const accountIdToNameMap: Record = {}; + for (const row of rows as RowDataPacket[]) { + accountIdToNameMap[row.id] = row.name; + } + return accountIdToNameMap; +} + export async function findTransactionsBy( db: Pool, financialAccountId: string, @@ -127,3 +145,170 @@ export async function getProfileCategoryExpensesQuery( const [rows] = await pool.query(query, [profileId, category]); return rows; } + +//creates a single transaction +export async function createTransactionQuery( + pool: Pool, + financialAccountId: number, + amount: number, + category: string, + date: Date, + sender: string, + recipient: string, + description: string, +): Promise { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [result] = await connection.query( + `INSERT INTO finus.transaction (financialAccount_id, amount, category, date, sender, recipient, description) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + financialAccountId, + amount, + category, + date, + sender, + recipient, + description, + ], + ); + + if ((result as ResultSetHeader).affectedRows === 0) { + await connection.rollback(); + return false; + } + + await connection.commit(); + return true; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} + +export async function deleteTransactionQuery( + pool: Pool, + transactionId: number, + profileId: number, +): Promise { + const query = ` + DELETE t + FROM finus.transaction t + JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id + JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id + WHERE t.id = ? AND pfa.profile_id = ? + `; + + const [result] = await pool.query(query, [ + transactionId, + profileId, + ]); + return result.affectedRows > 0; +} + +export async function getTransactionById( + pool: Pool, + transactionId: number, + profileId: number, +): Promise { + const query = ` + SELECT t.* + FROM finus.transaction t + JOIN finus.financialAccount fa ON t.financialAccount_id = fa.id + JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id + WHERE t.id = ? AND pfa.profile_id = ? + `; + + const [rows] = await pool.query(query, [ + transactionId, + profileId, + ]); + return rows[0]; +} + +export async function updateTransactionQuery( + pool: Pool, + transactionId: number, + profileId: number, //for verification that the user owns this transaction via account + updates: Partial, +): Promise { + //verify the transaction belongs to the user's profile + const existing = await getTransactionById(pool, transactionId, profileId); + if (!existing) return undefined; + + //define allowed fields for transaction updates + const allowedFields = [ + "amount", + "category", + "description", + "sender", + "recipient", + "date", + "financialAccount_id", + ]; + + const setClauses: string[] = []; + const values: unknown[] = []; + + //build update query dynamically since some fields may not be provided + for (const field of allowedFields) { + if (updates[field as keyof Transaction] !== undefined) { + setClauses.push(`${field} = ?`); + values.push(updates[field as keyof Transaction]); + } + } + + //if no fields to update, return the existing transaction + if (setClauses.length === 0) { + return existing; + } + + values.push(transactionId); + + await pool.query( + `UPDATE finus.transaction SET ${setClauses.join(", ")} WHERE id = ?`, + values, + ); + + //fetch the updated transaction + const [updated] = await pool.query( + `SELECT * FROM finus.transaction WHERE id = ?`, + [transactionId], + ); + + return updated[0]; +} + +export async function createTransaction( + pool: Pool, + financialAccountId: number, + transactionData: Transaction, +): Promise { + const { amount, category, description, sender, recipient, date } = + transactionData; + + const [result] = await pool.query( + `INSERT INTO finus.transaction + (financialAccount_id, amount, category, description, sender, recipient, date) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + financialAccountId, + amount, + category, + description, + sender, + recipient, + date, + ], + ); + + const [newTransaction] = await pool.query( + `SELECT * FROM finus.transaction WHERE id = ?`, + [result.insertId], + ); + + return newTransaction[0]; +} diff --git a/app/server/services/ts/user/src/routes/account.ts b/app/server/services/ts/user/src/routes/account.ts index 3a2ed8e..05ba808 100644 --- a/app/server/services/ts/user/src/routes/account.ts +++ b/app/server/services/ts/user/src/routes/account.ts @@ -125,7 +125,7 @@ accountsRouter.get("/", async (req: Request, res: Response) => { [profileId], ); - console.log(rows); + // console.log(rows); return res.status(200).json(rows); } catch (err) { @@ -183,7 +183,7 @@ accountsRouter.put("/", async (req: Request, res: Response) => { [name, type, balance, value, last_updated, subtype ?? null, id], ); - console.log("Updated Account " + name); + // console.log("Updated Account " + name); return res.status(200).json({ message: "Account successfully updated", lastUpdated: last_updated, From 35aa33cb770782c7cb3d484cebaf1055b9110dc9 Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Tue, 31 Mar 2026 12:17:22 -0500 Subject: [PATCH 02/19] minor fix to account selection logic for creating transactions --- app/client/src/index.css | 2 +- app/client/src/types/AccountType.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/client/src/index.css b/app/client/src/index.css index 39600df..c4522fa 100644 --- a/app/client/src/index.css +++ b/app/client/src/index.css @@ -1,6 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; -@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap"); +/* @import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap"); */ @custom-variant dark (&:is(.dark *)); diff --git a/app/client/src/types/AccountType.ts b/app/client/src/types/AccountType.ts index 0fcbbed..4da91ac 100644 --- a/app/client/src/types/AccountType.ts +++ b/app/client/src/types/AccountType.ts @@ -8,3 +8,8 @@ export interface Account { subtype?: string; last_updated: Date; } + +export interface MinimizedAccount { + id: number; + name: string; +} From 882c72346807ac160c8718c2f427b4ed0da3a909 Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Tue, 31 Mar 2026 23:06:45 -0500 Subject: [PATCH 03/19] Transactions table FOC --- app/client/src/api/DashboardAPI.ts | 29 ++-- .../src/components/TransactionTable.tsx | 129 +++++++++--------- .../services/ts/api-gateway/src/index.ts | 1 + app/server/services/ts/user/src/index.ts | 30 ++-- .../ts/user/src/logic/transactions.ts | 1 - .../ts/user/src/queries/transactions.ts | 1 + 6 files changed, 108 insertions(+), 83 deletions(-) diff --git a/app/client/src/api/DashboardAPI.ts b/app/client/src/api/DashboardAPI.ts index fe5e48a..9e6fc52 100644 --- a/app/client/src/api/DashboardAPI.ts +++ b/app/client/src/api/DashboardAPI.ts @@ -20,12 +20,17 @@ async function getTransactions(): Promise { } const output: Transaction[] = []; for (let i = 0; i < response.data.length; i++) { + let formattedDate = response.data[i]["date"]; + if (formattedDate) { + formattedDate = formattedDate.split("T")[0]; //database stores transacitons as datetime so split to get date and disregard time + } + output.push({ id: response.data[i]["id"], financialAccount_id: response.data[i]["financialAccount_id"], amount: response.data[i]["amount"], category: response.data[i]["category"], - date: response.data[i]["date"], + date: formattedDate, sender: response.data[i]["sender"], recipient: response.data[i]["recipient"], description: response.data[i]["description"], @@ -65,7 +70,7 @@ export async function updateTransaction( transaction: Transaction, ): Promise { try { - const response = await instance.put( + const response = await instance.patch( `/table/transactions?tid=${transaction.id}`, transaction, ); @@ -105,18 +110,26 @@ export async function getAccountIdsForUser(): Promise< > { try { const response = await instance.get(`/table/transactions/accounts`); + if (response.status !== 200) { throw new Error( `Failed to fetch account IDs for user: ${response.statusText}`, ); } - if (!response.data) { + + if (!response.data || !Array.isArray(response.data)) { return null; } - const output: MinimizedAccount[] = []; - for (const account of response.data) { - output.push({ id: account.id, name: account.name }); - } + + //directly map the array + const output: MinimizedAccount[] = response.data.map( + (account: MinimizedAccount) => ({ + id: account.id, + name: account.name, + }), + ); + + console.log("Fetched account IDs:", output); return output; } catch (error) { console.error("Error fetching account IDs for user:", error); @@ -224,7 +237,7 @@ async function getSavingsContribChartData( ); } const response = await instance.get(`/charts/savings?period=${period}`); - console.log("received savings data", response); + // console.log("received savings data", response); if (response.status !== 200) { throw new Error( `Failed to fetch savings contribution chart data: ${response.statusText}`, diff --git a/app/client/src/components/TransactionTable.tsx b/app/client/src/components/TransactionTable.tsx index f2ea96d..011d6a1 100644 --- a/app/client/src/components/TransactionTable.tsx +++ b/app/client/src/components/TransactionTable.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from "react"; +import { useState, useEffect, useMemo } from "react"; import { getAccountIdsForUser, getTransactions } from "../api/DashboardAPI"; import { deleteTransaction, @@ -22,11 +22,9 @@ interface TransactionTableProps { } interface EditableTransaction extends Transaction { - isEditing?: boolean; isExpanded?: boolean; } -//the initial limit is how many transactions we load at first, the loadMoreIncrement is how many more we load each time we click load more function TransactionTable({ initialLimit = 100, loadMoreIncrement = 50, @@ -41,11 +39,10 @@ function TransactionTable({ const [editingValues, setEditingValues] = useState<{ [key: number]: Partial; }>({}); - const tableContainerRef = useRef(null); - const [userAccounts, setUserAccounts] = useState([]); //will only be using the account id and account name in this table + const [userAccounts, setUserAccounts] = useState([]); const [selectedAccountId, setSelectedAccountId] = useState(""); - //fetches all transactions on component load + // Fetch all transactions on component load useEffect(() => { const fetchTransactions = async () => { try { @@ -63,13 +60,15 @@ function TransactionTable({ fetchTransactions(); }, []); + // Fetch accounts for dropdown useEffect(() => { const fetchAccounts = async () => { try { const accounts = await getAccountIdsForUser(); - setUserAccounts(accounts as MinimizedAccount[]); if (accounts && accounts.length > 0) { - setSelectedAccountId(accounts[0].id); //default to first account + setUserAccounts(accounts); + console.log("Fetched accounts:", accounts); + setSelectedAccountId(accounts[0].id); } } catch (error) { console.error("Error fetching accounts:", error); @@ -78,7 +77,7 @@ function TransactionTable({ fetchAccounts(); }, []); - // client side search - this just looks for the search term in all columns of each row. + // Client-side search const filteredTransactions = useMemo(() => { if (!searchTerm.trim()) return allTransactions; @@ -98,7 +97,7 @@ function TransactionTable({ const displayedTransactions = filteredTransactions.slice(0, displayLimit); const hasMore = displayLimit < filteredTransactions.length; - // format helpers + // Format helpers const formatAmount = (amount: number): string => { return amount < 0 ? `-$${Math.abs(amount).toFixed(2)}` @@ -123,6 +122,7 @@ function TransactionTable({ //initialize editing values when expanding const tx = allTransactions.find((t) => t.id === transactionId); if (tx && !editingValues[transactionId]) { + console.log("transaciton date is", tx.date); setEditingValues((prev) => ({ ...prev, [transactionId]: { @@ -138,7 +138,7 @@ function TransactionTable({ } }; - //handle field changes in expanded edit form - this is exactly like goals + // Handle field changes in expanded edit form const handleFieldChange = ( transactionId: number, field: string, @@ -153,31 +153,26 @@ function TransactionTable({ })); }; - // save edited transaction + // Save edited transaction const handleSaveEdit = async (transactionId: number) => { const updates = editingValues[transactionId]; if (!updates) return; - const existingTx = allTransactions.find((tx) => tx.id === transactionId); - if (!existingTx) return; - - const payload = { - ...existingTx, - ...updates, - id: transactionId, - } as Transaction; - try { - const success = await updateTransaction(payload); + const success = await updateTransaction({ + id: transactionId, + ...updates, + } as Transaction); + if (!success) throw new Error("Update failed"); - setAllTransactions((prev) => - prev.map((tx) => - tx.id === transactionId - ? { ...tx, ...updates, isExpanded: false } - : tx, - ), + //fetch the updated transaction to get fresh data + const updatedTxs = await getTransactions(); + // console.log("Updated transactions after edit:", updatedTxs); + setAllTransactions( + (updatedTxs || []).map((tx) => ({ ...tx, isExpanded: false })), ); + setEditingValues((prev) => { const newState = { ...prev }; delete newState[transactionId]; @@ -189,7 +184,7 @@ function TransactionTable({ } }; - //delete transaction + // Delete transaction const handleDelete = async (transactionId: number) => { if (!confirm("Are you sure you want to delete this transaction?")) return; @@ -204,7 +199,7 @@ function TransactionTable({ } }; - //add new transaction + // Add new transaction state const [newTransaction, setNewTransaction] = useState>({ date: new Date().toISOString().split("T")[0], description: "", @@ -234,6 +229,7 @@ function TransactionTable({ ...newTransaction, financialAccount_id: selectedAccountId as number, } as Transaction); + setAllTransactions((prev) => [ { ...created, isExpanded: false }, ...prev, @@ -274,7 +270,7 @@ function TransactionTable({ return (
- {/* search Bar */} + {/* Search Bar */}
@@ -295,7 +291,7 @@ function TransactionTable({
- {/* add Transaction Form */} + {/* Add Transaction Form */} {showAddForm && (
@@ -359,9 +355,6 @@ function TransactionTable({ onChange={(e) => setSelectedAccountId(Number(e.target.value))} className="bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white" > - {userAccounts.map((account) => (
)} - {/* transactions Table */} -
- {/* header */} + {/* Transactions Table */} +
+ {/* Header */}
Date
Description
Category
Amount
Account
-
From/To
+
From
+
To
- {/* body */} + {/* Body */}
{displayedTransactions.map((tx, index) => { const isExpanded = tx.isExpanded; const editValues = editingValues[tx.id] || {}; + const currentAccountId = + editValues.financialAccount_id || + userAccounts.find((acc) => acc.name === tx.account_name)?.id || + ""; return (
- {/* main row */} + {/* Main Row */}
toggleExpand(tx.id)} > -
- {new Date(tx.date).toLocaleDateString()} -
+
{tx.date}
{tx.account_name || "N/A"}
-
- {tx.sender - ? `From: ${tx.sender}` - : tx.recipient - ? `To: ${tx.recipient}` - : "-"} +
+ {tx.sender || "-"} +
+
+ {tx.recipient || "-"}
- {/* expanded edit row */} + {/* Expanded Edit Row */} {isExpanded && (
@@ -551,26 +546,32 @@ function TransactionTable({ onClick={(e) => e.stopPropagation()} />
+ + {/* Account Selector for Edit Form */}
- handleFieldChange( tx.id, - "account_name", - e.target.value, + "financialAccount_id", + Number(e.target.value), ) } className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" onClick={(e) => e.stopPropagation()} - /> + > + {userAccounts.map((account) => ( + + ))} +
+
- {/* load more button */} + {/* Load More Button */} {hasMore && (
+
+
+ ); + } + return ( - <> -
-
- {userAccounts.map((account) => ( - + {/* Header with search and add button */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-black/50 border border-green-500/20 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-green-500" + /> +
+ +
+ + {/* Add Account Form */} + {showAddForm && ( +
+
+

New Account

+ +
+
+ + setNewAccount({ ...newAccount, name: e.target.value }) + } + className="bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white" + /> + + {newAccount.type === "savings" && ( + + )} + {newAccount.type === "credit_card" && ( + + )} + + setNewAccount({ + ...newAccount, + balance: parseFloat(e.target.value) || 0, + }) + } + className="bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white" /> - ))} +
+
+ +
+ )} -
- + {/* Accounts Table */} +
+ {/* Header */} +
+
Account Name
+
Type
+
Balance
+
Last Updated
+
-
- {seen ? ( - - ) : null} - + {/* Body */} +
+
+ {filteredAccounts.map((account, index) => { + const isExpanded = account.isExpanded; + const editValues = editingValues[account.id] || {}; + + return ( +
+ {/* Main Row */} +
toggleExpand(account.id)} + > +
+ {account.name} +
+
+ {account.type ? account.type.replace("_", " ") : ""} +
+
+ {formatCurrency(account.balance)} +
+
+ {formatDate(account.last_updated)} +
+
+ +
+
+ + {/* Expanded Edit Row */} + {isExpanded && ( +
+
+
+ + + handleFieldChange( + account.id, + "name", + e.target.value, + ) + } + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+ + +
+
+ + + handleFieldChange( + account.id, + "subtype", + e.target.value, + ) + } + placeholder="Optional" + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+ + + handleFieldChange( + account.id, + "balance", + parseFloat(e.target.value), + ) + } + className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" + onClick={(e) => e.stopPropagation()} + /> +
+
+
+ + +
+
+ )} +
+ ); + })} +
+
+
+
); } + +export default AccountList; diff --git a/app/client/src/types/AccountType.ts b/app/client/src/types/AccountType.ts index 4da91ac..341d9b1 100644 --- a/app/client/src/types/AccountType.ts +++ b/app/client/src/types/AccountType.ts @@ -6,7 +6,7 @@ export interface Account { balance: number; value: number; subtype?: string; - last_updated: Date; + last_updated: string; } export interface MinimizedAccount { diff --git a/app/server/services/ts/user/src/CheckUser.ts b/app/server/services/ts/user/src/CheckUser.ts index 3a92f34..e7d8bc7 100644 --- a/app/server/services/ts/user/src/CheckUser.ts +++ b/app/server/services/ts/user/src/CheckUser.ts @@ -1,39 +1,67 @@ import type { RowDataPacket, Connection } from "mysql2/promise"; //Checks the user is the owner of the account +// export async function checkUserId( +// db: Connection, +// userId: number, +// accountId: number, +// ): Promise { +// let result = false; +// try { +// //need the user account's profile id first. This should be just a single item returned unless stretch feature 7 is implemented +// // const [profileRows] = await db.query( +// // `SELECT profile_id FROM finusAccount_profile WHERE account_id = ?`, +// // [userId], +// // ); + +// const [profileRows] = await db.query( +// `SELECT uid from` + +// if (!profileRows || (profileRows as RowDataPacket[]).length === 0) { +// throw new Error("User profile not found"); +// } + +// const profileId = (profileRows as RowDataPacket[])[0].profile_id; + +// //Checks if the profile has an account with that id +// const [rows] = await db.query( +// `SELECT 1 FROM profile_financialAccount +// WHERE profile_id =? AND financialAccount_id =?`, +// [profileId, accountId], +// ); + +// console.log(rows.length); +// if (rows.length > 0) { +// result = true; +// } +// } catch (error) { +// console.error(error); +// } + +// return result; +// } + export async function checkUserId( db: Connection, userId: number, accountId: number, ): Promise { - let result = false; try { - //need the user account's profile id first. This should be just a single item returned unless stretch feature 7 is implemented - const [profileRows] = await db.query( - `SELECT profile_id FROM finusAccount_profile WHERE account_id = ?`, - [userId], - ); - - if (!profileRows || (profileRows as RowDataPacket[]).length === 0) { - throw new Error("User profile not found"); - } - - const profileId = (profileRows as RowDataPacket[])[0].profile_id; - - //Checks if the profile has an account with that id + //single query joining through the relationship chain const [rows] = await db.query( - `SELECT 1 FROM profile_financialAccount - WHERE profile_id =? AND financialAccount_id =?`, - [profileId, accountId], + `SELECT 1 + FROM profile_financialAccount pfa + INNER JOIN finusAccount_profile uap ON pfa.profile_id = uap.profile_id + WHERE uap.account_id = ? AND pfa.financialAccount_id = ?`, + [userId, accountId], ); - - console.log(rows.length); - if (rows.length > 0) { - result = true; - } + console.log( + `checking account id:${accountId} for user ${userId}, rows found: `, + rows.length, + ); + return rows.length > 0; } catch (error) { - console.error(error); + console.error("Error in checkUserId:", error); + return false; } - - return result; } diff --git a/app/server/services/ts/user/src/routes/account.ts b/app/server/services/ts/user/src/routes/account.ts index 05ba808..cfbe250 100644 --- a/app/server/services/ts/user/src/routes/account.ts +++ b/app/server/services/ts/user/src/routes/account.ts @@ -9,7 +9,7 @@ import type { RowDataPacket, } from "mysql2/promise"; import type { financialAccount } from "@/types.js"; -import { authenticateJWT } from "../handleJWT.js"; +import { authenticateJWT } from "../utils/auth.ts"; //this used to be ../handleJWT.ts but that does not work right import { checkUserId } from "../CheckUser.ts"; import { pool } from "../db.ts"; @@ -173,6 +173,10 @@ accountsRouter.put("/", async (req: Request, res: Response) => { } const { id, name, type, balance, value, subtype } = req.body; + let valueUpdate = value; + if (!value) { + valueUpdate = balance; + } const last_updated = new Date(); @@ -180,7 +184,7 @@ accountsRouter.put("/", async (req: Request, res: Response) => { `UPDATE financialAccount SET name = ?, type = ?, balance = ?, value = ?, last_updated = ?, subtype = ? WHERE id= ?`, - [name, type, balance, value, last_updated, subtype ?? null, id], + [name, type, balance, valueUpdate, last_updated, subtype ?? null, id], ); // console.log("Updated Account " + name); @@ -201,7 +205,8 @@ accountsRouter.put("/", async (req: Request, res: Response) => { accountsRouter.delete("/", async (req: Request, res: Response) => { let userId; let connection: PoolConnection | undefined; - const { id } = req.body; + + const id = req.query.id ? Number(req.query.id) : null; try { userId = authenticateJWT(req); From 9ccda9cb8d5fee6481011d4689a65acd5a835780 Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Wed, 1 Apr 2026 11:48:31 -0500 Subject: [PATCH 05/19] Reformatted the projection page - there's a massive issue with savings projection at this point --- app/client/src/api/Debt.ts | 46 +- app/client/src/components/SankeyChart.tsx | 9 +- app/client/src/pages/ProjectionPage.tsx | 551 +++++++++++----------- 3 files changed, 314 insertions(+), 292 deletions(-) diff --git a/app/client/src/api/Debt.ts b/app/client/src/api/Debt.ts index 6b32969..afbf575 100644 --- a/app/client/src/api/Debt.ts +++ b/app/client/src/api/Debt.ts @@ -1,27 +1,29 @@ import type { DebtPayoffResponse } from "../types/responseTypes"; -import type { AuthSession } from "@/types/authTypes"; +// import type { AuthSession } from "@/types/authTypes"; import type { Account } from "@/types/AccountType"; import type { projectionDebtRequest } from "@/types/requestTypes"; +import { instance } from "./config"; -const requestUrl = "http://localhost:3000/api/debts"; +// const requestUrl = "http://localhost:3000/api/debts"; //Sends a request to get different debts the user has -export async function getDebt(session: AuthSession): Promise { +export async function getDebt(): Promise { try { //Sends a http request and waits for a response - const response = await fetch(requestUrl, { - method: "GET", - headers: { Authorization: `Bearer ${session.token}` }, - }); + // const response = await fetch(requestUrl, { + // method: "GET", + // headers: { Authorization: `Bearer ${session.token}` }, + // }); + const response = await instance.get(`/api/debts`); //Determine if we were able to retrieve user's data - if (!response.ok) { + if (response.status !== 200) { //Failed to retrieve user data, return empty array console.error("Error: Failed to retrieve users debts", response.status); return []; } - return response.json(); + return response.data; } catch (error) { console.error(error); throw error; @@ -30,28 +32,28 @@ export async function getDebt(session: AuthSession): Promise { //Post request, even tho it says get in the function export async function getDebtProjection( - session: AuthSession, + // session: AuthSession, request: projectionDebtRequest, ): Promise { - const url = "http://localhost:3000/predict-debt-payoff"; + // const url = "http://localhost:3000/predict-debt-payoff"; try { //Create post request and wait for response - const response = await fetch(url, { - method: "POST", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${session.token}`, - }, - body: JSON.stringify(request), - }); - + // const response = await fetch(url, { + // method: "POST", + // headers: { + // "content-type": "application/json", + // Authorization: `Bearer ${session.token}`, + // }, + // body: JSON.stringify(request), + // }); + const response = await instance.post(`/predict-debt-payoff`, request); //Determine if our post was a success - if (!response.ok) { + if (response.status !== 200) { console.error(response.status); } - return response.json(); + return response.data; } catch (error) { console.error(error); throw error; diff --git a/app/client/src/components/SankeyChart.tsx b/app/client/src/components/SankeyChart.tsx index 6ed3032..3eefad8 100644 --- a/app/client/src/components/SankeyChart.tsx +++ b/app/client/src/components/SankeyChart.tsx @@ -68,7 +68,7 @@ function CustomNode({ x, y, width, height, index, payload }: SankeyNodeProps) { fontSize="14" fill="#ffffff" > - {payload.name} + {formatCategoryLabel(payload.name)} word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + export default SankeyChart; diff --git a/app/client/src/pages/ProjectionPage.tsx b/app/client/src/pages/ProjectionPage.tsx index 1a63e8e..980c2fa 100644 --- a/app/client/src/pages/ProjectionPage.tsx +++ b/app/client/src/pages/ProjectionPage.tsx @@ -22,7 +22,7 @@ import { type projectedDataResponse, } from "@/types/responseTypes"; import ProjectionGraph from "@/components/ProjectionGraph"; -import { TbGraph } from "react-icons/tb"; +import { TbGraph, TbCalculator, TbRefresh } from "react-icons/tb"; import NoItemState from "@/components/NoItemState"; import { handleCurrencyChange, @@ -65,34 +65,31 @@ function ProjectionPage({ session }: ProjectionProp) { const [amount, setAmount] = useState(""); const [period, setPeriod] = useState(""); const [minPay, setMinPay] = useState(""); - const [selectedType, setSelectedType] = useState<"Saving" | "Debt">("Saving"); - const [savingData, setSavingData] = useState< projectedDataResponse | undefined >(undefined); const [debtData, setDebtData] = useState( undefined, ); - const [debtRequest, setDebtRequest] = useState< projectionDebtRequest | undefined >(undefined); const [savingRequest, setSavingRequest] = useState< projectionSavingRequest | undefined >(undefined); - - //Total amount of interest generated const [totalInterest, setTotalInterest] = useState(0); - //Total amoutn of pay const [totalPay, setTotalPay] = useState(0); + const [isLoading, setIsLoading] = useState(false); - const calculateProjection = () => { + const calculateProjection = async () => { const inputAmount = Number(amount); const inputMinPay = Number(minPay); const inputInterest = Number(interest); const inputPeriod = Number(period); + setIsLoading(true); + switch (selectedType) { case "Debt": if ( @@ -117,37 +114,35 @@ function ProjectionPage({ session }: ProjectionProp) { setDebtRequest(newDebtRequest); - getDebtProjection(session, newDebtRequest) - .then((data) => { + try { + const data = await getDebtProjection(newDebtRequest); + if (data) { let dataTotalInterest = 0; let dataTotalPay = 0; const debtLineData: LineInfo = { name: "Remaining Debt", data: [], }; - console.log(data); - if (data) { - const graphData: projectedDataResponse = { - lineInfo: [], - dateLabel: [], - }; - data.debtStages.map((stage) => { - debtLineData.data.push(stage.remainingDebt); - graphData.dateLabel.push(stage.installmentDate); - dataTotalInterest += stage.interestAmount; - dataTotalPay += stage.principalAmount; - }); - - graphData.lineInfo = [debtLineData]; - - setDebtData(graphData); - setTotalPay(dataTotalPay); - setTotalInterest(dataTotalInterest); - } - }) - .catch((error) => { - alert(error); - }); + const graphData: projectedDataResponse = { + lineInfo: [], + dateLabel: [], + }; + + data.debtStages.map((stage) => { + debtLineData.data.push(stage.remainingDebt); + graphData.dateLabel.push(stage.installmentDate); + dataTotalInterest += stage.interestAmount; + dataTotalPay += stage.principalAmount; + }); + + graphData.lineInfo = [debtLineData]; + setDebtData(graphData); + setTotalPay(dataTotalPay); + setTotalInterest(dataTotalInterest); + } + } catch { + alert("Failed to get debt projection"); + } } else { alert("Please enter all fields"); } @@ -173,52 +168,55 @@ function ProjectionPage({ session }: ProjectionProp) { setSavingRequest(newSavingRequest); - getSavingProjection(session, newSavingRequest) - .then((data) => { - console.log(data); + try { + const data = await getSavingProjection(session, newSavingRequest); + if (data) { const graphData: projectedDataResponse = { dateLabel: [], lineInfo: [], }; - const bestCase: LineInfo = { name: "Best Case", data: [] }; + const bestCase: LineInfo = { + name: "Best Case (Optimistic)", + data: [], + }; const expectedCase: LineInfo = { name: "Expected Case", data: [], }; - const worstCase: LineInfo = { name: "Worst Case", data: [] }; - - if (data) { - //Extract all the data from the response - data.map((datapoint) => { - graphData.dateLabel.push(datapoint.date); - bestCase.data.push(datapoint.accumulative_best_balance); - expectedCase.data.push( - datapoint.accumulative_expected_balance, - ); - worstCase.data.push(datapoint.accumulative_worst_balance); - }); - - graphData.lineInfo = [bestCase, expectedCase, worstCase]; - setSavingData(graphData); - } else { - alert("Failed to get the projection "); - } - }) - .catch(() => { - alert("Failed to get the projection"); - }); + const worstCase: LineInfo = { + name: "Worst Case (Conservative)", + data: [], + }; + + data.map((datapoint) => { + graphData.dateLabel.push(datapoint.date); + bestCase.data.push(datapoint.accumulative_best_balance); + expectedCase.data.push(datapoint.accumulative_expected_balance); + worstCase.data.push(datapoint.accumulative_worst_balance); + }); + + graphData.lineInfo = [bestCase, expectedCase, worstCase]; + setSavingData(graphData); + } + } catch { + alert("Failed to get saving projection"); + } } else { alert("Please enter all fields"); } + break; } + + setIsLoading(false); }; const handleTypeChange = (typeChange: "Saving" | "Debt") => { setSelectedType(typeChange); + setSavingData(undefined); + setDebtData(undefined); - if (selectedType === "Debt") { + if (typeChange === "Debt") { if (debtRequest) { - //Set the fields to what was used in the debt projection graph setSelectedAccount(Number(debtRequest.id)); setAmount(debtRequest.remainingAmount.toFixed(2)); setInterest(debtRequest.interestRate.toString()); @@ -233,16 +231,14 @@ function ProjectionPage({ session }: ProjectionProp) { setPeriod(""); setNextDueDate(""); } - } else if (selectedType === "Saving") { + } else if (typeChange === "Saving") { if (savingRequest) { - //Assigns the fields to what the projection of the saving account used setSelectedAccount(savingRequest.financial_account_id); - setAmount((savingRequest.balance * 100).toFixed(2)); + setAmount(savingRequest.balance.toFixed(2)); setInterest(savingRequest.annual_interest_rate.toString()); setMinPay(savingRequest.monthly_deposit.toFixed(2)); setPeriod(savingRequest.time_frame.toString()); } else { - //Resets the fields setSelectedAccount(0); setAmount(""); setInterest(""); @@ -252,256 +248,273 @@ function ProjectionPage({ session }: ProjectionProp) { } }; - //Onchange handler for the select accounts const handleSelectAccount = (event: React.ChangeEvent) => { - let target; const id = Number(event.target.value); if (selectedType === "Debt") { setSelectedDebt(id); - - target = debts.find((account) => account.id === id)?.balance; + const target = debts.find((account) => account.id === id)?.balance; + setAmount(target ? target.toFixed(2) : "0"); } else if (selectedType === "Saving") { setSelectedAccount(id); - - target = accounts.find((account) => account.id === id)?.balance; - } - - if (target) { - setAmount(target.toFixed(2)); - } else { - setAmount("0"); + const target = accounts.find((account) => account.id === id)?.balance; + setAmount(target ? target.toFixed(2) : "0"); } }; - //Get the saving accounts useEffect(() => { if (selectedType === "Saving") { - getSaving(session) - .then((userAccounts) => { - console.log(userAccounts); - - //Detemrine accounts exist - if (userAccounts) { - setAccounts(userAccounts); - } - }) - .catch(() => { - //alert("Failed to get accounts"); - }); + getSaving(session).then((userAccounts) => { + if (userAccounts) setAccounts(userAccounts); + }); } }, [session, selectedType]); - //Gets the debt accounts useEffect(() => { if (selectedType === "Debt") { - getDebt(session) - .then((userDebts) => { - console.log(userDebts); - - //Detemrine if there are any debts - if (userDebts) { - setDebts(userDebts); - } - }) - .catch(() => {}); + getDebt().then((userDebts) => { + if (userDebts) setDebts(userDebts); + }); } }, [session, selectedType]); - const typeButton = [ - { key: "Saving", label: "Saving" }, - { key: "Debt", label: "Debt" }, - ].map(({ key, label }) => ( - - )); + const typeButtons = [ + { key: "Saving", label: "Savings Projection" }, + { key: "Debt", label: "Debt Payoff Projection" }, + ]; const glowLeft = ( -
+
); const glowRight = (
); + return ( -
+
{glowLeft} {glowRight} -

Projection

-

- Here you can view a projection of you're saving's or debt -

- -

Select Saving Account or Debt

-
- {typeButton} -
+
+

Financial Projections

+

+ Plan your financial future with savings growth and debt payoff + simulations +

+ + {/* Type Selector */} +
+ {typeButtons.map(({ key, label }) => ( + + ))} +
-
- {selectedType === "Saving" ? ( - - ) : ( - - )} -
- <> - - handleCurrencyChange(event, setAmount)} - onBlur={(event) => handleCurrencyBlur(event, amount, setAmount)} - /> - -
- -
- {selectedType === "Saving" ? ( - savingData ? ( - <> - - - ) : ( - <> + {/* Results Section */} +
+

+ {selectedType === "Saving" + ? "Savings Growth Projection" + : "Debt Payoff Schedule"} +

+ + {selectedType === "Saving" ? ( + savingData ? ( + <> + + + ) : ( } /> - - ) - ) : null} - - {selectedType === "Debt" ? ( - debtData ? ( - <> - -
- {"Total Amount Paid:$" + - totalPay.toFixed(2) + - " Total Interest Paid:$" + - totalInterest.toFixed(2)} -
- - ) : ( - <> + ) + ) : null} + + {selectedType === "Debt" ? ( + debtData ? ( + <> + +
+
+
+

Total Amount Paid

+

+ ${totalPay.toFixed(2)} +

+
+
+

+ Total Interest Paid +

+

+ ${totalInterest.toFixed(2)} +

+
+
+
+ + ) : ( } /> - - ) - ) : null} + ) + ) : null} +
); From 761810e995496dd806afab0f54828202e4d1965a Mon Sep 17 00:00:00 2001 From: Dylan Prabagaran Date: Wed, 1 Apr 2026 13:06:48 -0500 Subject: [PATCH 06/19] addint unit tests for user input (WIP) --- app/server/docker-compose.test.yml | 5 + .../ts/user/Dockerfile.integration.test | 2 + .../ts/user/userInput/Dockerfile.userInp.test | 12 ++ .../ts/user/userInput/accountTests.test.ts | 163 ++++++++++++++ .../ts/user/userInput/transaction.test.ts | 201 ++++++++++++++++++ 5 files changed, 383 insertions(+) create mode 100644 app/server/services/ts/user/userInput/Dockerfile.userInp.test create mode 100644 app/server/services/ts/user/userInput/accountTests.test.ts create mode 100644 app/server/services/ts/user/userInput/transaction.test.ts diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index 76428b5..d1e75b9 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -19,6 +19,11 @@ services: context: services/python/analytics dockerfile: Dockerfile.test + userinp-test: + build: + context: services/ts + dockerfile: user/userInput/Dockerfile.userInp.test + # user-integration-test:#uncomment to run integration tests locally as it likely does not work properly in CI/CD # build: # context: ./services/ts diff --git a/app/server/services/ts/user/Dockerfile.integration.test b/app/server/services/ts/user/Dockerfile.integration.test index 51af2d2..7a68957 100644 --- a/app/server/services/ts/user/Dockerfile.integration.test +++ b/app/server/services/ts/user/Dockerfile.integration.test @@ -8,3 +8,5 @@ COPY vitest.config.ts . ENV CI=true CMD ["bun", "test", "integration", "--coverage"] + + diff --git a/app/server/services/ts/user/userInput/Dockerfile.userInp.test b/app/server/services/ts/user/userInput/Dockerfile.userInp.test new file mode 100644 index 0000000..56c57b7 --- /dev/null +++ b/app/server/services/ts/user/userInput/Dockerfile.userInp.test @@ -0,0 +1,12 @@ +FROM measureonecodetwice/finus-user:local + +COPY userInput/*.test.ts ./test/ + +WORKDIR /project + +COPY vitest.config.ts . + +ENV CI=true + +# Run tests +CMD ["bun", "run", "vitest", "--run"] diff --git a/app/server/services/ts/user/userInput/accountTests.test.ts b/app/server/services/ts/user/userInput/accountTests.test.ts new file mode 100644 index 0000000..a5e923a --- /dev/null +++ b/app/server/services/ts/user/userInput/accountTests.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; +import express from "express"; +import { accountsRouter } from "../src/routes/account.ts"; + +// mock the database and auth modules +vi.mock("../src/db.ts", () => ({ + pool: { + getConnection: vi.fn(), + }, +})); + +vi.mock("../src/utils/auth.ts", () => ({ + authenticateJWT: vi.fn(), +})); + +vi.mock("../src/CheckUser.ts", () => ({ + checkUserId: vi.fn(), +})); + +// here we import the router and set up the express app after mocking, so that the mocks are used in the router +const app = express(); +app.use(express.json()); +app.use("/accounts", accountsRouter); + +const mockConnection = { + query: vi.fn(), + release: vi.fn(), +}; + +const { pool } = await import("../src/db.ts"); +const { authenticateJWT } = await import("../src/utils/auth.ts"); +const { checkUserId } = await import("../src/CheckUser.ts"); + +//reset +beforeEach(() => { + vi.clearAllMocks(); + pool.getConnection.mockResolvedValue(mockConnection); +}); + +// POST /accounts + +describe("POST /accounts", () => { + it("returns 400 if name is missing", async () => { + const res = await request(app) + .post("/accounts") + .send({ type: "checking", balance: 100, value: 100 }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Name is required"); + }); + + it("returns 400 if type is missing", async () => { + const res = await request(app) + .post("/accounts") + .send({ name: "Test", balance: 100, value: 100 }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Type is required"); + }); + + it("returns 401 if JWT fails", async () => { + authenticateJWT.mockImplementation(() => { + throw new Error("bad token"); + }); + + const res = await request(app) + .post("/accounts") + .send({ name: "Test", type: "checking", balance: 100, value: 100 }); + + expect(res.status).toBe(401); + }); + + it("returns 404 if profile not found", async () => { + authenticateJWT.mockReturnValue(1); + mockConnection.query.mockResolvedValueOnce([[]]); + + const res = await request(app) + .post("/accounts") + .send({ name: "Test", type: "checking", balance: 100, value: 100 }); + + expect(res.status).toBe(404); + }); +}); + +// GET /accounts +describe("GET /accounts", () => { + it("returns 401 if JWT fails", async () => { + authenticateJWT.mockImplementation(() => { + throw new Error("bad token"); + }); + + const res = await request(app).get("/accounts"); + + expect(res.status).toBe(401); + }); + + it("returns 404 if profile not found", async () => { + authenticateJWT.mockReturnValue(1); + mockConnection.query.mockResolvedValueOnce([[]]); // no profile rows + + const res = await request(app).get("/accounts"); + + expect(res.status).toBe(404); + }); +}); + +// PUT /accounts +describe("PUT /accounts", () => { + it("returns 401 if no account is provided", async () => { + const res = await request(app).put("/accounts").send({}); + expect(res.status).toBe(401); + }); + + it("returns 401 if JWT fails", async () => { + authenticateJWT.mockImplementation(() => { + throw new Error("bad token"); + }); + + const res = await request(app) + .put("/accounts") + .send({ id: 1, name: "x", type: "checking", balance: 10, value: 10 }); + + expect(res.status).toBe(401); + }); + + it("returns 401 if user does not own the account", async () => { + authenticateJWT.mockReturnValue(1); + checkUserId.mockResolvedValue(false); + + const res = await request(app) + .put("/accounts") + .send({ id: 1, name: "x", type: "checking", balance: 10, value: 10 }); + + expect(res.status).toBe(401); + }); +}); + +// DELETE /accounts +describe("DELETE /accounts", () => { + it("returns 400 if no id is provided", async () => { + const res = await request(app).delete("/accounts"); + expect(res.status).toBe(400); + }); + + it("returns 401 if JWT fails", async () => { + authenticateJWT.mockImplementation(() => { + throw new Error("bad token"); + }); + + const res = await request(app).delete("/accounts?id=1"); + expect(res.status).toBe(401); + }); + + it("returns 401 if user does not own the account", async () => { + authenticateJWT.mockReturnValue(1); + checkUserId.mockResolvedValue(false); + + const res = await request(app).delete("/accounts?id=1"); + + expect(res.status).toBe(401); + }); +}); diff --git a/app/server/services/ts/user/userInput/transaction.test.ts b/app/server/services/ts/user/userInput/transaction.test.ts new file mode 100644 index 0000000..8033666 --- /dev/null +++ b/app/server/services/ts/user/userInput/transaction.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; +import express from "express"; +import { transactionsRouter } from "../src/routes/transaction.ts"; + +//mock for database and auth modules +vi.mock("../src/db.ts", () => ({ + pool: { + getConnection: vi.fn(), + }, +})); + +vi.mock("../src/utils/auth.ts", () => ({ + authenticateJWT: vi.fn(), +})); + +vi.mock("../src/CheckUser.ts", () => ({ + checkUserId: vi.fn(), +})); + +vi.mock("../src/DataConversion.ts", () => ({ + convertToDateTime: vi.fn((d) => d), +})); + +const app = express(); +app.use(express.json()); +app.use("/transactions", transactionsRouter); + +const mockConnection = { + query: vi.fn(), + release: vi.fn(), + beginTransaction: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), +}; + +const { pool } = await import("../src/db.ts"); +const { authenticateJWT } = await import("../src/utils/auth.ts"); +const { checkUserId } = await import("../src/CheckUser.ts"); + +// reset +beforeEach(() => { + vi.clearAllMocks(); + pool.getConnection.mockResolvedValue(mockConnection); +}); + +// GET /transactions +describe("GET /transactions", () => { + it("returns 400 if financialAccount_id is missing", async () => { + const res = await request(app).get("/transactions"); + expect(res.status).toBe(400); + }); + + it("returns 401 if JWT fails", async () => { + authenticateJWT.mockImplementation(() => { + throw new Error("bad token"); + }); + + const res = await request(app).get("/transactions?financialAccount_id=1"); + expect(res.status).toBe(401); + }); + + it("returns 401 if user does not own the account", async () => { + authenticateJWT.mockReturnValue(1); + checkUserId.mockResolvedValue(false); + + const res = await request(app).get("/transactions?financialAccount_id=1"); + expect(res.status).toBe(401); + }); +}); + +// POST /transactions +describe("POST /transactions", () => { + it("returns 401 if JWT fails", async () => { + authenticateJWT.mockImplementation(() => { + throw new Error("bad token"); + }); + + const res = await request(app) + .post("/transactions") + .send({ financialAccount_id: 1, amount: 10, date: "2024-01-01" }); + + expect(res.status).toBe(401); + }); + + it("returns 401 if user does not own the account", async () => { + authenticateJWT.mockReturnValue(1); + checkUserId.mockResolvedValue(false); + + const res = await request(app) + .post("/transactions") + .send({ financialAccount_id: 1, amount: 10, date: "2024-01-01" }); + + expect(res.status).toBe(401); + }); +}); + +// PUT /transactions +describe("PUT /transactions", () => { + it("returns 401 if JWT fails", async () => { + authenticateJWT.mockImplementation(() => { + throw new Error("bad token"); + }); + + const res = await request(app) + .put("/transactions") + .send({ id: 1, financialAccount_id: 1, amount: 10, date: "2024-01-01" }); + + expect(res.status).toBe(401); + }); + + it("returns 401 if user does not own the account", async () => { + authenticateJWT.mockReturnValue(1); + checkUserId.mockResolvedValue(false); + + const res = await request(app) + .put("/transactions") + .send({ id: 1, financialAccount_id: 1, amount: 10, date: "2024-01-01" }); + + expect(res.status).toBe(401); + }); +}); + +// DELETE /transactions +describe("DELETE /transactions", () => { + it("returns 400 if id or financialAccount_id missing", async () => { + const res = await request(app).delete("/transactions").send({}); + expect(res.status).toBe(400); + }); + + it("returns 401 if JWT fails", async () => { + authenticateJWT.mockImplementation(() => { + throw new Error("bad token"); + }); + + const res = await request(app) + .delete("/transactions") + .send({ id: 1, financialAccount_id: 1 }); + + expect(res.status).toBe(401); + }); + + it("returns 401 if user does not own the account", async () => { + authenticateJWT.mockReturnValue(1); + checkUserId.mockResolvedValue(false); + + const res = await request(app) + .delete("/transactions") + .send({ id: 1, financialAccount_id: 1 }); + + expect(res.status).toBe(401); + }); +}); + +// POST /transactions/csvTransaction +describe("POST /transactions/csvTransaction", () => { + it("returns 400 if payload invalid", async () => { + const res = await request(app) + .post("/transactions/csvTransaction") + .send({ financialAccount_id: 1, transactions: "not-array" }); + + expect(res.status).toBe(400); + }); + + it("returns 401 if JWT fails", async () => { + authenticateJWT.mockImplementation(() => { + throw new Error("bad token"); + }); + + const res = await request(app) + .post("/transactions/csvTransaction") + .send({ financialAccount_id: 1, transactions: [] }); + + expect(res.status).toBe(401); + }); + + it("returns 401 if user does not own the account", async () => { + authenticateJWT.mockReturnValue(1); + checkUserId.mockResolvedValue(false); + + const res = await request(app) + .post("/transactions/csvTransaction") + .send({ financialAccount_id: 1, transactions: [] }); + + expect(res.status).toBe(401); + }); + + it("returns 400 if no valid rows", async () => { + authenticateJWT.mockReturnValue(1); + checkUserId.mockResolvedValue(true); + + const res = await request(app) + .post("/transactions/csvTransaction") + .send({ + financialAccount_id: 1, + transactions: [{ errors: ["bad"] }], + }); + + expect(res.status).toBe(401); + }); +}); From 11902aeea10c331d72e774ca68d2b86fd71089dd Mon Sep 17 00:00:00 2001 From: Dylan Prabagaran Date: Wed, 1 Apr 2026 13:29:42 -0500 Subject: [PATCH 07/19] User input bug fixes for tests --- app/server/docker-compose.test.yml | 2 +- app/server/services/ts/user/Dockerfile.userInp.test | 11 +++++++++++ .../ts/user/userInput/Dockerfile.userInp.test | 12 ------------ 3 files changed, 12 insertions(+), 13 deletions(-) create mode 100644 app/server/services/ts/user/Dockerfile.userInp.test delete mode 100644 app/server/services/ts/user/userInput/Dockerfile.userInp.test diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index d1e75b9..ff47bc3 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -22,7 +22,7 @@ services: userinp-test: build: context: services/ts - dockerfile: user/userInput/Dockerfile.userInp.test + dockerfile: user/Dockerfile.userInp.test # user-integration-test:#uncomment to run integration tests locally as it likely does not work properly in CI/CD # build: diff --git a/app/server/services/ts/user/Dockerfile.userInp.test b/app/server/services/ts/user/Dockerfile.userInp.test new file mode 100644 index 0000000..7ea27e2 --- /dev/null +++ b/app/server/services/ts/user/Dockerfile.userInp.test @@ -0,0 +1,11 @@ +FROM measureonecodetwice/finus-user:local + +COPY user/userInput ./test + +WORKDIR /project + +COPY vitest.config.ts . + +ENV CI=true + +CMD [ "bun", "run", "vitest", "--run"] diff --git a/app/server/services/ts/user/userInput/Dockerfile.userInp.test b/app/server/services/ts/user/userInput/Dockerfile.userInp.test deleted file mode 100644 index 56c57b7..0000000 --- a/app/server/services/ts/user/userInput/Dockerfile.userInp.test +++ /dev/null @@ -1,12 +0,0 @@ -FROM measureonecodetwice/finus-user:local - -COPY userInput/*.test.ts ./test/ - -WORKDIR /project - -COPY vitest.config.ts . - -ENV CI=true - -# Run tests -CMD ["bun", "run", "vitest", "--run"] From 4d6bc2de5e171bd322dabedbcb453e1ea93b7887 Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Wed, 1 Apr 2026 14:29:54 -0500 Subject: [PATCH 08/19] FIxed massive savings projections and readded tests for new logic --- app/client/src/api/Saving.ts | 50 +++-- app/client/src/pages/ProjectionPage.tsx | 30 +-- .../python/analytics/src/logic/savings.py | 156 +++---------- .../python/analytics/test/test_savings.py | 210 +++++++++++++++--- 4 files changed, 254 insertions(+), 192 deletions(-) diff --git a/app/client/src/api/Saving.ts b/app/client/src/api/Saving.ts index 6215858..45412a2 100644 --- a/app/client/src/api/Saving.ts +++ b/app/client/src/api/Saving.ts @@ -1,26 +1,28 @@ import { type Account } from "@/types/AccountType"; -import { type AuthSession } from "@/types/authTypes"; +// import { type AuthSession } from "@/types/authTypes"; import type { projectionSavingRequest } from "@/types/requestTypes"; import { type savingProjectionResponseData } from "@/types/responseTypes"; -const requestUrl = "http://localhost:3000/api/savings"; +// const requestUrl = "http://localhost:3000/api/savings"; +import { instance } from "./config"; //Sends a request to get different debts the user has -export async function getSaving(session: AuthSession): Promise { +export async function getSaving(): Promise { try { //Sends a http request and waits for a response - const response = await fetch(requestUrl, { - method: "GET", - headers: { Authorization: `Bearer ${session.token}` }, - }); + // const response = await fetch(requestUrl, { + // method: "GET", + // headers: { Authorization: `Bearer ${session.token}` }, + // }); + const response = await instance.get(`/api/savings`); //Determine if we were able to retrieve user's data - if (!response.ok) { + if (response.status !== 200) { //Failed to retrieve user data, return empty array console.error("Error: Failed to retrieve users debts", response.status); return []; } - return response.json(); + return response.data; } catch (error) { console.error(error); throw error; @@ -29,29 +31,35 @@ export async function getSaving(session: AuthSession): Promise { //Post request, even tho it says get in the function export async function getSavingProjection( - session: AuthSession, + // session: AuthSession, request: projectionSavingRequest, ): Promise { - const url = "http://localhost:3000/compound-interest"; + // const url = "http://localhost:3000/compound-interest"; try { //Create post request and wait for response - const response = await fetch(url, { - method: "POST", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${session.token}`, - }, - body: JSON.stringify(request), - }); + // const response = await fetch(url, { + // method: "POST", + // headers: { + // "content-type": "application/json", + // Authorization: `Bearer ${session.token}`, + // }, + // body: JSON.stringify(request), + // }); + console.log("sent over: ", JSON.stringify(request)); + const response = await instance.post( + `/compound-interest`, + JSON.stringify(request), + ); + console.log("response from server: ", response); //Determine if our post was a success - if (!response.ok) { + if (response.status !== 200) { console.error(response.status); return []; } - return response.json(); + return response.data; } catch (error) { console.error(error); throw error; diff --git a/app/client/src/pages/ProjectionPage.tsx b/app/client/src/pages/ProjectionPage.tsx index 980c2fa..0d37324 100644 --- a/app/client/src/pages/ProjectionPage.tsx +++ b/app/client/src/pages/ProjectionPage.tsx @@ -169,7 +169,7 @@ function ProjectionPage({ session }: ProjectionProp) { setSavingRequest(newSavingRequest); try { - const data = await getSavingProjection(session, newSavingRequest); + const data = await getSavingProjection(newSavingRequest); if (data) { const graphData: projectedDataResponse = { dateLabel: [], @@ -339,19 +339,21 @@ function ProjectionPage({ session }: ProjectionProp) { ? "Select Savings Account" : "Select Debt Account"} - {selectedType === "Saving" ? ( - - ) : ( - - )} +
+ {selectedType === "Saving" ? ( + + ) : ( + + )} +
{/* Input Grid */} diff --git a/app/server/services/python/analytics/src/logic/savings.py b/app/server/services/python/analytics/src/logic/savings.py index 43166dd..a822b14 100644 --- a/app/server/services/python/analytics/src/logic/savings.py +++ b/app/server/services/python/analytics/src/logic/savings.py @@ -1,13 +1,8 @@ import pandas as pd -import numpy as np from typing import List, Dict -from datetime import datetime, date +from datetime import date from dateutil.relativedelta import relativedelta -from fastapi import HTTPException -# from ..queries import savings as savings_queries -from src.dependencies import get_db_connection -from src.models.schemas import ProjectedSavingsRequest, MonthlySavingGrowthRate, CompoundInterestResponse -from src.queries.savings import get_savings_transactions +from src.models.schemas import ProjectedSavingsRequest, CompoundInterestResponse def calculate_savings_over_time( accounts: List[Dict], @@ -58,152 +53,53 @@ def calculate_savings_over_time( }] } -# def get_monthly_balances(transactions: pd.DataFrame): -# df = transactions.copy() -# df['date'] = pd.to_datetime(df['date']) - -# # Sort by date -# df = df.sort_values('date') - -# # Group by month -# df['year_month'] = df['date'].dt.to_period('M') - -# monthly_sums = df.groupby('year_month')['amount'].sum().reset_index() - -# monthly_sums['year_month'] = monthly_sums['year_month'].dt.to_timestamp() - -# # Create full monthly range -# full_range = pd.date_range( -# start=monthly_sums['year_month'].min(), -# end=monthly_sums['year_month'].max(), -# freq='MS' # month start -# ) - -# monthly_sums = monthly_sums.set_index('year_month').reindex(full_range, fill_value=0) -# monthly_sums = monthly_sums.rename_axis('date').reset_index() - -# # Convert to cumulative balance -# monthly_sums['balance'] = monthly_sums['amount'].cumsum() -# print(monthly_sums) - -# return monthly_sums - -def get_monthly_balances(transactions: pd.DataFrame): - df = transactions.copy() - df['date'] = pd.to_datetime(df['date']) - - # Extract month only (1–12) - df['month'] = df['date'].dt.month - - # Initialize 12 months with no balance - monthly_totals = {month: 0 for month in range(1, 13)} - - # Aggregate ignoring year - for _, row in df.iterrows(): - monthly_totals[row['month']] += row['amount'] - - # Convert to DataFrame (ordered) - monthly_df = pd.DataFrame([ - {"month": m, "amount": monthly_totals[m]} - for m in range(1, 13) - ]) - - # Compute cumulative balance for each month - monthly_df['balance'] = monthly_df['amount'].cumsum() - - print(monthly_df) - - return monthly_df - -def compute_monthly_growth_rates(monthly_df: pd.DataFrame): - # Compute increase rate between months - monthly_df['growth_rate'] = monthly_df['balance'].pct_change() - - # Replace inf and -inf with NaN and drop NaN - monthly_df['growth_rate'].replace([np.inf, -np.inf], np.nan, inplace=True) - - # Replace NaN with 0 - monthly_df['growth_rate'].fillna(0, inplace=True) - - growth_rates = monthly_df['growth_rate'] - growth_rates = growth_rates.round(3) - - return growth_rates - -def generate_savings_growth_rate(transactions: List[Dict]) -> MonthlySavingGrowthRate: - df = pd.DataFrame(transactions) - - if df.empty: - return None - - monthly_balances = get_monthly_balances(df) - growth_rates = compute_monthly_growth_rates(monthly_balances) - - mean_growth = growth_rates.mean() - standard_deviation_growth = growth_rates.std() - - return MonthlySavingGrowthRate( - best_case=round(mean_growth + standard_deviation_growth, 2), - expected_case=round(mean_growth, 2), - worst_case=max(round(mean_growth - standard_deviation_growth, 2), 0) - ) - def calculate_compound_interest(request: ProjectedSavingsRequest) -> List[CompoundInterestResponse]: - connection = get_db_connection() - if not connection: - raise HTTPException(status_code=500, detail="Database connection failed") - - cursor = connection.cursor(dictionary=True) - transactions = get_savings_transactions(cursor, [request.financial_account_id]) - print(transactions) - if len(transactions) == 0: - return [] - - monthly_savings_rate = generate_savings_growth_rate(transactions) - print(monthly_savings_rate) - - best_rate = monthly_savings_rate.best_case - worst_rate = monthly_savings_rate.worst_case - expected_rate = monthly_savings_rate.expected_case + annual_rate = request.annual_interest_rate / 100 + monthly_rate = annual_rate / 12 + worst_balance = request.balance expected_balance = request.balance best_balance = request.balance - + monthly_deposit = request.monthly_deposit time_frame = request.time_frame - + results: List[CompoundInterestResponse] = [] - + months = time_frame * 12 current_date = date.today() - for _ in range(months): - # deposit money + # Monte Carlo simulation approach is more robust + best_monthly_rate = monthly_rate * 2 + worst_monthly_rate = monthly_rate * 0.5 + + for month in range(months): + # add monthly deposit at beginning of month worst_balance += monthly_deposit expected_balance += monthly_deposit best_balance += monthly_deposit - - # compute growth - worst_balance += worst_balance * worst_rate - expected_balance += expected_balance * expected_rate - best_balance += best_balance * best_rate - - # rounding + + # apply compound interest (standard formula) + worst_balance = worst_balance * (1 + worst_monthly_rate) + expected_balance = expected_balance * (1 + monthly_rate) + best_balance = best_balance * (1 + best_monthly_rate) + worst_balance = round(worst_balance, 2) expected_balance = round(expected_balance, 2) best_balance = round(best_balance, 2) - + + # calculate next month's date + next_date = current_date + relativedelta(months=month + 1) + results.append( CompoundInterestResponse( accumulative_worst_balance=worst_balance, accumulative_expected_balance=expected_balance, accumulative_best_balance=best_balance, - date=current_date.strftime("%B %Y") + date=next_date.strftime("%B %Y") ) ) - - current_date += relativedelta(months=1) - + return results \ No newline at end of file diff --git a/app/server/services/python/analytics/test/test_savings.py b/app/server/services/python/analytics/test/test_savings.py index 53e4ed2..c214666 100644 --- a/app/server/services/python/analytics/test/test_savings.py +++ b/app/server/services/python/analytics/test/test_savings.py @@ -1,11 +1,12 @@ -from src.models.schemas import ProjectedSavingsRequest -import pytest +# tests/test_savings.py import pandas as pd -from datetime import datetime, timedelta -from unittest.mock import patch, MagicMock -from src.logic.savings import calculate_savings_over_time, calculate_compound_interest, generate_savings_growth_rate, compute_monthly_growth_rates, get_monthly_balances +import pytest +from datetime import date, timedelta +from dateutil.relativedelta import relativedelta +from src.models.schemas import ProjectedSavingsRequest, CompoundInterestResponse +from src.logic.savings import calculate_compound_interest, calculate_savings_over_time -class TestSavings: +class TestSavingsProjection: @pytest.fixture def mock_savings_transactions(self): @@ -29,6 +30,27 @@ def mock_savings_accounts(self): {'id': 1, 'balance': 5000}, {'id': 2, 'balance': 3000}, ] + + @pytest.fixture + def mock_savings_request(self): + return ProjectedSavingsRequest( + financial_account_id=1, + balance=1000.00, + monthly_deposit=100.00, + annual_interest_rate=5.0, # 5% annual interest + time_frame=1 # 1 year + ) + + @pytest.fixture + def expected_compound_calculation(self): + """Helper to calculate expected values for verification""" + def calculate(balance, monthly_deposit, monthly_rate, months): + result = balance + for _ in range(months): + result += monthly_deposit + result *= (1 + monthly_rate) + return round(result, 2) + return calculate def test_calculate_savings_over_time_weekly(self, mock_savings_accounts, mock_savings_transactions): end_date = pd.Timestamp('2024-03-15') @@ -121,35 +143,169 @@ def test_calculate_savings_over_time_no_accounts(self): assert result['labels'] == [] assert result['datasets'][0]['data'] == [] - def test_get_monthly_balances(self, mock_savings_transactions_by_financial_account): + def test_calculate_compound_interest_basic(self, mock_savings_request, expected_compound_calculation): + result = calculate_compound_interest(mock_savings_request) + + # Verify result structure + assert isinstance(result, list) + assert len(result) == 12 # 1 year = 12 months + + # Verify first month + first_month = result[0] + assert isinstance(first_month, CompoundInterestResponse) + assert first_month.accumulative_expected_balance > mock_savings_request.balance + + # Verify monthly progression + for i in range(1, len(result)): + assert result[i].accumulative_expected_balance > result[i-1].accumulative_expected_balance + + # Verify date formatting + current_date = date.today() + first_expected_date = (current_date + relativedelta(months=1)).strftime("%B %Y") + assert result[0].date == first_expected_date + + def test_calculate_compound_interest_zero_balance(self, mock_savings_request): + mock_savings_request.balance = 0.0 + result = calculate_compound_interest(mock_savings_request) + + assert len(result) == 12 + # After 12 months of $100 deposits with interest, should be > $1200 + assert result[-1].accumulative_expected_balance > 1200.0 + + def test_calculate_compound_interest_zero_deposit(self, mock_savings_request): + mock_savings_request.monthly_deposit = 0.0 + result = calculate_compound_interest(mock_savings_request) + + # Should still grow from interest alone + assert result[-1].accumulative_expected_balance > mock_savings_request.balance - result = get_monthly_balances(mock_savings_transactions_by_financial_account) + def test_calculate_compound_interest_high_interest_rate(self, mock_savings_request): + mock_savings_request.annual_interest_rate = 20.0 # 20% annual + result = calculate_compound_interest(mock_savings_request) + + # Growth should be significantly higher + assert result[-1].accumulative_expected_balance > 2000.0 - # January = 150, February = 200 - assert result.loc[result['month'] == 1, 'amount'].values[0] == 150 - assert result.loc[result['month'] == 2, 'amount'].values[0] == 200 + def test_calculate_compound_interest_zero_interest_rate(self, mock_savings_request): + mock_savings_request.annual_interest_rate = 0.0 + result = calculate_compound_interest(mock_savings_request) + + # With no interest, final balance should be: balance + (monthly_deposit * 12) + expected = mock_savings_request.balance + (mock_savings_request.monthly_deposit * 12) + assert result[-1].accumulative_expected_balance == expected - # cumulative balance - assert result.loc[result['month'] == 1, 'balance'].values[0] == 150 - assert result.loc[result['month'] == 2, 'balance'].values[0] == 350 + def test_calculate_compound_interest_negative_interest_rate(self, mock_savings_request): + mock_savings_request.annual_interest_rate = -5.0 + result = calculate_compound_interest(mock_savings_request) + + # With negative interest, balance should decrease + expected_no_interest = mock_savings_request.balance + (mock_savings_request.monthly_deposit * 12) + assert result[-1].accumulative_expected_balance < expected_no_interest - def test_compute_growth_rates_handles_nan_and_inf(self, mock_monthly_diff): + def test_calculate_compound_interest_long_term(self, mock_savings_request): + mock_savings_request.time_frame = 5 + result = calculate_compound_interest(mock_savings_request) + + assert len(result) == 60 # 5 years * 12 months + + # Verify best/west/expected ordering + for month in result: + assert month.accumulative_best_balance >= month.accumulative_expected_balance >= month.accumulative_worst_balance - growth_rates = compute_monthly_growth_rates(mock_monthly_diff) + def test_calculate_compound_interest_short_term(self, mock_savings_request): + mock_savings_request.time_frame = 1 # 1 year + result = calculate_compound_interest(mock_savings_request) + + assert len(result) == 12 + assert result[0].accumulative_expected_balance > mock_savings_request.balance - # Should not contain NaN or inf - assert not growth_rates.isnull().any() - assert not (growth_rates == float("inf")).any() + def test_calculate_compound_interest_rounding(self, mock_savings_request): + result = calculate_compound_interest(mock_savings_request) + + for month in result: + # Check that values have at most 2 decimal places + best_str = f"{month.accumulative_best_balance:.10f}" + expected_str = f"{month.accumulative_expected_balance:.10f}" + worst_str = f"{month.accumulative_worst_balance:.10f}" + + # After the decimal point, there should be at most 2 non-zero digits + # or they should be exactly 0 + best_decimal = best_str.split('.')[1].rstrip('0') + expected_decimal = expected_str.split('.')[1].rstrip('0') + worst_decimal = worst_str.split('.')[1].rstrip('0') + + assert len(best_decimal) <= 2 or best_decimal == '' + assert len(expected_decimal) <= 2 or expected_decimal == '' + assert len(worst_decimal) <= 2 or worst_decimal == '' - def test_generate_growth_rate(self, mock_transactions_with_same_amount): + def test_calculate_compound_interest_best_worst_cases(self, mock_savings_request): + result = calculate_compound_interest(mock_savings_request) + + for month in result: + assert month.accumulative_best_balance >= month.accumulative_expected_balance + assert month.accumulative_expected_balance >= month.accumulative_worst_balance - result = generate_savings_growth_rate(mock_transactions_with_same_amount) + def test_calculate_compound_interest_monthly_progression(self, mock_savings_request): + result = calculate_compound_interest(mock_savings_request) + + for i in range(1, len(result)): + # Expected case should always increase + assert result[i].accumulative_expected_balance > result[i-1].accumulative_expected_balance + + # Best case should always increase + assert result[i].accumulative_best_balance > result[i-1].accumulative_best_balance + + # Worst case should always increase (unless negative interest) + assert result[i].accumulative_worst_balance > result[i-1].accumulative_worst_balance - assert result is not None - assert result.best_case >= result.expected_case - assert result.expected_case >= result.worst_case + def test_calculate_compound_interest_large_values(self, mock_savings_request): + mock_savings_request.balance = 1000000.00 + mock_savings_request.monthly_deposit = 50000.00 + mock_savings_request.time_frame = 10 + + result = calculate_compound_interest(mock_savings_request) + + # Should still produce valid numbers (not inf or NaN) + assert len(result) == 120 + assert all(isinstance(m.accumulative_expected_balance, float) for m in result) + assert all(m.accumulative_expected_balance < float('inf') for m in result) + assert all(not pd.isna(m.accumulative_expected_balance) for m in result) - def test_generate_growth_rate_empty(self): - result = generate_savings_growth_rate([]) + def test_calculate_compound_interest_different_accounts(self): + test_cases = [ + (5000, 200, 3.5, 2), # High balance, moderate deposits + (100, 500, 7.0, 3), # Low balance, high deposits + (10000, 0, 4.0, 1), # High balance, no deposits + (0, 1000, 6.0, 5), # Zero balance, high deposits + ] + + for balance, deposit, rate, years in test_cases: + request = ProjectedSavingsRequest( + financial_account_id=1, + balance=balance, + monthly_deposit=deposit, + annual_interest_rate=rate, + time_frame=years + ) + result = calculate_compound_interest(request) + + assert len(result) == years * 12 + # Final balance should be greater than starting balance + total deposits + total_deposits = deposit * (years * 12) + if rate > 0: + assert result[-1].accumulative_expected_balance > balance + total_deposits + else: + assert result[-1].accumulative_expected_balance == balance + total_deposits - assert result is None \ No newline at end of file + def test_calculate_compound_interest_date_handling(self, mock_savings_request): + result = calculate_compound_interest(mock_savings_request) + + # Parse dates and verify they are sequential + from datetime import datetime + dates = [datetime.strptime(r.date, "%B %Y") for r in result] + + for i in range(1, len(dates)): + # Each month should be exactly 1 month apart + expected_next = dates[i-1] + relativedelta(months=1) + assert dates[i].year == expected_next.year + assert dates[i].month == expected_next.month \ No newline at end of file From 371b23c6b816949f889134456b0bd11917f594c8 Mon Sep 17 00:00:00 2001 From: Roman Tebel Date: Wed, 1 Apr 2026 16:27:18 -0500 Subject: [PATCH 09/19] Added import CSV button and removed old files --- app/client/src/api/Transaction.ts | 143 +------- app/client/src/components/AccountForm.tsx | 284 --------------- app/client/src/components/AccountListCard.tsx | 74 ---- app/client/src/components/CSVImportModal.tsx | 73 ++++ app/client/src/components/TransactionCard.tsx | 70 ---- app/client/src/components/TransactionList.tsx | 125 ------- .../src/components/TransactionTable.tsx | 61 +++- app/client/src/components/TransationForm.tsx | 324 ------------------ .../src/components/csvread/CsvConfirm.tsx | 2 +- app/client/src/components/csvread/CsvDrop.tsx | 5 +- .../src/components/csvread/CsvPreview.tsx | 2 +- .../src/components/csvread/CsvUpload.tsx | 12 +- 12 files changed, 155 insertions(+), 1020 deletions(-) delete mode 100644 app/client/src/components/AccountForm.tsx delete mode 100644 app/client/src/components/AccountListCard.tsx create mode 100644 app/client/src/components/CSVImportModal.tsx delete mode 100644 app/client/src/components/TransactionCard.tsx delete mode 100644 app/client/src/components/TransactionList.tsx delete mode 100644 app/client/src/components/TransationForm.tsx diff --git a/app/client/src/api/Transaction.ts b/app/client/src/api/Transaction.ts index b6c0b59..60a51a6 100644 --- a/app/client/src/api/Transaction.ts +++ b/app/client/src/api/Transaction.ts @@ -1,124 +1,12 @@ -import type { AuthSession } from "@/types/authTypes"; -import type { updateResponse } from "../types/responseTypes"; +// import type { AuthSession } from "@/types/authTypes"; +// import type { updateResponse } from "../types/responseTypes"; import type { Transaction } from "../types/Transaction"; import type { TransactionDraft } from "@/utils/ConvertTransaction"; - -const requestUrl = "http://localhost:3000/api/transactions"; - -//Sends a GET request to get the list of user transactions for the account - no need for this -export async function getTransactions( - session: AuthSession, - financialAccount_id: number, -): Promise { - try { - const response = await fetch( - `${requestUrl}/?financialAccount_id=${financialAccount_id}`, - { - method: "GET", - headers: { Authorization: `Bearer ${session.token}` }, - }, - ); - - if (!response.ok) { - alert("Failed to retrieve account's transaction\n"); - console.error(response.status); - return []; - } - - return response.json(); - } catch (error) { - console.error(error); - throw error; - } -} - -//Can send multiple transactions in a push request - no need for this -export async function postTranscations( - session: AuthSession, - trans: Transaction, -): Promise { - try { - const response = await fetch(requestUrl, { - method: "POST", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${session.token}`, - }, - body: JSON.stringify(trans), - }); - - if (response.ok) { - alert("Create the transaction"); - } else { - alert("Failed to create transaction"); - console.error(response.status); - } - - return response.json(); - } catch (error) { - console.log(error); - throw error; - } -} -// PUT /api/transactions/ -export async function putTranscations( - session: AuthSession, - trans: Transaction, -): Promise { - try { - const response = await fetch(requestUrl, { - method: "PUT", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${session.token}`, - }, - body: JSON.stringify(trans), - }); - - if (!response.ok) { - console.error("Failed to update transaction", response.status); - } - - return response.ok; - } catch (error) { - console.error(error); - throw error; - } -} - -//DELETE /api/transactions/ - -export async function deleteTransaction( - session: AuthSession, - selectedTransaction: Transaction, -): Promise { - try { - const response = await fetch(requestUrl, { - method: "DELETE", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${session.token}`, - }, - body: JSON.stringify({ - id: selectedTransaction.id, - financialAccount_id: selectedTransaction.financialAccount_id, - }), - }); - - if (!response.ok) { - console.error("Failed to delete transaction", response.status); - } - - return response.ok; - } catch (error) { - console.error(error); - throw error; - } -} +import { instance } from "./config"; // POST /api/transactions/csvTransaction export async function uploadCsvTransactions( - session: AuthSession, + // session: AuthSession, financialAccount_id: number, transactions: TransactionDraft[], ): Promise<{ @@ -126,14 +14,17 @@ export async function uploadCsvTransactions( skipped: number; transactions: Transaction[]; }> { - const response = await fetch(`${requestUrl}/csvTransaction`, { - method: "POST", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${session.token}`, - }, - body: JSON.stringify({ financialAccount_id, transactions }), - }); - - return response.json(); + // const response = await fetch(`${requestUrl}/csvTransaction`, { + // method: "POST", + // headers: { + // "content-type": "application/json", + // Authorization: `Bearer ${session.token}`, + // }, + // body: JSON.stringify({ financialAccount_id, transactions }), + // }); + const response = await instance.post( + `/api/transactions/csvTransaction`, + JSON.stringify({ financialAccount_id, transactions }), + ); + return response.data; } diff --git a/app/client/src/components/AccountForm.tsx b/app/client/src/components/AccountForm.tsx deleted file mode 100644 index 441225c..0000000 --- a/app/client/src/components/AccountForm.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React, { useState } from "react"; -import "./userForm.css"; -import { postUserAccount, putUserAccount } from "../api/Account.ts"; -import { type Account } from "../types/AccountType.ts"; -import { validateAccountForm } from "../utils/ValidateForms.ts"; -import { - handleCurrencyChange, - handleCurrencyBlur, -} from "../utils/handleInput.ts"; -import { accountCategory } from "@/enum/AccountCategory.ts"; -import type { AuthSession } from "@/types/authTypes.ts"; - -interface popupProp { - toggle: () => void; - session: AuthSession; - setAccount?: (account: Account) => void; - addAccount?: (account: Account) => void; - edit: boolean; - selectedAccount?: Account; -} - -type typeofAccount = keyof typeof accountCategory; - -export default function PopupForm({ - toggle, - session, - setAccount, - addAccount, - edit, - selectedAccount, -}: popupProp) { - //Submits the account data - - const handleSubmit = () => { - let subtype = undefined; - let interest = undefined; - const accountBalance = Number(balance); - - if ( - (accountType === "SAVINGS" || accountType === "CREDIT_CARD") && - formInput.subType - ) { - subtype = formInput.subType; - } - - if (accountType === accountCategory.SAVINGS) { - interest = formInput.interest; - } - - //Check account before validating b/c account can be undefined - //Determine if form input for an account is valid - if ( - accountType && - validateAccountForm( - formInput.name, - accountCategory[accountType], - accountBalance, - undefined, - subtype, - interest, - ) - ) { - const newAccount: Account = { - id: 0, - name: formInput.name, - type: accountType, - balance: accountBalance, - subtype: subtype, - value: 0, - last_updated: new Date(), - }; - console.log(newAccount); - - //Determine if we editing an account info - if (edit && selectedAccount) { - newAccount.id = selectedAccount.id; - - try { - //Put request to update the account - putUserAccount(session, newAccount).then((response) => { - //Determine if sucessfully updated the account - if (response && response.lastUpdated && setAccount) { - newAccount.last_updated = response.lastUpdated; - newAccount.balance = Number(newAccount.balance.toFixed(2)); - setAccount(newAccount); - } - }); - } catch { - alert("Failed to update Account"); - } - } else { - try { - //Send a post request to create - postUserAccount(session, newAccount).then((response) => { - //When creating new account, id and lastupdated is returned for that account - if (response && response.id) { - newAccount.id = response.id; - } - - if (response.lastUpdated) { - newAccount.last_updated = response.lastUpdated; - - //Check if addAcount is undefined - if (addAccount) { - addAccount(newAccount); - } - } - }); - } catch { - alert("Failed to create account " + newAccount.name); - } - } - - toggle(); - } else { - alert("Please fill out the form"); - } - }; - - const handleChange = ( - event: React.ChangeEvent, - ) => { - setFormInput({ ...formInput, [event.target.name]: event.target.value }); - }; - - //Handles the bank statement - /*const handleFile = (event:React.ChangeEvent) => { - - if(event.target && event.target.files && event.target.files[0]){ - setFile(event.target.files[0]) - } else { - setFile(undefined) - } - }*/ - - const [formInput, setFormInput] = useState(() => { - if (edit && selectedAccount) { - return { - name: selectedAccount.name, - subType: selectedAccount.subtype || "", - interest: 0, - }; - } else { - //Default value - return { - name: "", - subType: "", - interest: 0, - }; - } - }); - //const [file, setFile] = useState(undefined) - const [balance, setBalance] = useState(() => { - if (edit && selectedAccount) { - return selectedAccount.balance.toString(); - } else { - //Default value - return ""; - } - }); - - const [accountType, setAccountType] = useState( - () => { - if (edit && selectedAccount) { - console.log(selectedAccount.type); - return selectedAccount.type as typeofAccount; - } else { - //Default value - return undefined; - } - }, - ); - - const accountCat: typeofAccount[] = Object.keys( - accountCategory, - ) as typeofAccount[]; - - return ( - <> -
-
- {edit ? ( -

Edit Account

- ) : ( -

Create Account

- )} - - - -

- - - -

- - - handleCurrencyChange(event, setBalance)} - onBlur={(event) => handleCurrencyBlur(event, balance, setBalance)} - placeholder="0.00" - /> -

- - {accountType === "SAVINGS" ? ( - <> - - -

- - ) : null} - - {accountType === "CREDIT_CARD" ? ( - <> - - - - ) : null} - -
- - {edit ? ( - - ) : ( - - )} -
-
-
- - ); -} diff --git a/app/client/src/components/AccountListCard.tsx b/app/client/src/components/AccountListCard.tsx deleted file mode 100644 index 7987a6d..0000000 --- a/app/client/src/components/AccountListCard.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { deleteUserAccount } from "../api/Account.ts"; -import { useState } from "react"; -import AccountPopup from "./AccountForm.tsx"; -import { type Account } from "../types/AccountType.ts"; -import type { AuthSession } from "@/types/authTypes.ts"; -import { accountCategory } from "@/enum/AccountCategory.ts"; - -interface cardProp { - account: Account; - session: AuthSession; - setAccount: (editAccount: Account) => void; - removeAccount: (removeAccount: Account) => void; -} - -export default function Card({ - account, - session, - setAccount, - removeAccount, -}: cardProp) { - //Stores the value that toggles thhe account popup form - const [seen, setSeen] = useState(false); - - const toggle = () => { - setSeen(!seen); - }; - - //Deletes the user account from the server and then remove it from the list - const deleteAccount = () => { - try { - //Send a request to delete user account - deleteUserAccount(session, account).then((result) => { - //Sucessfully deleted account - if (result) { - removeAccount(account); - } - }); - } catch { - alert("Failed to delete account " + account.name); - } - }; - - return ( - <> -
-

{account.name}

-

- -
-

- {accountCategory[account.type as keyof typeof accountCategory] + - " " + - (account.subtype ? " " + account.subtype : "")} -

-

{Number(account.balance).toFixed(2)}

-
- -
- - -
-
- {seen ? ( - - ) : null} - - ); -} diff --git a/app/client/src/components/CSVImportModal.tsx b/app/client/src/components/CSVImportModal.tsx new file mode 100644 index 0000000..10c0553 --- /dev/null +++ b/app/client/src/components/CSVImportModal.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { AiOutlineClose } from "react-icons/ai"; +import CsvUpload from "./csvread/CsvUpload"; +// import type { AuthSession } from "@/types/authTypes"; +import type { Transaction } from "@/types/Transaction"; + +interface CSVImportModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (newTransactions: Transaction[]) => void; + accountId: number; +} + +function CSVImportModal({ + isOpen, + onClose, + onSuccess, + accountId, +}: CSVImportModalProps) { + const [importComplete, setImportComplete] = useState(false); + + if (!isOpen) return null; + + const handleImported = (newTransactions: Transaction[]) => { + onSuccess(newTransactions); + setImportComplete(true); + }; + + const handleClose = () => { + setImportComplete(false); + onClose(); + }; + + return ( +
+
+ {/* Header */} +
+

+ Import Transactions from CSV +

+ +
+ + {/* Body */} +
+ {importComplete ? ( +
+
+ ✓ Import Complete! +
+ +
+ ) : ( + + )} +
+
+
+ ); +} + +export default CSVImportModal; diff --git a/app/client/src/components/TransactionCard.tsx b/app/client/src/components/TransactionCard.tsx deleted file mode 100644 index 4e4c600..0000000 --- a/app/client/src/components/TransactionCard.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useState } from "react"; -import { deleteTransaction } from "../api/Transaction"; -import { type Transaction } from "../types/Transaction"; -import TransactionPopup from "./TransationForm"; -import type { AuthSession } from "@/types/authTypes"; - -interface cardProp { - transaction: Transaction; - session: AuthSession; - setTransaction: (editedTransaction: Transaction) => void; - removeTransaction: (removeTransaction: Transaction) => void; -} - -export default function Card({ - transaction, - session, - setTransaction, - removeTransaction, -}: cardProp) { - const [seen, setSeen] = useState(false); - - const toggle = () => { - setSeen(!seen); - }; - - //Deletes the user account from the server and then remove it from the list - const deleteTrans = () => { - try { - //Send a request to delete account's transaction - deleteTransaction(session, transaction).then((result) => { - //Sucessfully deleted transaction - if (result) { - removeTransaction(transaction); - } - }); - } catch { - alert("Failed to delete transaction " + transaction.id); - } - }; - - return ( - <> -
-

{transaction.id}

-
-

{"$" + transaction.amount.toFixed(2)}

-

{"Date:" + transaction.date}

-

{"Type: " + transaction.category}

-

-

{transaction.description}

-
- -
- - -
-
- - {seen ? ( - - ) : null} - - ); -} diff --git a/app/client/src/components/TransactionList.tsx b/app/client/src/components/TransactionList.tsx deleted file mode 100644 index 8e6fef8..0000000 --- a/app/client/src/components/TransactionList.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useEffect, useState } from "react"; -import { getTransactions } from "../api/Transaction"; -import { type Transaction } from "../types/Transaction"; -import TransactionPopup from "./TransationForm"; -import TransactionCard from "./TransactionCard"; -import type { AuthSession } from "@/types/authTypes"; -import { type Account } from "@/types/AccountType"; -import { getUserAccounts } from "@/api/Account"; -import SelectAccount from "./SelectAccount"; - -interface listProp { - session: AuthSession; -} - -export default function TransactionList({ session }: listProp) { - const [userAccounts, setUserAccounts] = useState([]); - const [selectedAccount, setSelectedAccount] = useState(0); - const [accountTransactions, setAccountTransactions] = useState( - [], - ); - const [seen, setSeen] = useState(false); - - useEffect(() => { - getUserAccounts(session) - .then((accounts) => { - console.log(accounts); - - //Detemrine accounts exist - if (accounts) { - setUserAccounts(accounts); - } else { - //alert("Failed to get accounts"); - } - }) - .catch(() => { - //alert("Failed to get accounts"); - }); - }, [session, selectedAccount]); - - //Try to get the account's transaction from the server - useEffect(() => { - if (selectedAccount) { - getTransactions(session, selectedAccount) - .then((transactions) => { - //Determine if we acquired the accounts transaction - if (transactions) { - setAccountTransactions(transactions); - } - }) - .catch(() => { - alert("Failed to get transaction"); - }); - } - }, [session, selectedAccount]); - - //Adds a account to the list - const addTransaction = (newTransaction: Transaction) => { - setAccountTransactions((userTransactions) => [ - ...userTransactions, - newTransaction, - ]); - }; - - //Removes any transaction from the list thats shares the same id - const removeTransaction = (remove: Transaction) => { - setAccountTransactions((userTransactions) => - userTransactions.filter((transaction) => transaction.id !== remove.id), - ); - }; - - const setTransaction = (editTransaction: Transaction) => { - setAccountTransactions( - accountTransactions.map((transaction) => - transaction.id === editTransaction.id - ? { ...editTransaction } - : transaction, - ), - ); - }; - - const toggle = () => { - setSeen(!seen); - }; - - return ( - <> -
-
- , - ) => setSelectedAccount(Number(event.target.value))} - /> -
- -
- {accountTransactions.map((transaction) => ( - - ))} -
- -
- -
-
- - {seen ? ( - - ) : null} - - ); -} diff --git a/app/client/src/components/TransactionTable.tsx b/app/client/src/components/TransactionTable.tsx index 011d6a1..96a1c13 100644 --- a/app/client/src/components/TransactionTable.tsx +++ b/app/client/src/components/TransactionTable.tsx @@ -11,10 +11,12 @@ import { AiOutlineSearch, AiOutlinePlus, AiOutlineClose, + AiOutlineUpload, } from "react-icons/ai"; import { FiEdit2 } from "react-icons/fi"; import NoItemState from "./NoItemState"; import type { MinimizedAccount } from "@/types/AccountType.ts"; +import CSVImportModal from "./CSVImportModal"; interface TransactionTableProps { initialLimit?: number; @@ -41,6 +43,10 @@ function TransactionTable({ }>({}); const [userAccounts, setUserAccounts] = useState([]); const [selectedAccountId, setSelectedAccountId] = useState(""); + const [showImportModal, setShowImportModal] = useState(false); + const [selectedAccountForImport, setSelectedAccountForImport] = useState< + number | null + >(null); // Fetch all transactions on component load useEffect(() => { @@ -77,6 +83,15 @@ function TransactionTable({ fetchAccounts(); }, []); + const handleImportSuccess = (newTransactions: Transaction[]) => { + setAllTransactions((prev) => [ + ...newTransactions.map((tx) => ({ ...tx, isExpanded: false })), + ...prev, + ]); + setShowImportModal(false); + setSelectedAccountForImport(null); + }; + // Client-side search const filteredTransactions = useMemo(() => { if (!searchTerm.trim()) return allTransactions; @@ -282,13 +297,45 @@ function TransactionTable({ className="w-full pl-10 pr-4 py-2 bg-black/50 border border-green-500/20 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-green-500" />
- +
+ + {/* Import CSV Modal */} +
+ +
+ {showImportModal && selectedAccountForImport && ( + { + setShowImportModal(false); + setSelectedAccountForImport(null); + }} + onSuccess={handleImportSuccess} + accountId={selectedAccountForImport} + /> + )} +
{/* Add Transaction Form */} diff --git a/app/client/src/components/TransationForm.tsx b/app/client/src/components/TransationForm.tsx deleted file mode 100644 index 6c0609d..0000000 --- a/app/client/src/components/TransationForm.tsx +++ /dev/null @@ -1,324 +0,0 @@ -import { useEffect, useState } from "react"; -import CsvUpload from "./csvread/CsvUpload"; -import "./userForm.css"; -import { getUserAccounts } from "../api/Account"; -import { validateTransactionForm } from "../utils/ValidateForms"; -import { postTranscations, putTranscations } from "../api/Transaction"; -import { handleCurrencyChange, handleCurrencyBlur } from "../utils/handleInput"; -import { type Transaction } from "../types/Transaction"; -import { type Account } from "@/types/AccountType"; -import { transactionCategory } from "@/enum/TransactionCategory"; -import type { AuthSession } from "@/types/authTypes"; -import SelectAccount from "@/components/SelectAccount"; - -interface popupProp { - toggle: () => void; - session: AuthSession; - setTransaction?: (editedTransaction: Transaction) => void; - addTransaction?: (newTransaction: Transaction) => void; - edit: boolean; - selectedTransaction?: Transaction; -} - -//Gets the keys of the enum -type typeOfTransaction = keyof typeof transactionCategory; - -//Returns a form of for the user to enter their info -export default function PopupForm({ - toggle, - session, - setTransaction, - addTransaction, - edit, - selectedTransaction, -}: popupProp) { - //Handles the submiting the form - const handleSubmit = () => { - const transferAmount = Number(amount); - let userTransaction: Transaction; - let recipient = ""; - let sender = ""; - - if ( - selectedType && - validateTransactionForm( - selectedAccount, - selectedType, - transferAmount, - selectedDate, - undefined, - ) - ) { - let target; - - if (account) { - if (selectedType === transactionCategory.INCOME) { - target = account.find( - (account) => account.id === selectedAccount, - )?.name; - if (target) { - recipient = target; - } - - sender = other; - } else { - recipient = other; - - target = account.find( - (account) => account.id === selectedAccount, - )?.name; - if (target) { - sender = target; - } - } - } - - userTransaction = { - id: 0, - financialAccount_id: Number(selectedAccount), - recipient: recipient, - sender: sender, - amount: transferAmount, - category: selectedType, - date: new Date(selectedDate), - }; - - if (edit && selectedTransaction) { - try { - //Editing selected transaction - userTransaction.id = selectedTransaction.id; - - //Send a request to update the transaction - putTranscations(session, userTransaction).then((result) => { - //Determine if we're able to able to edit the transaction - if (result && setTransaction) { - setTransaction(userTransaction); - } - }); - } catch { - alert("Failed to edit transaction"); - } - } else { - try { - //Send a request to create the transaction - postTranscations(session, userTransaction).then((response) => { - //Successful put if response is returned - if (response && response.id) { - userTransaction.id = response.id; - - if (addTransaction) { - addTransaction(userTransaction); - } - } - }); - } catch { - alert("Failed to create transaction"); - } - } - - //Need to insert function that sets the state in parent component - - toggle(); - } else { - alert("Please fill out the transaction form"); - } - }; - - //Handle when the user adds a file to the form - /*const handleFile = (event:React.ChangeEvent) =>{ - - if(event.target && event.target.files && event.target.files[0]){ - setFile(event.target.files[0]) - } else { - setFile(undefined) - } - - //Insert cvs parsing function/validation function - }*/ - - //State of the user's account - const [account, setAccount] = useState(); - - useEffect(() => { - getUserAccounts(session) - .then((accounts) => { - console.log(accounts); - //Detemrine accounts exist - if (accounts) { - setAccount(accounts); - } else { - alert( - "Failed to retrieve user's accounts, cannot make a transaction", - ); - //toggle(); - } - }) - .catch(() => { - alert("Failed to retrieve user's accounts, cannot make a transaction"); - //toggle(); - }); - }, [session]); - - //Holds state of user input - const [selectedAccount, setSelectedAccount] = useState(() => { - if (edit && selectedTransaction) { - return selectedTransaction.financialAccount_id; - } else { - return 0; - } - }); - - const [selectedType, setSelectedType] = useState(() => { - if (edit && selectedTransaction) { - return selectedTransaction.category; - } else { - return ""; - } - }); - - const [amount, setAmount] = useState(() => { - if (edit && selectedTransaction) { - return selectedTransaction.amount.toFixed(2); - } else { - return ""; - } - }); - //const [file, setFile] = useState(undefined) - - const [selectedDate, setSelectedDate] = useState(() => { - if (edit && selectedTransaction) { - return new Date(selectedTransaction.date).toISOString().slice(0, 10); - } else { - //Default - return ""; - } - }); - - const [other, setOther] = useState(() => { - if (edit && selectedTransaction) { - if (selectedType === transactionCategory.INCOME) { - return selectedTransaction.sender; - } else { - return selectedTransaction.recipient; - } - } else { - //Default - return ""; - } - }); - - //Holds the types of transfers - const transCat: typeOfTransaction[] = Object.keys( - transactionCategory, - ) as typeOfTransaction[]; - - return ( - <> -
-
- {edit ? ( -

Edit Transaction

- ) : ( -

Create Transaction

- )} - - {account && ( - , - ) => setSelectedAccount(Number(event.target.value))} - /> - )} - -

- - {selectedType == transactionCategory.INCOME ? ( - - ) : ( - - )} - setOther(event.target.value)} - > -

- - - -

- - - handleCurrencyChange(event, setAmount)} - onBlur={(event) => handleCurrencyBlur(event, amount, setAmount)} - placeholder="0.00" - /> -

- - - setSelectedDate(event.target.value)} - /> -

- - {edit ? null : ( - <> - - {!edit && selectedAccount && ( - { - if (addTransaction) { - newTxs.forEach((tx) => addTransaction(tx)); - } - }} - /> - )} - - )} -

- -
- - {edit ? ( - - ) : ( - - )} -
-
-
- - ); -} diff --git a/app/client/src/components/csvread/CsvConfirm.tsx b/app/client/src/components/csvread/CsvConfirm.tsx index fb9e52f..2edcadb 100644 --- a/app/client/src/components/csvread/CsvConfirm.tsx +++ b/app/client/src/components/csvread/CsvConfirm.tsx @@ -22,7 +22,7 @@ export default function CsvConfirmation({ rows, onConfirm, onBack }: Props) { .reduce((sum, r) => sum + (r.amount ?? 0), 0); return ( -
+

Summary

diff --git a/app/client/src/components/csvread/CsvDrop.tsx b/app/client/src/components/csvread/CsvDrop.tsx index e6a2dc4..a0c24e3 100644 --- a/app/client/src/components/csvread/CsvDrop.tsx +++ b/app/client/src/components/csvread/CsvDrop.tsx @@ -1,4 +1,5 @@ //component to upload for CSV files +// import { color } from "chart.js/helpers"; import React, { useState } from "react"; interface CsvDropProps { @@ -31,10 +32,10 @@ export default function CsvDrop({ onFileSelect }: CsvDropProps) { background: isDragging ? "#eef" : "#fafafa", cursor: "pointer", marginBottom: "1rem", + color: "#888", }} > -

Drag & drop your CSV here, or click to select

- n +

Drag & drop your CSV here, or click to select a file

+
Total rows: {rows.length}
Valid rows: {validCount}
diff --git a/app/client/src/components/csvread/CsvUpload.tsx b/app/client/src/components/csvread/CsvUpload.tsx index 3fe42d1..97de7c6 100644 --- a/app/client/src/components/csvread/CsvUpload.tsx +++ b/app/client/src/components/csvread/CsvUpload.tsx @@ -10,16 +10,16 @@ import { parseCsvFile } from "../../utils/ParseCsv"; import type { TransactionDraft } from "../../utils/ConvertTransaction"; import SuccessScreen from "./csvSuccess"; import { uploadCsvTransactions } from "@/api/Transaction"; -import type { AuthSession } from "@/types/authTypes"; +// import type { AuthSession } from "@/types/authTypes"; import type { Transaction } from "@/types/Transaction"; export default function CsvUpload({ accountId, - session, + // session, onImported, }: { accountId: number; - session: AuthSession; + // session: AuthSession; onImported: (txs: Transaction[]) => void; }) { const [file, setFile] = useState(null); // selected CSV file @@ -104,7 +104,7 @@ export default function CsvUpload({ try { const response = await uploadCsvTransactions( - session, + // session, accountId, parsedData, ); @@ -129,7 +129,7 @@ export default function CsvUpload({ }; return ( -
+
{importResult && (
@@ -466,13 +472,22 @@ function AccountList({ session }: AccountListProps) { Balance - handleFieldChange( + handleListCurrencyChange( + e, + handleFieldChange, + account.id, + "balance", + ) + } + onBlur={(e) => + handleListCurrencyBlur( + e, + handleFieldChange, account.id, "balance", - parseFloat(e.target.value), ) } className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" diff --git a/app/client/src/enum/TransactionCategory.ts b/app/client/src/enum/TransactionCategory.ts index 0dfb57f..6dcca84 100644 --- a/app/client/src/enum/TransactionCategory.ts +++ b/app/client/src/enum/TransactionCategory.ts @@ -3,6 +3,6 @@ export const transactionCategory = { HOUSING: "Housing", UTIL: "Utilzities", TRANSPORTATION: "Transportation", - INCOME: "INCOME", + INCOME: "Income", OTHER: "Other", }; diff --git a/app/client/src/utils/handleInput.ts b/app/client/src/utils/handleInput.ts index 0b0a071..4b79e95 100644 --- a/app/client/src/utils/handleInput.ts +++ b/app/client/src/utils/handleInput.ts @@ -6,10 +6,8 @@ export const handleCurrencyChange = ( setCurrency: React.Dispatch>, ): void => { let input = event.target.value; - const pattern = /^\d*\.?\d{0,2}$/; + const pattern = /^-?\d*\.?\d{0,2}$/; - console.log(input); - console.log(pattern.test(input)); //Determine if the input follows the format/pattern if (pattern.test(input)) { input = input.replace(/^0+(?=\d)/, ""); @@ -57,3 +55,39 @@ export const handleNumberChange = ( setNumber(changeNumber.toString()); } }; + +export const handleListCurrencyChange = ( + event: React.ChangeEvent, + handleChange: ( + accountId: number, + field: string, + value: string | number, + ) => void, + id: number, + field: string, +) => { + let input = event.target.value; + const pattern = /^-?\d*\.?\d{0,2}$/; + + console.log(pattern.test(input)); + + //Determine if the input follows the format/pattern + if (pattern.test(input)) { + input = input.replace(/^0+(?=\d)/, ""); + handleChange(id, field, input); + } +}; +export const handleListCurrencyBlur = ( + event: React.ChangeEvent, + handleChange: ( + accountId: number, + field: string, + value: string | number, + ) => void, + id: number, + field: string, +) => { + if (event.target.value !== "") { + handleChange(id, field, parseFloat(event.target.value).toFixed(2)); + } +}; From efb72cc6508e8114bb0203d41fb06c9709e937f6 Mon Sep 17 00:00:00 2001 From: Jackie Mei Date: Wed, 1 Apr 2026 18:35:52 -0500 Subject: [PATCH 12/19] Turned the sub type input while editing to a selector --- app/client/src/components/AccountList.tsx | 61 ++++++++++++++++------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/app/client/src/components/AccountList.tsx b/app/client/src/components/AccountList.tsx index dc4c4f4..2b8f3c1 100644 --- a/app/client/src/components/AccountList.tsx +++ b/app/client/src/components/AccountList.tsx @@ -448,25 +448,48 @@ function AccountList({ session }: AccountListProps) {
-
- - - handleFieldChange( - account.id, - "subtype", - e.target.value, - ) - } - placeholder="Optional" - className="w-full bg-black/50 border border-green-500/30 rounded px-3 py-2 text-white text-sm" - onClick={(e) => e.stopPropagation()} - /> -
+ + {account.subtype && ( +
+ + {account.type === "savings" && ( + + )} + {account.type === "credit_card" && ( + + )} +
+ )}