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/App.tsx b/app/client/src/App.tsx index 9892c57..c13df41 100644 --- a/app/client/src/App.tsx +++ b/app/client/src/App.tsx @@ -199,7 +199,6 @@ function App() { } /> - {session && ( }> )} - + } /> {/**Code below is only used for dashboard development purposes */} diff --git a/app/client/src/api/Account.ts b/app/client/src/api/Account.ts index df93723..417984c 100644 --- a/app/client/src/api/Account.ts +++ b/app/client/src/api/Account.ts @@ -1,129 +1,83 @@ import type { updateResponse } from "../types/responseTypes"; import type { Account } from "../types/AccountType"; -import type { AuthSession } from "@/types/authTypes"; -import { BASE_URL } from "@/utils/constants"; -const requestUrl = `${BASE_URL}/api/accounts`; +import { instance } from "./config"; +// import { data } from "react-router-dom"; //Sends a request to get different accounts the user has -export async function getUserAccounts( - session: AuthSession, - type?: string, -): Promise { - let url = requestUrl; - - //Determine if we're targetting a specific type - if (type) { - url = `${requestUrl}/?type=${type}`; - } - +export async function getUserAccounts(type?: string): Promise { try { - //Sends a http request and waits for a response - const response = await fetch(url, { - method: "GET", - headers: { Authorization: `Bearer ${session.token}` }, - }); + const response = await instance.get( + `/api/accounts${type ? `?type=${type}` : ""}`, + ); - //Determine if we were able to retrieve user's data - if (!response.ok) { - //Failed to retrieve user data, return empty array - console.error( - "Error: Failed to retrieve users accounts ", - response.status, - ); - return []; + //determine if we were able to retrieve user's data + if (response.status === 200 || response.status === 201) { + // console.log("Account created:", response.data); + return response.data; + } else { + console.error("Failed to create account", response.status); + throw new Error(`Failed to create account: ${response.statusText}`); } - - return response.json(); } catch (error) { - console.error(error); + console.error("Error creating account:", error); throw error; } } //Sends a post request to create a user account export async function postUserAccount( - session: AuthSession, newAccount: Account, ): Promise { try { - //Create post request and wait for response - const response = await fetch(requestUrl, { - method: "POST", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${session.token}`, - }, - body: JSON.stringify(newAccount), - }); - - //Determine if our post was a success - if (response.ok) { - alert("Account " + newAccount.name + " has been created"); + const response = await instance.post(`/api/accounts`, newAccount); + if (response.status === 200 || response.status === 201) { + console.log("Account created:", response.data); + return newAccount; } else { - alert("Failed to create account " + newAccount.name); - console.error(response.status); + console.error("Failed to create account", response.status); + throw new Error(`Failed to create account: ${response.statusText}`); } - - return response.json(); } catch (error) { - console.error(error); + console.error("Error creating account:", error); throw error; } } //Updates the users account export async function putUserAccount( - session: AuthSession, newAccount: Account, ): Promise { try { - //Create post request and wait for response - const response = await fetch(requestUrl, { - method: "PUT", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${session.token}`, - }, - body: JSON.stringify(newAccount), - }); + const response = await instance.put(`/api/accounts`, newAccount); - //Determine if our post was a success - if (response.ok) { - alert("Account " + newAccount.name + " has been updated"); + if (response.status === 200) { + console.log("Account updated:", response.data); + return response.data; } else { - alert("Failed to update account " + newAccount.name); - console.error(response.status); + console.error("Failed to update account", response.status); + throw new Error(`Failed to update account: ${response.statusText}`); } - - return response.json(); } catch (error) { - console.error(error); + console.error("Error updating account:", error); throw error; } } -export async function deleteUserAccount( - session: AuthSession, - userAccount: Account, -) { - const content = JSON.stringify({ id: userAccount.id }); - //Create delete request to delete the account - const response = await fetch(requestUrl, { - method: "DELETE", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${session.token}`, - }, - body: content, - }); +export async function deleteUserAccount(accountId: number): Promise { + try { + const id = Number(accountId); + const response = await instance.delete(`/api/accounts?id=${id}`); - if (response.ok) { - alert("Account " + userAccount.name + " has been deleted"); - } else { - alert("Failed to delete account " + userAccount.name); - console.error(response.status); + if (response.status === 200) { + console.log("Account deleted successfully"); + return true; + } else { + console.error("Failed to delete account", response.status); + return false; + } + } catch (error) { + console.error("Error deleting account:", error); + return false; } - - return response.ok; } diff --git a/app/client/src/api/DashboardAPI.ts b/app/client/src/api/DashboardAPI.ts index 076d83e..b17285c 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 { Account, MinimizedAccount } from "@/types/AccountType"; async function getTransactions(): Promise { try { @@ -19,15 +20,21 @@ 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"], + account_name: response.data[i]["account_name"], }); } @@ -38,6 +45,98 @@ 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.patch( + `/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 || !Array.isArray(response.data)) { + return null; + } + + //directly map the array + const output: MinimizedAccount[] = response.data.map( + (account: MinimizedAccount) => ({ + 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"); @@ -138,7 +237,6 @@ async function getSavingsContribChartData( ); } const response = await instance.get(`/charts/savings?period=${period}`); - console.log("received savings data", response); if (response.status !== 200) { throw new Error( `Failed to fetch savings contribution chart data: ${response.statusText}`, @@ -192,6 +290,90 @@ async function getIncomeFlowChartData( } } +export async function getUserAccounts(): Promise { + try { + const response = await instance.get(`/api/accounts`); + + if (response.status !== 200) { + throw new Error(`Failed to fetch accounts: ${response.statusText}`); + } + if (!response.data) { + return null; + } + + const output: Account[] = []; + for (let i = 0; i < response.data.length; i++) { + output.push({ + id: response.data[i]["id"], + name: response.data[i]["name"], + type: response.data[i]["type"], + balance: response.data[i]["balance"], + value: response.data[i]["value"] || response.data[i]["balance"], + subtype: response.data[i]["subtype"], + last_updated: response.data[i]["last_updated"], + }); + } + + return output; + } catch (error) { + console.error("Error fetching accounts:", error); + throw error; + } +} + +//create a new account +export async function createAccount( + accountData: Omit, +): Promise { + try { + const response = await instance.post(`/api/accounts`, accountData); + + if (response.status !== 200) { + throw new Error(`Failed to create account: ${response.statusText}`); + } + + return response.data; + } catch (error) { + console.error("Error creating account:", error); + throw error; + } +} + +//update an existing account +export async function updateAccount( + accountData: Partial & { id: number }, +): Promise { + try { + const response = await instance.put( + `/api/accounts/${accountData.id}`, + accountData, + ); + + if (response.status !== 200) { + throw new Error(`Failed to update account: ${response.statusText}`); + } + + return response.data; + } catch (error) { + console.error("Error updating account:", error); + throw error; + } +} + +//delete an account +export async function deleteAccount(accountId: number): Promise { + try { + const response = await instance.delete(`/api/accounts/${accountId}`); + + if (response.status !== 200) { + throw new Error(`Failed to delete account: ${response.statusText}`); + } + } catch (error) { + console.error("Error deleting account:", error); + throw error; + } +} + export { getBudgetWithExpenditure, getTransactions, 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/api/GoalsAPI.ts b/app/client/src/api/GoalsAPI.ts index c9cbb9a..4a024d0 100644 --- a/app/client/src/api/GoalsAPI.ts +++ b/app/client/src/api/GoalsAPI.ts @@ -2,23 +2,19 @@ import type { Goal } from "../types/goals.ts"; import { instance } from "./config"; async function fetchGoals(): Promise { - console.log("fetching goals"); const response = await instance.get("/goals"); if (response.status !== 200) { throw new Error(`Failed to fetch goals data: ${response.statusText}`); } - console.log("returning a list of goals: ", response.data); return response.data; } async function createGoal(goal: Partial): Promise { - console.log("creating goal: ", goal); const response = await instance.post("/goals", goal); if (response.status !== 201) { // 201 Created is standard for POST throw new Error(`Failed to create goal: ${response.statusText}`); } - console.log("got response from create goal: ", response.data); return response.data; } @@ -31,12 +27,10 @@ async function updateGoal( throw new Error(`Failed to edit goal: ${response.statusText}`); } - // console.log("updated goal: ", response.data); return response.data; } async function deleteGoal(goalId: string): Promise { - // console.log("deleting goal: ", goalId); const response = await instance.delete(`/goals?gid=${goalId}`); if (response.status !== 204) { // 204 No Content is standard for DELETE 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/api/Transaction.ts b/app/client/src/api/Transaction.ts index 2eed076..eff3752 100644 --- a/app/client/src/api/Transaction.ts +++ b/app/client/src/api/Transaction.ts @@ -1,125 +1,13 @@ -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"; -import { BASE_URL } from "@/utils/constants"; +import { instance } from "./config"; -const requestUrl = `${BASE_URL}/api/transactions`; - -//Sends a GET request to get the list of user transactions for the account -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 -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; - } -} // POST /api/transactions/csvTransaction export async function uploadCsvTransactions( - session: AuthSession, + // session: AuthSession, financialAccount_id: number, transactions: TransactionDraft[], ): Promise<{ @@ -127,14 +15,9 @@ 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 instance.post( + `/api/transactions/csvTransaction`, + JSON.stringify({ financialAccount_id, transactions }), + ); + return response.data; } diff --git a/app/client/src/assets/react.svg b/app/client/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/app/client/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/client/src/components/AccountForm.tsx b/app/client/src/components/AccountForm.tsx deleted file mode 100644 index 7ee8513..0000000 --- a/app/client/src/components/AccountForm.tsx +++ /dev/null @@ -1,283 +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 === "SAVING" || accountType === "CREDIT_CARD") && - formInput.subType - ) { - subtype = formInput.subType; - } - - if (accountType === accountCategory.SAVING) { - 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/AccountList.tsx b/app/client/src/components/AccountList.tsx index 34d2530..2b8f3c1 100644 --- a/app/client/src/components/AccountList.tsx +++ b/app/client/src/components/AccountList.tsx @@ -1,86 +1,553 @@ -import { useEffect, useState } from "react"; -import { getUserAccounts } from "../api/Account"; +import { useEffect, useState, useMemo } from "react"; +import { + getUserAccounts, + postUserAccount, + putUserAccount, + deleteUserAccount, +} from "../api/Account"; import { type Account } from "../types/AccountType"; -import AccountListCard from "./AccountListCard"; -import AccountPopup from "./AccountForm"; +import { AiOutlinePlus, AiOutlineClose, AiOutlineSearch } from "react-icons/ai"; +import { FiEdit2 } from "react-icons/fi"; +import NoItemState from "./NoItemState"; import type { AuthSession } from "@/types/authTypes"; -import "./userForm.css"; -interface listProp { +import { + handleCurrencyBlur, + handleCurrencyChange, + handleListCurrencyChange, + handleListCurrencyBlur, +} from "@/utils/handleInput"; + +interface AccountListProps { session: AuthSession; } -export default function AccountList({ session }: listProp) { - //Stores a lits of the user's account - const [userAccounts, setUserAccounts] = useState([]); +interface EditableAccount extends Account { + isExpanded?: boolean; +} - const [seen, setSeen] = useState(false); +function AccountList({ session }: AccountListProps) { + const [accounts, setAccounts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [showAddForm, setShowAddForm] = useState(false); + const [editingValues, setEditingValues] = useState<{ + [key: number]: Partial; + }>({}); - //Try to get the accounts from the server + // Fetch accounts on mount useEffect(() => { - try { - getUserAccounts(session).then((accounts) => { - //Determine if we acquired the accounts - if (accounts) { - console.log(accounts); - setUserAccounts(accounts); - } - }); - } catch { - alert("Failed to retrieve user accounts"); - } + const fetchAccounts = async () => { + try { + setIsLoading(true); + const data = await getUserAccounts(); + setAccounts((data || []).map((acc) => ({ ...acc, isExpanded: false }))); + } catch (error) { + console.error("Error fetching accounts:", error); + } finally { + setIsLoading(false); + } + }; + fetchAccounts(); }, [session]); - //Adds a account to the list - const addAccount = (newAccount: Account) => { - setUserAccounts((userAccounts) => [...userAccounts, newAccount]); + // Filter accounts based on search + const filteredAccounts = useMemo(() => { + if (!searchTerm.trim()) return accounts; + const term = searchTerm.toLowerCase(); + return accounts.filter( + (acc) => + acc.name.toLowerCase().includes(term) || + acc.type.toLowerCase().includes(term) || + (acc.subtype && acc.subtype.toLowerCase().includes(term)), + ); + }, [accounts, searchTerm]); + + // format helpers + const formatCurrency = (amount: number | string): string => { + //convert to number if it's a string + const numAmount = typeof amount === "string" ? parseFloat(amount) : amount; + + //check if conversion was successful + if (isNaN(numAmount)) { + return "$0.00"; + } + + if (numAmount < 0) { + return `-$${Math.abs(numAmount).toFixed(2)}`; + } + return `$${numAmount.toFixed(2)}`; }; - //Removes any account from the list thats shares the same id - const removeAccount = (remove: Account) => { - setUserAccounts((userAccounts) => - userAccounts.filter((account) => account.id !== remove.id), - ); + const formatDate = (dateStr: string): string => { + if (!dateStr) return "N/A"; + return new Date(dateStr).toLocaleDateString(); }; - const setAccount = (editAccount: Account) => { - setUserAccounts( - userAccounts.map((account) => - account.id === editAccount.id ? { ...editAccount } : account, + // expand/collapse row + const toggleExpand = (accountId: number) => { + setAccounts((prev) => + prev.map((acc) => + acc.id === accountId ? { ...acc, isExpanded: !acc.isExpanded } : acc, ), ); + + const account = accounts.find((a) => a.id === accountId); + if (account && !editingValues[accountId]) { + setEditingValues((prev) => ({ + ...prev, + [accountId]: { + name: account.name, + type: account.type, + balance: account.balance, + subtype: account.subtype, + }, + })); + } + }; + + // handle field changes + const handleFieldChange = ( + accountId: number, + field: string, + value: string | number, + ) => { + setEditingValues((prev) => ({ + ...prev, + [accountId]: { + ...prev[accountId], + [field]: value, + }, + })); + }; + + // save edited account + const handleSaveEdit = async (accountId: number) => { + const updates = editingValues[accountId]; + if (!updates) return; + + try { + const updatedAccount = await putUserAccount({ + ...updates, + balance: Number(updates.balance), + id: accountId, + } as Account); + if (updatedAccount) { + setAccounts((prev) => + prev.map((acc) => + acc.id === accountId + ? { ...acc, ...updates, isExpanded: false } + : acc, + ), + ); + setEditingValues((prev) => { + const newState = { ...prev }; + delete newState[accountId]; + return newState; + }); + } + } catch (error) { + console.error("Failed to update account:", error); + alert("Failed to update account"); + } + }; + + // delete account + const handleDelete = async (accountId: number) => { + if ( + !confirm( + "Are you sure you want to delete this account? This will also delete all associated transactions.", + ) + ) + return; + + try { + const accountToDelete = accounts.find((a) => a.id === accountId); + if (accountToDelete) { + const success = await deleteUserAccount(accountToDelete.id); + if (success) { + setAccounts((prev) => prev.filter((acc) => acc.id !== accountId)); + } + } + } catch (error) { + console.error("Failed to delete account:", error); + alert("Failed to delete account"); + } }; - const toggle = () => { - setSeen(!seen); + // new account state + const [newAccount, setNewAccount] = useState({ + name: "", + type: "", + subtype: "", + }); + + const [newBalance, setNewBalance] = useState(""); + + const handleAddAccount = async () => { + if (!newAccount.name || !newAccount.type) { + alert("Please fill in required fields (name and type)"); + return; + } + + try { + const balance = Number(newBalance); + const created = await postUserAccount({ + id: 0, + name: newAccount.name, + type: newAccount.type, + subtype: newAccount.subtype || null, + balance: balance, + value: balance, + last_updated: new Date().toISOString(), + } as Account); + + setAccounts( + (prev) => + [{ ...created, isExpanded: false }, ...prev] as EditableAccount[], + ); + setNewAccount({ name: "", type: "", subtype: "" }); + setNewBalance(""); + setShowAddForm(false); + } catch (error) { + console.error("Failed to create account:", error); + alert("Failed to create account"); + } }; + if (isLoading) { + return ( +
+
Loading accounts...
+
+ ); + } + + if (accounts.length === 0 && !showAddForm) { + return ( +
+ 💰
} + /> +
+ +
+ + ); + } + 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" && ( + + )} + handleCurrencyChange(e, setNewBalance)} + onBlur={(e) => handleCurrencyBlur(e, newBalance, setNewBalance)} + 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()} + /> +
+
+ + +
+ + {account.subtype && ( +
+ + {account.type === "savings" && ( + + )} + {account.type === "credit_card" && ( + + )} +
+ )} +
+ + + handleListCurrencyChange( + e, + handleFieldChange, + account.id, + "balance", + ) + } + onBlur={(e) => + handleListCurrencyBlur( + e, + handleFieldChange, + account.id, + "balance", + ) + } + 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/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/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/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 3070169..98af1de 100644 --- a/app/client/src/components/TransactionTable.tsx +++ b/app/client/src/components/TransactionTable.tsx @@ -1,115 +1,715 @@ -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, 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, + 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"; -function TransactionTable() { - const [transactions, setTransactions] = useState(null); +interface TransactionTableProps { + initialLimit?: number; + loadMoreIncrement?: number; +} + +// interface EditableTransaction extends Transaction { +// } +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 [userAccounts, setUserAccounts] = useState([]); + const [selectedAccountId, setSelectedAccountId] = useState(""); + const [showImportModal, setShowImportModal] = useState(false); + const [selectedAccountForImport, setSelectedAccountForImport] = useState< + number | null + >(null); + const [expandedRowId, setExpandedRowId] = useState(null); + + // Fetch 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 || []); } catch (error) { console.error("Error fetching transactions:", error); + } finally { + setIsLoading(false); } }; fetchTransactions(); }, []); - const noTransactionFound = ( - } - /> - ); + // Fetch accounts for dropdown + useEffect(() => { + const fetchAccounts = async () => { + try { + const accounts = await getAccountIdsForUser(); + if (accounts && accounts.length > 0) { + setUserAccounts(accounts); + setSelectedAccountId(accounts[0].id); + } + } catch (error) { + console.error("Error fetching accounts:", error); + } + }; + fetchAccounts(); + }, []); + + const handleImportSuccess = (newTransactions: Transaction[]) => { + setAllTransactions((prev) => [...newTransactions, ...prev]); + setShowImportModal(false); + setSelectedAccountForImport(null); + }; + + //helper function for date formatting + const formatDateForInput = (dateStr: string): string => { + if (!dateStr) return ""; + // If it's already YYYY-MM-DD, return as is + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr; + // Otherwise parse and format + return new Date(dateStr).toISOString().split("T")[0]; + }; + + const handleRowClick = (transactionId: number) => { + const isExpanding = expandedRowId !== transactionId; + setExpandedRowId(isExpanding ? transactionId : null); + + // Initialize editing values when expanding + if (isExpanding) { + 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 || "", + }, + })); + } + } + }; + + // client-side search + 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(" "); + }; + + // handle field changes in expanded edit form + 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; + + try { + const success = await updateTransaction({ + id: transactionId, + ...updates, + } as Transaction); + + if (!success) throw new Error("Update failed"); + + const isExpanding = expandedRowId !== transactionId; + setExpandedRowId(isExpanding ? transactionId : null); + + const freshTransactions = await getTransactions(); + setAllTransactions(freshTransactions || []); + + setEditingValues((prev) => { + const newState = { ...prev }; + delete newState[transactionId]; + return newState; + }); + + // close the expanded row + setExpandedRowId(null); + } 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 state + const [newTransaction, setNewTransaction] = useState>({ + date: new Date().toISOString().split("T")[0], + description: "", + category: "", + amount: 0, + sender: "", + recipient: "", + }); + + const handleAddTransaction = async () => { + if ( + !newTransaction.date || + !newTransaction.category || + newTransaction.amount === undefined + ) { + alert("Please fill in required fields (date, category, amount)"); + return; + } - const transactionTable = - transactions && transactions.length > 0 ? ( -
+ if (!selectedAccountId) { + alert("Please select an account"); + return; + } + + try { + const created = await createTransaction({ + ...newTransaction, + financialAccount_id: selectedAccountId as number, + } as Transaction); + + const selectedAccount = userAccounts.find( + (acc) => acc.id === selectedAccountId, + ); + + const transactionWithAccountName = { + ...created, + account_name: selectedAccount?.name || "Unknown", + }; + + setAllTransactions((prev) => [transactionWithAccountName, ...prev]); + setNewTransaction({ + date: new Date().toISOString().split("T")[0], + description: "", + category: "", + amount: 0, + sender: "", + recipient: "", + }); + setSelectedAccountId(userAccounts[0]?.id || ""); + setShowAddForm(false); + + if (expandedRowId !== null) { + setExpandedRowId(null); + } + } 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" + /> +
+
+ + {/* Import CSV Modal */} +
+ +
+ {showImportModal && selectedAccountForImport && ( + { + setShowImportModal(false); + setSelectedAccountForImport(null); + }} + onSuccess={handleImportSuccess} + accountId={selectedAccountForImport} + /> + )} +
+
+ + {/* 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 */} -
+
Date
Description
Category
Amount
+
Account
From
To
+
{/* Body */}
- {transactions.map((tx, index) => ( -
- {/* Date */} -
- {new Date(tx.date).toLocaleDateString()} -
+ {displayedTransactions.map((tx, index) => { + const isExpanded = expandedRowId === tx.id; + const editValues = editingValues[tx.id] || {}; + const currentAccountId = + editValues.financialAccount_id || + userAccounts.find((acc) => acc.name === tx.account_name)?.id || + ""; - {/* Description */} -
- {tx.description || "N/A"} -
+ return ( +
+ {/* Main Row */} +
handleRowClick(tx.id)} + > +
+ {formatDateForInput(tx.date)} +
+
+ {tx.description || "N/A"} +
+
+ {formatCategoryLabel(tx.category)} +
+
+ {formatAmount(tx.amount)} +
+
+ {tx.account_name || "Unknown Account"} +
+
+ {tx.sender || "-"} +
+
+ {tx.recipient || "-"} +
+
+ +
+
- {/* Category */} -
- {formatCategoryLabel(tx.category)} -
- - {/* Amount */} -
- {tx.amount < 0 - ? `-$${Math.abs(tx.amount).toFixed(2)}` - : `$${tx.amount.toFixed(2)}`} -
+ {/* 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()} + /> +
- {/* From */} -
{tx.sender}
+ {/* Account Selector for Edit Form */} +
+ + +
- {/* To */} -
{tx.recipient}
-
- ))} +
+ + + 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()} + /> +
+
+
+ + +
+
+ )} +
+ ); + })}
- ) : 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/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 && ( - )); + 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)} - /> - - - handleCurrencyChange(event, setMinPay)} - onBlur={(event) => handleCurrencyBlur(event, minPay, setMinPay)} - /> - -
- -
- {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} +
); diff --git a/app/client/src/types/AccountType.ts b/app/client/src/types/AccountType.ts index 0fcbbed..341d9b1 100644 --- a/app/client/src/types/AccountType.ts +++ b/app/client/src/types/AccountType.ts @@ -6,5 +6,10 @@ export interface Account { balance: number; value: number; subtype?: string; - last_updated: Date; + last_updated: string; +} + +export interface MinimizedAccount { + id: number; + name: string; } 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/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)); + } +}; diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml index 31b564a..0dca070 100644 --- a/app/server/docker-compose.test.yml +++ b/app/server/docker-compose.test.yml @@ -52,3 +52,17 @@ services: - ./default_container.env environment: - JWT_SECRET + + load-test: + image: grafana/k6:0.49.0 + # Mount the k6 scripts read-only so the same load test can be run + # either through Docker or locally with a host-installed k6 binary. + volumes: + - ./load-tests:/scripts:ro + environment: + - LOAD_TEST_BASE_URL=http://api-gateway:8000 + - LOAD_TEST_USERS=20 + - REQUESTS_PER_USER_PER_MINUTE=10 + - LOAD_TEST_DURATION=2m + command: run /scripts/api-gateway-capacity.js + diff --git a/app/server/load-tests/README.md b/app/server/load-tests/README.md new file mode 100644 index 0000000..7cb5ef7 --- /dev/null +++ b/app/server/load-tests/README.md @@ -0,0 +1,25 @@ +# Load Testing + +This folder contains the `k6` capacity test for the backend. + +The main script is `api-gateway-capacity.js`. + +It validates the course capacity requirement by simulating: + +- `20` concurrent users +- `200` total requests per minute + +How it works: + +- Each virtual user signs up and logs in with a unique test account. +- Each virtual user creates one account and one starter transaction. +- After setup, each virtual user sends `10` authenticated requests per minute. +- `20` users x `10` requests per minute = `200` requests per minute total. + +How to run it: + +```bash +cd app/server +docker compose up -d +docker compose -f docker-compose.test.yml up load-test --abort-on-container-failure +``` diff --git a/app/server/load-tests/api-gateway-capacity.js b/app/server/load-tests/api-gateway-capacity.js new file mode 100644 index 0000000..c0aedc2 --- /dev/null +++ b/app/server/load-tests/api-gateway-capacity.js @@ -0,0 +1,221 @@ +/* + Load testing script + + It is designed to be run either: + 1. through Docker using the command from app/server: + docker compose up -d + docker compose -f docker-compose.yml -f docker-compose.test.yml up load-test --abort-on-container-failure. + 2. Using k6 which needs to be installed first then run with from app/server/load-tests: + k6.exe run api-gateway-capacity.js + + Performance targets: + 20 concurrent users, each sending 10 requests per minute. + 20 users * 10 requests/minute = 200 requests/minute total. + + */ + +import http from "k6/http"; +import { check, sleep } from "k6"; +import { Counter } from "k6/metrics"; + +const CONCURRENT_USERS = Number(__ENV.LOAD_TEST_USERS || 20); +const REQUESTS_PER_USER_PER_MINUTE = Number( + __ENV.REQUESTS_PER_USER_PER_MINUTE || 10, +); +const TEST_DURATION = __ENV.LOAD_TEST_DURATION || "2m"; +const API_BASE_URL = __ENV.LOAD_TEST_BASE_URL || "http://localhost:3000"; +const EMAIL_RUN_ID = __ENV.LOAD_TEST_RUN_ID || Date.now().toString(); + +// Each iteration sends exactly one authenticated request, so sleeping for +// 60 / 10 = 6 seconds keeps one user at 10 requests per minute. +const REQUEST_INTERVAL_SECONDS = 60 / REQUESTS_PER_USER_PER_MINUTE; + +// Count successful business requests so the summary clearly shows whether +// the server handled the intended request volume successfully. +const successfulRequests = new Counter("successful_requests"); + +export const options = { + scenarios: { + capacity_requirement: { + executor: "constant-vus", + vus: CONCURRENT_USERS, + duration: TEST_DURATION, + }, + }, + thresholds: { + // The capacity test should be stable; failures indicate the system + // cannot reliably sustain the configured user/request volume. + http_req_failed: ["rate<0.01"], + checks: ["rate>0.99"], + }, + summaryTrendStats: ["avg", "min", "med", "p(90)", "p(95)", "max"], +}; + +let authToken; +let accountId; +let transactionSequence = 0; + +function buildUserIdentity(userNumber) { + const suffix = `${EMAIL_RUN_ID}_${userNumber}`; + return { + username: `load_user_${suffix}`, + email: `load_user_${suffix}@finus.test`, + password: "LoadTestPass123!", + first_name: "Load", + last_name: `User${userNumber}`, + age: 30, + }; +} + +function signupAndLoginForUser(userNumber) { + const user = buildUserIdentity(userNumber); + + // Signup provisions a real user/profile pair in the database so each + // user exercises the same auth and persistence flow as production. + const signupResponse = http.post( + `${API_BASE_URL}/api/signup`, + JSON.stringify(user), + { + headers: { "Content-Type": "application/json" }, + tags: { endpoint: "signup" }, + }, + ); + + // Re-runs should still work if the same run id is reused and the account + // already exists, so we accept the duplicate-account response as setup-safe. + check(signupResponse, { + "signup succeeded or already existed": (response) => + response.status === 201 || response.status === 409, + }); + + const loginResponse = http.post( + `${API_BASE_URL}/api/login`, + JSON.stringify({ + email: user.email, + password: user.password, + }), + { + headers: { "Content-Type": "application/json" }, + tags: { endpoint: "login" }, + }, + ); + + check(loginResponse, { + "login returned token": (response) => + response.status === 200 && Boolean(response.json("token")), + }); + + authToken = loginResponse.json("token"); +} + +function createAccountForUser(userNumber) { + // Each user creates one account once so read endpoints have + // meaningful user-owned data to query during the steady-state load. + const accountResponse = http.post( + `${API_BASE_URL}/api/accounts`, + JSON.stringify({ + id: 0, + name: `Load Checking ${EMAIL_RUN_ID}_${userNumber}`, + type: "CHEQUING", + balance: 1000, + value: 1000, + last_updated: new Date().toISOString(), + }), + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + tags: { endpoint: "create_account" }, + }, + ); + + check(accountResponse, { + "account created": (response) => + response.status === 200 || response.status === 201, + }); + + accountId = accountResponse.json("id"); +} + +function createTransactionForUser(userNumber) { + transactionSequence += 1; + + const transactionResponse = http.post( + `${API_BASE_URL}/api/transactions`, + JSON.stringify({ + id: 0, + financialAccount_id: accountId, + amount: 25 + transactionSequence, + description: `Load test transaction ${transactionSequence}`, + sender: `Employer_${userNumber}`, + recipient: `Load_User_${userNumber}`, + date: new Date().toISOString(), + category: "INCOME", + }), + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + tags: { endpoint: "create_transaction" }, + }, + ); + + const requestPassed = check(transactionResponse, { + "transaction created": (response) => + response.status === 200 || response.status === 201, + }); + + if (requestPassed) { + successfulRequests.add(1); + } +} + +function ensureUserIsInitialized() { + if (authToken && accountId) { + return; + } + + const userNumber = __VU; + signupAndLoginForUser(userNumber); + createAccountForUser(userNumber); + createTransactionForUser(userNumber); +} + +function authenticatedGet(path, endpointTag) { + const response = http.get(`${API_BASE_URL}${path}`, { + headers: { Authorization: `Bearer ${authToken}` }, + tags: { endpoint: endpointTag }, + }); + + const requestPassed = check(response, { + [`${endpointTag} returned 200`]: (res) => res.status === 200, + }); + + if (requestPassed) { + successfulRequests.add(1); + } + + return response; +} + +export default function () { + ensureUserIsInitialized(); + + // Rotate endpoints so the load is spread across the gateway and the user + // service instead of repeatedly hitting a single route. + const routeIndex = __ITER % 3; + if (routeIndex === 0) { + authenticatedGet("/api/accounts", "accounts_list"); + } else if (routeIndex === 1) { + authenticatedGet( + `/api/transactions?financialAccount_id=${accountId}`, + "transaction_list", + ); + } else { + createTransactionForUser(__VU); + } + + sleep(REQUEST_INTERVAL_SECONDS); +} 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 diff --git a/app/server/services/ts/api-gateway/src/index.ts b/app/server/services/ts/api-gateway/src/index.ts index 5ba37c1..092c564 100644 --- a/app/server/services/ts/api-gateway/src/index.ts +++ b/app/server/services/ts/api-gateway/src/index.ts @@ -36,9 +36,11 @@ registerProxy(process.env.USER_SERVICE_ADDR, [ "/profiles", "/goals", "/debts", + "/savings", "/transactions", "/charts/expenses", "/table/transactions", + "/table/transactions/accounts", "/table/snapshot", "/transactions/csvTransaction", ]); 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/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/integration/accounts.test.ts b/app/server/services/ts/user/integration/accounts.test.ts index 00743ea..53b02b6 100644 --- a/app/server/services/ts/user/integration/accounts.test.ts +++ b/app/server/services/ts/user/integration/accounts.test.ts @@ -81,9 +81,8 @@ describe("Accounts Integration (Docker)", () => { it("deletes an account", async () => { const res = await request(BASE_URL) - .delete("/api/accounts") - .set("Authorization", `Bearer ${token}`) - .send({ id: accountId }); + .delete(`/api/accounts?id=${accountId}`) + .set("Authorization", `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.message).toBe("Account successfully deleted"); 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/index.ts b/app/server/services/ts/user/src/index.ts index f968850..aabe250 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 type { Transaction } from "./types/Transaction.ts"; +import { + createTransaction, + deleteTransactionQuery, + updateTransactionQuery, +} from "./queries/transactions.ts"; const pool = getConnectionPool(); const app = express(); @@ -32,7 +42,6 @@ app.use(express.json()); app.use((req, res, next) => { console.log("USER Incoming request: " + req.method + " " + req.url); - console.log(req.body); next(); }); @@ -88,6 +97,7 @@ app.get( return res.json([]); // Return empty array for no transactions } + // console.log("Fetched transactions for user:", transactions); res.json(transactions); } catch (error) { console.error("Error fetching transactions:", error); @@ -100,6 +110,220 @@ app.get( }, ); +app.get( + "/table/transactions/accounts", + async (req: express.Request, res: express.Response) => { + try { + const userId = authenticateJWT(req); + const accountsMap = await getAccountIDsForUser(pool, userId); + + //convert Map to array of objects + const accountsArray: { id: number; name: string }[] = []; + if (accountsMap instanceof Map) { + accountsMap.forEach((name, id) => { + accountsArray.push({ id: Number(id), name }); + }); + } + + res.json(accountsArray); + } 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", + }, + }; + + // console.log("Creating transaction with data:", transactionData); + + 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" }); + } + return res.status(200).send(); + } 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; + if (req.body.financialAccount_id !== undefined) + updates.financialAccount_id = req.body.financialAccount_id; + + // console.log("Updating transaction with the following updates:", updates); + + const transaction = await updateTransactionQuery( + pool, + transactionId, + userProfileId, + updates, + ); + if (!transaction) { + return res.status(404).json({ error: "Transaction not found" }); + } + + res.json(transaction); + } 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) => { @@ -210,7 +434,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" }); @@ -276,7 +500,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 fac6085..90ce360 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..fe286ff 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,24 @@ 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, + ); + 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..088c26c 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,171 @@ 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, + ]); + // console.log("Delete transaction result:", result); + 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..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"; @@ -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) { @@ -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,10 +184,10 @@ 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); + // console.log("Updated Account " + name); return res.status(200).json({ message: "Account successfully updated", lastUpdated: last_updated, @@ -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); diff --git a/app/server/services/ts/user/src/routes/transaction.ts b/app/server/services/ts/user/src/routes/transaction.ts index 3318ee6..b8845d8 100644 --- a/app/server/services/ts/user/src/routes/transaction.ts +++ b/app/server/services/ts/user/src/routes/transaction.ts @@ -50,6 +50,10 @@ transactionsRouter.get("/", async (req: Request, res: Response) => { } catch (err) { console.error("Failed to fetch transactions", err); return res.status(500).json({ error: "Failed to fetch transactions" }); + } finally { + if (connection) { + connection.release(); + } } }); diff --git a/app/server/services/ts/user/stryker.conf.json b/app/server/services/ts/user/stryker.conf.json deleted file mode 100644 index 725596c..0000000 --- a/app/server/services/ts/user/stryker.conf.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mutate": ["src/logic/goals.ts"], - "testRunner": "bun", - "plugins": ["stryker-mutator-bun-runner"], - "bun": { - "testFiles": ["./test/goals.test.ts"], - "timeout": 30000 - } -} diff --git a/app/server/services/ts/user/strykerNODE.conf.json b/app/server/services/ts/user/strykerNODE.conf.json deleted file mode 100644 index fd0b1b6..0000000 --- a/app/server/services/ts/user/strykerNODE.conf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", - "testRunner": "vitest", - "vitest": { - "configFile": "vitest.config.stryker.ts" - }, - "coverageAnalysis": "perTest", - "mutate": ["src/logic/goals.ts"], - "reporters": ["clear-text", "html"], - "concurrency": 4, - "timeoutMS": 10000 -} diff --git a/app/server/services/ts/user/test/accountTests.test.ts b/app/server/services/ts/user/test/accountTests.test.ts new file mode 100644 index 0000000..a5e923a --- /dev/null +++ b/app/server/services/ts/user/test/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/test/logic_transactions.test.ts b/app/server/services/ts/user/test/logic_transactions.test.ts index d4fe83c..dcce5c3 100644 --- a/app/server/services/ts/user/test/logic_transactions.test.ts +++ b/app/server/services/ts/user/test/logic_transactions.test.ts @@ -8,14 +8,20 @@ import { createEmptyTransactions, createTransactionsWithMissingFields, } from "./factories/transaction.factory.ts"; -import type { Transaction } from "../src/types/Transaction.ts"; +// import type { Transaction } from "../src/types/Transaction.ts"; + +const mockQuery = vi.fn(); +const mockPool = { + query: mockQuery, +} as unknown as Pool; vi.mock("../src/queries/transactions", () => ({ getAllTransactionsQuery: vi.fn(), + getTransactionAccountNames: vi.fn() })); describe("getTransactionsData", () => { - const mockPool = {} as Pool; + // const mockPool = {} as Pool; const userId = "123"; const mockGetAllTransactionsQuery = @@ -28,6 +34,7 @@ describe("getTransactionsData", () => { describe("Happy path", () => { it("should return enriched transactions", async () => { const mockTransactions = createMockTransactions(3); + mockQuery.mockResolvedValue([mockTransactions]); mockGetAllTransactionsQuery.mockResolvedValue(mockTransactions); const result = await getTransactionsData(mockPool, userId); @@ -49,6 +56,7 @@ describe("getTransactionsData", () => { constructor: { name: "RowDataPacket" }, }), ]; + mockQuery.mockResolvedValue([[]]); mockGetAllTransactionsQuery.mockResolvedValue(mockTransactions); const result = await getTransactionsData(mockPool, userId); @@ -60,6 +68,7 @@ describe("getTransactionsData", () => { describe("Edge cases", () => { it("should handle empty transactions", async () => { + mockQuery.mockResolvedValue([[]]); mockGetAllTransactionsQuery.mockResolvedValue(createEmptyTransactions()); const result = await getTransactionsData(mockPool, userId); @@ -68,6 +77,7 @@ describe("getTransactionsData", () => { }); it("should handle missing sender/recipient fields", async () => { + mockQuery.mockResolvedValue([[]]); const mockTransactions = createTransactionsWithMissingFields(); mockGetAllTransactionsQuery.mockResolvedValue(mockTransactions); @@ -86,4 +96,3 @@ describe("getTransactionsData", () => { }); }); }); - diff --git a/app/server/services/ts/user/test/transaction.test.ts b/app/server/services/ts/user/test/transaction.test.ts new file mode 100644 index 0000000..8033666 --- /dev/null +++ b/app/server/services/ts/user/test/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); + }); +}); diff --git a/app/server/services/ts/user/vitest.config.stryker.ts b/app/server/services/ts/user/vitest.config.stryker.ts deleted file mode 100644 index 8ac7718..0000000 --- a/app/server/services/ts/user/vitest.config.stryker.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "node", - include: ["test/**/*.test.ts"], - coverage: { - provider: "v8", - reporter: ["json", "text"], - }, - }, -}); diff --git a/documentation/acceptance tests/goals.md b/documentation/Acceptance/Goals/GoalsAcceptance.md similarity index 100% rename from documentation/acceptance tests/goals.md rename to documentation/Acceptance/Goals/GoalsAcceptance.md diff --git a/documentation/Coding_Style.md b/documentation/Coding_Style.md new file mode 100644 index 0000000..a7ee3d2 --- /dev/null +++ b/documentation/Coding_Style.md @@ -0,0 +1,58 @@ +### General Map + +To follow our application's code, we have divided everything into client and server directories. Client is done in TS with Bun and VIte as its framework. Server consists of microservices in both TS and Python. The database is also located in the server as a separate microservice. + +### Client + +At app -> client you will see all the general directories and files that are used to build up the client. + +At client -> test you will find some unit tests for more complex logic and functionality. These were made as an extra precaution even though it was assumed that we did not need them for our grading. + +Within the client directory, you will find standard bun package and organizational files, as well as all of our setup for Docker. The standard Dockerfile and coker-compose.yml do not run tests and are the typical way to launch the client container. Docker files with the ".test" suffix run client-side unit tests. + +In client -> src you will find all of the client source code, including various directories. The App.tsx file contains some of the first few authentication steps that would be typically seen by a user. This authentication shell wrapps the entirety of the code beyond. + +In src -> api you will find our API managers for various features. Some of these are more functional than others and contain a mixture of methods that would be typically called from a single page instead of just one feature. They all generally follow the structure of using an axios instance from the config.ts file, which appends JWT to every request sent to the server. You will find that all methods handle errors from the response gracefully. + +In src -> components you will find the multitude of components utilized throughout the pages, as well as some separation for CSV reading components, market page components, and some more general ui, which is a remnant of our project's initial stages where we tried to use shadcn/ui components. Most components end up containing a lot of HTML within them and they can get very complex. You will find comments throughtout the code whenever chunks of code must be explained. We did not spend time commenting on self-explanatory, simple functions. + +In src -> enum you will find some enumerator types to assist with account and transaction logic for user data input feature. + +In src -> hooks you will find a single hook for loading in a simple snapshot of user performance in the main dashboard page. + +All of our 5 pages can be found in src -> pages. These are primarily for layout and style and they host a lot of components internally. + +In src -> type you will find all the types that are being used to pass data around the client. + +The src -> utils directory contains all of our helper methods ranging from data validation to formatting. + + +### Server + +This section focuses on everything that can be found in app -> server. Inside the server directory, you will see the same setup of docker-compose files - one for regular deployment and one for running test containers. + +In server -> services you should find the 3 categories for microservices: TS, Python and the database. + +#### Database + +services -> database contains everything related to our MySQL DB. We access the database from outside of its container, so we user a non-root user account that needs extra privileges to contact the db. The populator python script, which can be ran with "python populator.py --clear", as an example, can be used to cleanup and repopulate the databse with randomized data. Our schema can also be found in this directory. + +#### Python + +In services -> python you will find the directories for our analytics and market services. Python naming of variables and methods uses the Python's standard snake case. + +The analytics service is the most complex out of the two, so its source code has been separated into logic, models, queries and utils. The main file, which exists outisde of those directories, contains the handling of redirects and it invokes all the logic code, which then invokes the queries. + +In addition to the rest of the source code for analytics, you can find the test directory, whcih contains unit and integration tests and coverage reports from pytest. Integration tests for all our services can only be ran locally, as they rely on the database being active, which is something that GitHub actions is not flexible enough to support. + +The market service is simpler, as it relies on an external API. All of its source code is found int its own src directory under python -> market -> src. In the market service, you will find the similar unit tests, but also integration tests separated within the integration directory. Unlike other services, market does not rely on the DB, so its integration test can run whenever, even in the CI/CD pipeline. + +#### TypeScript + +Inside the server -> services -> ts directory, you will find all of our TS services: api-gateway, auth and user. We use camel case for all TS naming. + +The auth services is simple in its functionality, so all of its code exsits within auth -> src directory. The files are labeled according to what they contain. + +The api gateway service is the most simple, as it just serves as a redirect for all API calls. It only has an index file as its source code, but that is really all it needs. + +The user microservice is meant to handle all API calls regarding simple data retrieval and processing tasks. It's code has been divided into logic, queries, routes, types and utils. Rerouting and logic invocation is happening in the index file. diff --git a/documentation/Finus_Test_Plan.md b/documentation/Finus_Test_Plan.md index cc74133..f15044c 100644 --- a/documentation/Finus_Test_Plan.md +++ b/documentation/Finus_Test_Plan.md @@ -130,7 +130,21 @@ We attempted to run stryker with vitest in node, but this results in a lot of li 3. ## **Load Testing** -*Skip for Sprint 3\.* +We use a `k6` load test located at `app/server/load-tests/api-gateway-capacity.js` to validate the course capacity requirement: + +- **20 concurrent users** +- **200 total requests per minute** + +The script enforces this by running **20 virtual users** concurrently and having each one issue **10 authenticated requests per minute** after signing up, logging in, and creating a test account. This exercises the gateway, auth service, user service, and database together under sustained load. + +This load test is **not required to be part of the CI/CD pipeline** and can be run manually with Docker when needed. + +```bash +cd app/server +docker compose -f docker-compose.yml -f docker-compose.test.yml up load-test --abort-on-container-failure +``` + +The test fails if request failures rise above 1% or if fewer than 99% of checks pass. 3. # **Terms/Acronyms**