{edit ? (
diff --git a/app/client/src/components/AccountListCard.tsx b/app/client/src/components/AccountListCard.tsx
index 4fc5111..29aaab8 100644
--- a/app/client/src/components/AccountListCard.tsx
+++ b/app/client/src/components/AccountListCard.tsx
@@ -47,7 +47,7 @@ export default function Card({
- {account.type + " " + (account.subtype ? " " + account.type : "")}
+ {account.type + " " + (account.subtype ? " " + account.subtype : "")}
{Number(account.balance).toFixed(2)}
diff --git a/app/client/src/components/NavBar.tsx b/app/client/src/components/NavBar.tsx
index 333fb7a..836e7bf 100644
--- a/app/client/src/components/NavBar.tsx
+++ b/app/client/src/components/NavBar.tsx
@@ -1,6 +1,7 @@
import { Sidebar } from "react-pro-sidebar";
import { Link, useLocation } from "react-router-dom";
import { CgHome } from "react-icons/cg";
+import { AiOutlineFundProjectionScreen } from "react-icons/ai";
import { MdCandlestickChart } from "react-icons/md";
import { FaSignOutAlt, FaTimes } from "react-icons/fa";
import { SIDEBAR_WIDTH } from "@/utils/constants";
@@ -16,6 +17,7 @@ function NavBar({ isOpen, onClose, onLogout }: NavigationBarProps) {
const navItems = [
{ to: "/dashboard", label: "Dashboard", icon:
},
{ to: "/markets", label: "Markets", icon:
},
+ {to:"/projection", label:"Projection", icon:
}
];
return (
diff --git a/app/client/src/components/ProjectionGraph.tsx b/app/client/src/components/ProjectionGraph.tsx
new file mode 100644
index 0000000..b6a3310
--- /dev/null
+++ b/app/client/src/components/ProjectionGraph.tsx
@@ -0,0 +1,90 @@
+import type { projectedDataResponse } from "@/types/responseTypes";
+import type { ChartData, ChartOptions } from "chart.js";
+import { Line } from "react-chartjs-2";
+import { Wallet } from "lucide-react";
+import NoItemState from "./NoItemState";
+
+interface graphProp {
+ data: projectedDataResponse;
+ name: string;
+}
+
+export default function ProjectionGraph({ data, name }: graphProp) {
+ const colors = [
+ "rgba(34, 250, 94, 1)",
+ "rgba(206, 232, 11, 1)",
+ "rgba(197, 34, 34, 1)",
+ ];
+
+ const chartOptions: ChartOptions<"line"> = {
+ responsive: true,
+ plugins: {
+ legend: {
+ position: "top",
+ labels: {
+ font: {
+ size: 16,
+ weight: "bold",
+ },
+ padding: 50,
+ },
+ },
+ },
+ backgroundColor: "rgb(255, 255, 255, 0.8)",
+ scales: {
+ x: {
+ ticks: {
+ font: {
+ size: 14,
+ weight: "bold", // Set the font weight to bold
+ },
+ },
+ },
+ y: {
+ beginAtZero: true,
+ ticks: {
+ font: {
+ size: 14,
+ weight: "bold", // Set the font weight to bold
+ },
+ },
+ },
+ },
+ };
+
+ console.log(data);
+ const chartData: ChartData<"line"> = {
+ labels: data.dateLabel,
+ datasets: data.lineInfo.map((line, index) => ({
+ label: line.name,
+ data: line.data,
+ fill: true,
+ borderColor: colors[index % colors.length],
+ backgroundColor: colors[index % colors.length].replace("1)", "0.55)"),
+ borderWidth: 1,
+ })),
+ };
+
+ return (
+ <>
+ {chartData ? (
+
+
{name}
+
+
+ ) : (
+ <>
+
}
+ />
+ >
+ )}
+ >
+ );
+}
diff --git a/app/client/src/components/SelectAccount.tsx b/app/client/src/components/SelectAccount.tsx
new file mode 100644
index 0000000..f2efbbf
--- /dev/null
+++ b/app/client/src/components/SelectAccount.tsx
@@ -0,0 +1,35 @@
+import type { Account } from "@/types/AccountType";
+import React from "react";
+
+interface selectProp {
+ accounts: Account[];
+ selectedAccount: number;
+ handleSelectAccount: (event: React.ChangeEvent
) => void;
+}
+
+export default function SelectAccount({
+ accounts,
+ selectedAccount,
+ handleSelectAccount,
+}: selectProp) {
+ console.log(accounts);
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/app/client/src/components/SelectDebt.tsx b/app/client/src/components/SelectDebt.tsx
new file mode 100644
index 0000000..1e21e03
--- /dev/null
+++ b/app/client/src/components/SelectDebt.tsx
@@ -0,0 +1,34 @@
+import { type Debt } from "@/types/Debt";
+import React, { type SetStateAction } from "react";
+
+interface selectProp {
+ debts: Debt[];
+ selectedDebt: number;
+ setSelectedDebt: React.Dispatch>;
+}
+
+//Component that puts out a selection of all the user's debts
+export default function SelectDebt({
+ debts,
+ selectedDebt,
+ setSelectedDebt,
+}: selectProp) {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/app/client/src/components/TransactionCard.tsx b/app/client/src/components/TransactionCard.tsx
index 6401b24..4e4c600 100644
--- a/app/client/src/components/TransactionCard.tsx
+++ b/app/client/src/components/TransactionCard.tsx
@@ -43,7 +43,9 @@ export default function Card({
{transaction.id}
-
{transaction.amount + " " + transaction.date}
+
{"$" + transaction.amount.toFixed(2)}
+
{"Date:" + transaction.date}
+
{"Type: " + transaction.category}
{transaction.description}
diff --git a/app/client/src/components/TransactionList.tsx b/app/client/src/components/TransactionList.tsx
index 85deae9..8e6fef8 100644
--- a/app/client/src/components/TransactionList.tsx
+++ b/app/client/src/components/TransactionList.tsx
@@ -6,6 +6,7 @@ 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;
@@ -13,7 +14,7 @@ interface listProp {
export default function TransactionList({ session }: listProp) {
const [userAccounts, setUserAccounts] = useState
([]);
- const [selectedAccount, setSelectedAccount] = useState("");
+ const [selectedAccount, setSelectedAccount] = useState(0);
const [accountTransactions, setAccountTransactions] = useState(
[],
);
@@ -85,20 +86,13 @@ export default function TransactionList({ session }: listProp) {
<>
-
-
+ ,
+ ) => setSelectedAccount(Number(event.target.value))}
+ />
diff --git a/app/client/src/components/TransationForm.tsx b/app/client/src/components/TransationForm.tsx
index d26ca3a..6c0609d 100644
--- a/app/client/src/components/TransationForm.tsx
+++ b/app/client/src/components/TransationForm.tsx
@@ -9,6 +9,7 @@ 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;
@@ -215,24 +216,22 @@ export default function PopupForm({
<>
- {edit ?
Edit Transaction
:
Create Transaction
}
+ {edit ? (
+
Edit Transaction
+ ) : (
+
Create Transaction
+ )}
+
+ {account && (
+
,
+ ) => setSelectedAccount(Number(event.target.value))}
+ />
+ )}
-
-
{selectedType == transactionCategory.INCOME ? (
@@ -244,6 +243,7 @@ export default function PopupForm({
id="other"
type="text"
value={other}
+ className="formInput"
onChange={(event) => setOther(event.target.value)}
>
@@ -252,6 +252,7 @@ export default function PopupForm({
-
+
setSelectedDate(event.target.value)}
/>
diff --git a/app/client/src/components/userForm.css b/app/client/src/components/userForm.css
index 999dbe1..fb24786 100644
--- a/app/client/src/components/userForm.css
+++ b/app/client/src/components/userForm.css
@@ -1,33 +1,44 @@
.popupForm {
- background-color: lightgrey;
+ background-color: rgba(14, 15, 15, 0.776);
padding: 15px;
place-content: start;
+ width: max-content;
height: auto;
+ white-space: nowrap;
}
-input {
+.formInput {
margin-top: 2.5%;
margin-bottom: 2.5%;
margin-left: 1%;
+ margin-right: 1%;
+ background-color: rgba(0, 0, 0, 0.566);
+ padding-left: 2%;
+ border-color: black;
+ border-width: 2px;
}
-select {
+.formSelect {
margin-top: 2.5%;
margin-bottom: 2.5%;
margin-left: 1%;
+ background-color: rgba(0, 0, 0, 0.566);
+ padding-left: 2%;
+ border-color: black;
+ border-width: 2px;
}
.popup {
color: black;
- background-color: floralwhite;
- padding: 15px;
+ background-color: rgb(34, 206, 66);
+ padding: 10px;
border-radius: 5px;
border-style: solid;
border-width: 2px;
- border-color: black;
+ border-color: rgba(34, 250, 94, 1);
position: fixed;
- top: 0;
+ top: 10%;
bottom: 0;
right: 35%;
left: 35%;
@@ -36,20 +47,29 @@ select {
height: max-content;
font-size: 10pt;
+ color: antiquewhite;
justify-content: center;
align-items: center;
}
-button {
+.formButton {
padding: 5px;
border-width: 1px;
border-color: black;
border-radius: 5px;
}
+.formH2 {
+ font-weight: bold;
+ text-align: center;
+ padding-bottom: 2%;
+}
+
.bottomButtons {
+ margin-top: 5px;
display: flex;
+ color: black;
}
.bottomButtons :first-child {
diff --git a/app/client/src/enum/AccountCategory.ts b/app/client/src/enum/AccountCategory.ts
index 6b084df..b35f4ec 100644
--- a/app/client/src/enum/AccountCategory.ts
+++ b/app/client/src/enum/AccountCategory.ts
@@ -1,6 +1,7 @@
export const accountCategory = {
- SAVING: "Saving",
+ SAVING: "Savings",
CHEQUING: "Chequing",
INVESTMENT: "Investment",
- DEBT: "Debt",
+ CREDIT_CARD:"Credit Card",
+ UNCONFIRMED: "Unconfirmed"
};
diff --git a/app/client/src/pages/ProjectionPage.tsx b/app/client/src/pages/ProjectionPage.tsx
new file mode 100644
index 0000000..b520687
--- /dev/null
+++ b/app/client/src/pages/ProjectionPage.tsx
@@ -0,0 +1,536 @@
+import {
+ Chart,
+ PointElement,
+ LineElement,
+ ArcElement,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Title,
+ Tooltip,
+ Legend,
+} from "chart.js";
+
+import type { AuthSession } from "../types/authTypes";
+import SelectAccount from "@/components/SelectAccount";
+import React, { useEffect, useState } from "react";
+import { type Account } from "@/types/AccountType";
+import { getDebt, getDebtProjection } from "@/api/Debt";
+import { getSaving, getSavingProjection } from "@/api/Saving";
+import {
+ type LineInfo,
+ type projectedDataResponse,
+} from "@/types/responseTypes";
+import ProjectionGraph from "@/components/ProjectionGraph";
+import { TbGraph } from "react-icons/tb";
+import NoItemState from "@/components/NoItemState";
+import { handleCurrencyChange, handleCurrencyBlur } from "@/utils/handleInput";
+import type {
+ projectionDebtRequest,
+ projectionSavingRequest,
+} from "@/types/requestTypes";
+import {
+ validateDebtProjection,
+ validateSavingProjection,
+} from "@/utils/ValidateProjectionRequest";
+import { accountCategory } from "@/enum/AccountCategory";
+
+Chart.register(
+ PointElement,
+ LineElement,
+ ArcElement,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Title,
+ Tooltip,
+ Legend,
+);
+
+type ProjectionProp = {
+ session: AuthSession;
+};
+
+function ProjectionPage({ session }: ProjectionProp) {
+ const [accounts, setAccounts] = useState([]);
+ const [debts, setDebts] = useState([]);
+ const [selectedAccount, setSelectedAccount] = useState(0);
+ const [selectedDebt, setSelectedDebt] = useState(0);
+ const [nextDueDate, setNextDueDate] = useState("");
+ const [interest, setInterest] = useState("");
+ const [amount, setAmount] = useState("");
+ const [period, setPeriod] = useState("");
+ const [minPay, setMinPay] = useState("");
+
+ const [selectedType, setSelectedType] = useState<"Saving" | "Debt">("Saving");
+
+ const [savingData, setSavingData] = useState<
+ projectedDataResponse | undefined
+ >(undefined);
+ const [debtData, setDebtData] = useState(
+ undefined,
+ );
+
+ const [debtRequest, setDebtRequest] = useState<
+ projectionDebtRequest | undefined
+ >(undefined);
+ const [savingRequest, setSavingRequest] = useState<
+ projectionSavingRequest | undefined
+ >(undefined);
+
+ //Total amount of interest generated
+ const [totalInterest, setTotalInterest] = useState(0);
+ //Total amoutn of pay
+ const [totalPay, setTotalPay] = useState(0);
+
+ const calculateProjection = () => {
+ const inputAmount = Number(amount);
+ const inputMinPay = Number(minPay);
+ const inputInterest = Number(interest);
+ const inputPeriod = Number(period);
+
+ switch (selectedType) {
+ case "Debt":
+ if (
+ validateDebtProjection(
+ selectedDebt,
+ inputAmount,
+ inputMinPay,
+ inputInterest,
+ nextDueDate,
+ inputPeriod,
+ )
+ ) {
+ const newDebtRequest: projectionDebtRequest = {
+ id: selectedAccount.toString(),
+ category: accountCategory.CREDIT_CARD,
+ remainingAmount: inputAmount,
+ minimumPayment: inputMinPay,
+ interestRate: inputInterest / 100,
+ nextDueDate: nextDueDate,
+ period: inputPeriod,
+ };
+
+ setDebtRequest(newDebtRequest);
+
+ getDebtProjection(session, newDebtRequest)
+ .then((data) => {
+ let dataTotalInterest = 0;
+ let dataTotalPay = 0;
+ const debtLineData: LineInfo = {
+ name: "Remaining Debt",
+ data: [],
+ };
+ console.log(data);
+ if (data) {
+ const graphData: projectedDataResponse = {
+ lineInfo: [],
+ dateLabel: [],
+ };
+ data.debtStages.map((stage) => {
+ debtLineData.data.push(stage.remainingDebt);
+ graphData.dateLabel.push(stage.installmentDate);
+ dataTotalInterest += stage.interestAmount;
+ dataTotalPay += stage.principalAmount;
+ });
+
+ graphData.lineInfo = [debtLineData];
+
+ setDebtData(graphData);
+ setTotalPay(dataTotalPay);
+ setTotalInterest(dataTotalInterest);
+ }
+ })
+ .catch((error) => {
+ alert(error);
+ });
+ } else {
+ alert("Please enter all fields");
+ }
+ break;
+
+ case "Saving":
+ if (
+ validateSavingProjection(
+ selectedAccount,
+ inputInterest,
+ inputAmount,
+ inputMinPay,
+ inputPeriod,
+ )
+ ) {
+ const newSavingRequest: projectionSavingRequest = {
+ financial_account_id: selectedAccount,
+ balance: inputAmount,
+ monthly_deposit: inputMinPay,
+ annual_interest_rate: inputInterest / 100,
+ time_frame: inputPeriod,
+ };
+
+ setSavingRequest(newSavingRequest);
+
+ getSavingProjection(session, newSavingRequest)
+ .then((data) => {
+ console.log(data);
+ const graphData: projectedDataResponse = {
+ dateLabel: [],
+ lineInfo: [],
+ };
+ const bestCase: LineInfo = { name: "Best Case", data: [] };
+ const expectedCase: LineInfo = {
+ name: "Expected Case",
+ data: [],
+ };
+ const worstCase: LineInfo = { name: "Worst Case", data: [] };
+
+ if (data) {
+ //Extract all the data from the response
+ data.map((datapoint) => {
+ graphData.dateLabel.push(datapoint.date);
+ bestCase.data.push(datapoint.accumulative_best_balance);
+ expectedCase.data.push(
+ datapoint.accumulative_expected_balance,
+ );
+ worstCase.data.push(datapoint.accumulative_worst_balance);
+ });
+
+ graphData.lineInfo = [bestCase, expectedCase, worstCase];
+ setSavingData(graphData);
+ } else {
+ alert("Failed to get the projection ");
+ }
+ })
+ .catch(() => {
+ alert("Failed to get the projection");
+ });
+ } else {
+ alert("Please enter all fields");
+ }
+ }
+ };
+
+ const handleTypeChange = (typeChange: "Saving" | "Debt") => {
+ setSelectedType(typeChange);
+
+ if (selectedType === "Debt") {
+ if (debtRequest) {
+ //Set the fields to what was used in the debt projection graph
+ setSelectedAccount(Number(debtRequest.id));
+ setAmount(debtRequest.remainingAmount.toFixed(2));
+ setInterest(debtRequest.interestRate.toString());
+ setMinPay(debtRequest.minimumPayment.toFixed(2));
+ setNextDueDate(debtRequest.nextDueDate);
+ setPeriod(debtRequest.period.toString());
+ } else {
+ setSelectedDebt(0);
+ setAmount("");
+ setInterest("");
+ setMinPay("");
+ setPeriod("");
+ setNextDueDate("");
+ }
+ } else if (selectedType === "Saving") {
+ if (savingRequest) {
+ //Assigns the fields to what the projection of the saving account used
+ setSelectedAccount(savingRequest.financial_account_id);
+ setAmount(savingRequest.balance.toFixed(2));
+ setInterest(savingRequest.annual_interest_rate.toString());
+ setMinPay(savingRequest.monthly_deposit.toFixed(2));
+ setPeriod(savingRequest.time_frame.toString());
+ } else {
+ //Resets the fields
+ setSelectedAccount(0);
+ setAmount("");
+ setInterest("");
+ setMinPay("");
+ setPeriod("");
+ }
+ }
+ };
+
+ //Onchange handler for the select accounts
+ const handleSelectAccount = (event: React.ChangeEvent) => {
+ let target;
+ const id = Number(event.target.value);
+
+ if (selectedType === "Debt") {
+ setSelectedDebt(id);
+
+ target = debts.find((account) => account.id === id)?.balance;
+ } else if (selectedType === "Saving") {
+ setSelectedAccount(id);
+
+ target = accounts.find((account) => account.id === id)?.balance;
+ }
+
+ if (target) {
+ setAmount(target.toFixed(2));
+ } else {
+ setAmount("0");
+ }
+ };
+
+ //Handles on change of percentage
+ const handleNumberChange = (
+ event: React.ChangeEvent,
+ setNumber: React.Dispatch>,
+ min: number,
+ max: number,
+ ) => {
+ let input = event.target.value;
+ let changeInterest;
+ 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 = input.replace(/^0+(?=\d)/, "");
+ changeInterest = Number(input);
+
+ if (changeInterest > max) {
+ changeInterest = max;
+ }
+
+ if (changeInterest < min) {
+ changeInterest = min;
+ }
+ console.log(changeInterest);
+ setNumber(changeInterest.toString());
+ }
+ };
+ //Get the saving accounts
+ useEffect(() => {
+ if (selectedType === "Saving") {
+ getSaving(session)
+ .then((userAccounts) => {
+ console.log(userAccounts);
+
+ //Detemrine accounts exist
+ if (userAccounts) {
+ setAccounts(userAccounts);
+ }
+ })
+ .catch(() => {
+ //alert("Failed to get accounts");
+ });
+ }
+ }, [session, selectedType]);
+
+ //Gets the debt accounts
+ useEffect(() => {
+ if (selectedType === "Debt") {
+ getDebt(session)
+ .then((userDebts) => {
+ console.log(userDebts);
+
+ //Detemrine if there are any debts
+ if (userDebts) {
+ setDebts(userDebts);
+ }
+ })
+ .catch(() => {});
+ }
+ }, [session, selectedType]);
+
+ const typeButton = [
+ { key: "Saving", label: "Saving" },
+ { key: "Debt", label: "Debt" },
+ ].map(({ key, label }) => (
+
+ ));
+
+ 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}
+
+
+
+ {selectedType === "Saving" ? (
+
+ ) : (
+
+ )}
+
+ <>
+
+ handleCurrencyChange(event, setAmount)}
+ onBlur={(event) => handleCurrencyBlur(event, amount, setAmount)}
+ />
+
+
+ handleCurrencyChange(event, setMinPay)}
+ onBlur={(event) => handleCurrencyBlur(event, minPay, setMinPay)}
+ />
+
+
+ {
+ handleNumberChange(event, setPeriod, 0, 100);
+ }}
+ />
+ >
+
+
+ {
+ handleNumberChange(event, setInterest, 0, 100);
+ }}
+ />
+
+ {selectedType === "Debt" && (
+ <>
+
+
+ setNextDueDate(event.target.value)}
+ />
+ >
+ )}
+
+
+
+
+
+
+ {selectedType === "Saving" ? (
+ savingData ? (
+ <>
+
+ >
+ ) : (
+ <>
+
}
+ />
+ >
+ )
+ ) : null}
+
+ {selectedType === "Debt" ? (
+ debtData ? (
+ <>
+
+
+ {"Total Amount Paid:$" +
+ totalPay.toFixed(2) +
+ " Total Interest Paid:$" +
+ totalInterest.toFixed(2)}
+
+ >
+ ) : (
+ <>
+
}
+ />
+ >
+ )
+ ) : null}
+
+
+ );
+}
+
+export default ProjectionPage;
diff --git a/app/client/src/types/Debt.ts b/app/client/src/types/Debt.ts
new file mode 100644
index 0000000..6f1a3c5
--- /dev/null
+++ b/app/client/src/types/Debt.ts
@@ -0,0 +1,16 @@
+export type Debt = {
+ id: string;
+ name: string;
+ category: string;
+ remainingAmount: number;
+ minimumPayment: number;
+ interestRate?: number;
+ nextDueDate: string; // format: YYYY-MM-DD
+ period: number; // number of days between each payment installment
+};
+
+export type DebtStage = {
+ paidAmount: number;
+ remainingDebt: number;
+ installmentDate: string; // YYYY-MM-DD
+};
diff --git a/app/client/src/types/requestTypes.ts b/app/client/src/types/requestTypes.ts
new file mode 100644
index 0000000..01e3792
--- /dev/null
+++ b/app/client/src/types/requestTypes.ts
@@ -0,0 +1,17 @@
+export interface projectionDebtRequest {
+ id: string;
+ category: string;
+ remainingAmount: number;
+ minimumPayment: number;
+ interestRate: number;
+ nextDueDate: string; // format: YYYY-MM-DD
+ period: number; // number of days between each payment installment
+}
+
+export interface projectionSavingRequest {
+ financial_account_id: number;
+ balance: number;
+ monthly_deposit: number;
+ annual_interest_rate: number;
+ time_frame: number;
+}
diff --git a/app/client/src/types/responseTypes.ts b/app/client/src/types/responseTypes.ts
index b3ef6e0..6b4308f 100644
--- a/app/client/src/types/responseTypes.ts
+++ b/app/client/src/types/responseTypes.ts
@@ -3,3 +3,36 @@ export interface updateResponse {
lastUpdated?: Date;
id?: number;
}
+
+export interface projectedDataResponse {
+ lineInfo: LineInfo[];
+ dateLabel: string[];
+}
+
+export interface LineInfo {
+ data: number[];
+ name: string;
+}
+
+export type DebtPayoffResponse = {
+ id: string;
+ category: string;
+ minimumPayment: number;
+ interestRate: number;
+ debtStages: AdvancedDebtStage[];
+};
+
+export type AdvancedDebtStage = {
+ id: number;
+ principalAmount: number;
+ interestAmount: number;
+ remainingDebt: number;
+ installmentDate: string; // YYYY-MM-DD
+};
+
+export interface savingProjectionResponseData {
+ accumulative_best_balance: number;
+ accumulative_expected_balance: number;
+ accumulative_worst_balance: number;
+ date: string;
+}
diff --git a/app/client/src/utils/ValidateProjectionRequest.ts b/app/client/src/utils/ValidateProjectionRequest.ts
new file mode 100644
index 0000000..0d6349c
--- /dev/null
+++ b/app/client/src/utils/ValidateProjectionRequest.ts
@@ -0,0 +1,35 @@
+export function validateDebtProjection(
+ id: number,
+ remainingAmount: number,
+ minimumPayment: number,
+ interestRate: number,
+ nextDueDate: string,
+ period: number,
+) {
+ return (
+ id &&
+ remainingAmount >= 0 &&
+ minimumPayment > 0 &&
+ interestRate >= 0 &&
+ interestRate <= 100 &&
+ nextDueDate &&
+ period
+ );
+}
+
+export function validateSavingProjection(
+ id: number,
+ interestRate: number,
+ balence: number,
+ monthly_deposit: number,
+ time_frame: number,
+) {
+ return (
+ id &&
+ interestRate >= 0 &&
+ interestRate <= 100 &&
+ balence >= 0 &&
+ monthly_deposit >= 0 &&
+ time_frame > 0
+ );
+}
diff --git a/app/server/docker-compose.test.yml b/app/server/docker-compose.test.yml
index 096f10c..76428b5 100644
--- a/app/server/docker-compose.test.yml
+++ b/app/server/docker-compose.test.yml
@@ -58,17 +58,17 @@ services:
market:
condition: service_started
- user-integration-test:
- build:
- context: ./services/ts
- dockerfile: user/Dockerfile.integration.test
- env_file:
- - ./default_container.env
- environment:
- - JWT_SECRET
+ # user-integration-test:
+ # build:
+ # context: ./services/ts
+ # dockerfile: user/Dockerfile.integration.test
+ # env_file:
+ # - ./default_container.env
+ # environment:
+ # - JWT_SECRET
- depends_on:
- database:
- condition: service_healthy
- api-gateway:
- condition: service_started
+ # depends_on:
+ # database:
+ # condition: service_healthy
+ # api-gateway:
+ # condition: service_started
diff --git a/app/server/services/database/schema.sql b/app/server/services/database/schema.sql
index b5895b4..340e43e 100644
--- a/app/server/services/database/schema.sql
+++ b/app/server/services/database/schema.sql
@@ -154,8 +154,8 @@ CREATE TABLE finus.fixedInterestInvestment(
);
#Populate lookup tables
-INSERT INTO finus.financialAccountType (type) VALUES ('chequing'), ('savings'), ('credit_card'), ('investment');
-INSERT INTO finus.financialAccountSubtype (subtype) VALUES ('RRSP'), ('TFSA'), ('FHSA'), ('RESP'), ('RDSP'), ('loan'), ('na');
+INSERT INTO finus.financialAccountType (type) VALUES ('chequing'), ('savings'), ('credit_card'), ('investment');
+INSERT INTO finus.financialAccountSubtype (subtype) VALUES ('RRSP'), ('TFSA'), ('FHSA'), ('RESP'), ('RDSP'), ('Loan'), ('na');
-- loan is used for credit_card accounts that are for loans like mortgage and etc, this is used to track debt
INSERT INTO finus.investmentType (type) VALUES ('fixedInterest'), ('stock'); #These have to match table names
diff --git a/app/server/services/python/analytics/src/logic/debt.py b/app/server/services/python/analytics/src/logic/debt.py
new file mode 100644
index 0000000..cf763ec
--- /dev/null
+++ b/app/server/services/python/analytics/src/logic/debt.py
@@ -0,0 +1,48 @@
+import pandas as pd
+from typing import List, Optional
+from datetime import datetime, timedelta
+from src.models.schemas import DebtPayoffRequest, DebtPayoffResponse, DebtPayoffStage, BadRequestError
+
+
+def generate_debt_payoff_stages(dbr: DebtPayoffRequest) -> DebtPayoffResponse:
+ remaining_debt = dbr.remainingAmount
+ minimum_payment = dbr.minimumPayment
+ interest_rate = dbr.interestRate or 0
+
+ monthly_interest_rate = round(interest_rate / 12 / 100, 5) if interest_rate else 0
+
+ expected_date = datetime.strptime(dbr.nextDueDate, "%Y-%m-%d")
+ stages: List[DebtPayoffStage] = []
+
+ i = 1
+ while remaining_debt > 0:
+ interest = remaining_debt * monthly_interest_rate
+ principal = minimum_payment - interest
+
+ if principal > remaining_debt:
+ principal = remaining_debt
+
+ if principal <= 0:
+ raise BadRequestError("Minimum payment is too low. Debt will never be paid off.")
+
+ new_remaining = remaining_debt - principal
+
+ stages.append(DebtPayoffStage(
+ id=i,
+ principalAmount=round(principal, 2),
+ interestAmount=round(interest, 2),
+ remainingDebt=round(new_remaining, 2),
+ installmentDate=expected_date.strftime("%Y-%m-%d")
+ ))
+
+ remaining_debt = new_remaining
+ expected_date += timedelta(days=dbr.period)
+ i += 1
+
+ return DebtPayoffResponse(
+ id=dbr.id,
+ category=dbr.category,
+ minimumPayment=dbr.minimumPayment,
+ interestRate=interest_rate,
+ debtStages=stages,
+ )
diff --git a/app/server/services/python/analytics/src/logic/savings.py b/app/server/services/python/analytics/src/logic/savings.py
index cb655bd..43166dd 100644
--- a/app/server/services/python/analytics/src/logic/savings.py
+++ b/app/server/services/python/analytics/src/logic/savings.py
@@ -1,6 +1,13 @@
import pandas as pd
+import numpy as np
from typing import List, Dict
+from datetime import datetime, 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
def calculate_savings_over_time(
accounts: List[Dict],
@@ -49,4 +56,154 @@ def calculate_savings_over_time(
'label': f'{period_name} Savings',
'data': [item['savings'] for item in savings_over_time]
}]
- }
\ No newline at end of file
+ }
+
+# 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
+
+ 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
+ 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
+ worst_balance = round(worst_balance, 2)
+ expected_balance = round(expected_balance, 2)
+ best_balance = round(best_balance, 2)
+
+ results.append(
+ CompoundInterestResponse(
+ accumulative_worst_balance=worst_balance,
+ accumulative_expected_balance=expected_balance,
+ accumulative_best_balance=best_balance,
+ date=current_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/src/main.py b/app/server/services/python/analytics/src/main.py
index 240a046..eb80e9b 100644
--- a/app/server/services/python/analytics/src/main.py
+++ b/app/server/services/python/analytics/src/main.py
@@ -1,14 +1,17 @@
from fastapi import FastAPI, Depends, Query, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import pandas as pd
+from typing import List, Dict
import uvicorn
import os
+
from src.dependencies import get_db_connection, get_current_user
from src.utils.dates import period_calc
from src.logic.budget import generate_budget, generate_budget_performance
from src.queries import savings as savings_queries, incomeflow as incomeflow_queries
-from src.logic import savings as savings_service, incomeflow as incomeflow_service
+from src.logic import savings as savings_service, incomeflow as incomeflow_service, debt as debt_service
+from src.models.schemas import BadRequestError, ProjectedSavingsRequest, ProjectedSavingsResponse, CompoundInterestResponse, DebtPayoffRequest
app = FastAPI()
@@ -116,6 +119,34 @@ async def get_budget(
except Exception as e:
print(e)
raise HTTPException(status_code=500, detail=str(e))
+
+@app.post('/predict-debt-payoff')
+async def predict_debt_payoff(
+ requestBody: DebtPayoffRequest,
+ user_id: int = Depends(get_current_user),
+):
+ try:
+ print(f"Generating predicted debt payoff for user {user_id}")
+ print(requestBody)
+ return debt_service.generate_debt_payoff_stages(requestBody)
+ except BadRequestError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ print(e)
+ raise HTTPException(status_code=500, detail=str(e))
+
+@app.post('/compound-interest')
+async def compount_interest(
+ requestBody: ProjectedSavingsRequest,
+ user_id: int = Depends(get_current_user),
+) -> List[CompoundInterestResponse]:
+ try:
+ print(f"Generating compound interests for user {user_id}")
+ print(requestBody)
+ return savings_service.calculate_compound_interest(requestBody)
+ except Exception as e:
+ print(e)
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/app/server/services/python/analytics/src/models/schemas.py b/app/server/services/python/analytics/src/models/schemas.py
index bd8b43c..be89b8f 100644
--- a/app/server/services/python/analytics/src/models/schemas.py
+++ b/app/server/services/python/analytics/src/models/schemas.py
@@ -1,3 +1,4 @@
+from numpy import number
from pydantic import BaseModel
from typing import List, Literal, Optional
from datetime import datetime
@@ -51,4 +52,52 @@ class BudgetPerformanceItem(BaseModel):
class BudgetPerformanceResponse(BaseModel):
categories: List[str]
budgetAmounts: List[float]
- actualAmounts: List[float]
\ No newline at end of file
+ actualAmounts: List[float]
+
+class DebtPayoffRequest(BaseModel):
+ id: str
+ category: str
+ remainingAmount: float
+ minimumPayment: float
+ interestRate: Optional[float] = 0
+ nextDueDate: str # YYYY-MM-DD
+ period: int # days
+
+class DebtPayoffStage(BaseModel):
+ id: int
+ principalAmount: float
+ interestAmount: float
+ remainingDebt: float
+ installmentDate: str
+
+class DebtPayoffResponse(BaseModel):
+ id: str
+ category: str
+ minimumPayment: float
+ interestRate: float
+ debtStages: List[DebtPayoffStage]
+
+class ProjectedSavingsRequest(BaseModel):
+ financial_account_id: int
+ balance: float
+ monthly_deposit: float
+ annual_interest_rate: float | None # annual interest rate in percentage
+ time_frame: int # in years
+class MonthlySavingGrowthRate(BaseModel):
+ best_case: float
+ expected_case: float
+ worst_case: float
+class ProjectedSavingsResponse(BaseModel):
+ balance: float
+ monthly_contribution: float
+ interest_rate: float | None # annual interest rate in percentage
+ time_frame: int # in years
+ stat: List[MonthlySavingGrowthRate]
+class CompoundInterestResponse(BaseModel):
+ accumulative_best_balance: float
+ accumulative_expected_balance: float
+ accumulative_worst_balance: float
+ date: str
+
+class BadRequestError(Exception):
+ pass
\ No newline at end of file
diff --git a/app/server/services/python/analytics/src/queries/savings.py b/app/server/services/python/analytics/src/queries/savings.py
index e8b3bfd..b7909f0 100644
--- a/app/server/services/python/analytics/src/queries/savings.py
+++ b/app/server/services/python/analytics/src/queries/savings.py
@@ -1,5 +1,4 @@
from typing import List, Dict
-import mysql.connector
def get_savings_accounts(cursor, user_id: int) -> List[Dict]:
cursor.execute("""
diff --git a/app/server/services/python/analytics/test/conftest.py b/app/server/services/python/analytics/test/conftest.py
index 5374bc6..1dfc278 100644
--- a/app/server/services/python/analytics/test/conftest.py
+++ b/app/server/services/python/analytics/test/conftest.py
@@ -1,7 +1,8 @@
import pytest
+import pandas as pd
from unittest.mock import patch, MagicMock
from datetime import datetime, timedelta
-from src.models.schemas import BudgetCategory, BudgetResponse
+from src.models.schemas import BudgetCategory, BudgetResponse, DebtPayoffRequest
from src.utils.trans_cat_classifier import CategoryClassifier
import jwt
import os
@@ -62,6 +63,30 @@ def mock_db_queries():
yield mock
+@pytest.fixture
+def mock_savings_accounts():
+ return [
+ {'id': 1, 'balance': 5000},
+ {'id': 2, 'balance': 3000},
+ ]
+
+@pytest.fixture
+def mock_savings_transactions():
+ base_date = datetime.now() - timedelta(days=30)
+ return [
+ {'financialAccount_id': 1, 'amount': 100, 'date': base_date + timedelta(days=5)},
+ {'financialAccount_id': 1, 'amount': 200, 'date': base_date + timedelta(days=10)},
+ {'financialAccount_id': 2, 'amount': -50, 'date': base_date + timedelta(days=7)},
+ {'financialAccount_id': 2, 'amount': 150, 'date': base_date + timedelta(days=15)},
+ {'financialAccount_id': 1, 'amount': -75, 'date': base_date + timedelta(days=20)},
+ ]
+@pytest.fixture
+def mock_savings_transactions_by_financial_account():
+ return pd.DataFrame([
+ {"financialAccountId": "1", "date": "2026-01-10", "amount": 100},
+ {"financialAccountId": "1", "date": "2025-01-20", "amount": 50},
+ {"financialAccountId": "1", "date": "2026-02-01", "amount": 200},
+ ])
@pytest.fixture
def mock_incomeflow_transactions():
@@ -78,7 +103,35 @@ def mock_incomeflow_transactions():
{'amount': -100, 'category': 'entertainment', 'date': base_date + timedelta(days=8)},
{'amount': -400, 'category': 'utilities', 'date': base_date + timedelta(days=12)},
]
+@pytest.fixture
+def mock_transactions_with_same_amount():
+ return [
+ {"date": "2026-01-01", "amount": 100},
+ {"date": "2026-02-01", "amount": 100},
+ {"date": "2026-03-01", "amount": 100},
+ {"date": "2026-04-01", "amount": 100},
+ {"date": "2026-05-01", "amount": 100},
+ ]
+@pytest.fixture
+def mock_monthly_diff():
+ return pd.DataFrame({
+ "month": [1, 2, 3],
+ "amount": [0, 0, 100],
+ "balance": [0, 0, 100]
+ })
+
+@pytest.fixture
+def mock_debt_payoff_request():
+ return DebtPayoffRequest(
+ id="1",
+ category="credit_card",
+ remainingAmount=1000.0,
+ minimumPayment=200.0,
+ interestRate=12.0,
+ nextDueDate="2024-01-01",
+ period=30
+ )
@pytest.fixture
def mock_env_vars():
diff --git a/app/server/services/python/analytics/test/test_debts.py b/app/server/services/python/analytics/test/test_debts.py
new file mode 100644
index 0000000..54b9d25
--- /dev/null
+++ b/app/server/services/python/analytics/test/test_debts.py
@@ -0,0 +1,52 @@
+import pytest
+from src.logic.debt import generate_debt_payoff_stages
+from src.models.schemas import BadRequestError, DebtPayoffRequest, DebtPayoffResponse
+
+class TestDebts:
+ def test_generate_debt_payoff(self, mock_debt_payoff_request):
+ result = generate_debt_payoff_stages(mock_debt_payoff_request)
+
+ assert result.id == mock_debt_payoff_request.id
+ assert result.category == mock_debt_payoff_request.category
+ assert len(result.debtStages) > 0
+
+ # final debt should be zero
+ assert result.debtStages[-1].remainingDebt == 0
+
+
+ def test_generate_debt_payoff_no_interest(self, mock_debt_payoff_request):
+ mock_debt_payoff_request.interestRate = 0
+
+ result = generate_debt_payoff_stages(mock_debt_payoff_request)
+
+ # No interest should be charged
+ for stage in result.debtStages:
+ assert stage.interestAmount == 0
+
+ assert len(result.debtStages) == 5
+
+
+ def test_last_payment_adjustment(self, mock_debt_payoff_request):
+ mock_debt_payoff_request.remainingAmount = 450
+ mock_debt_payoff_request.minimumPayment = 200
+ mock_debt_payoff_request.interestRate = 0
+
+ result = generate_debt_payoff_stages(mock_debt_payoff_request)
+
+ last_stage = result.debtStages[-1]
+
+ # Last payment should exactly clear the debt
+ assert last_stage.remainingDebt == 0
+ assert last_stage.principalAmount <= mock_debt_payoff_request.minimumPayment
+
+
+ def test_min_payment_too_low(self, mock_debt_payoff_request):
+ mock_debt_payoff_request.minimumPayment = 1
+ mock_debt_payoff_request.interestRate = 100 # Very high interest
+
+ with pytest.raises(BadRequestError) as exc:
+ generate_debt_payoff_stages(mock_debt_payoff_request)
+
+ assert "too low" in str(exc.value)
+
+
diff --git a/app/server/services/python/analytics/test/test_savings.py b/app/server/services/python/analytics/test/test_savings.py
index a1f94de..53e4ed2 100644
--- a/app/server/services/python/analytics/test/test_savings.py
+++ b/app/server/services/python/analytics/test/test_savings.py
@@ -1,9 +1,9 @@
+from src.models.schemas import ProjectedSavingsRequest
import pytest
import pandas as pd
-from datetime import datetime
-from unittest.mock import patch, MagicMock
-from src.logic.savings import calculate_savings_over_time
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
class TestSavings:
@@ -119,4 +119,37 @@ def test_calculate_savings_over_time_no_accounts(self):
# Should return empty structure
assert result['labels'] == []
- assert result['datasets'][0]['data'] == []
\ No newline at end of file
+ assert result['datasets'][0]['data'] == []
+
+ def test_get_monthly_balances(self, mock_savings_transactions_by_financial_account):
+
+ result = get_monthly_balances(mock_savings_transactions_by_financial_account)
+
+ # January = 150, February = 200
+ assert result.loc[result['month'] == 1, 'amount'].values[0] == 150
+ assert result.loc[result['month'] == 2, 'amount'].values[0] == 200
+
+ # cumulative balance
+ assert result.loc[result['month'] == 1, 'balance'].values[0] == 150
+ assert result.loc[result['month'] == 2, 'balance'].values[0] == 350
+
+ def test_compute_growth_rates_handles_nan_and_inf(self, mock_monthly_diff):
+
+ growth_rates = compute_monthly_growth_rates(mock_monthly_diff)
+
+ # Should not contain NaN or inf
+ assert not growth_rates.isnull().any()
+ assert not (growth_rates == float("inf")).any()
+
+ def test_generate_growth_rate(self, mock_transactions_with_same_amount):
+
+ result = generate_savings_growth_rate(mock_transactions_with_same_amount)
+
+ assert result is not None
+ assert result.best_case >= result.expected_case
+ assert result.expected_case >= result.worst_case
+
+ def test_generate_growth_rate_empty(self):
+ result = generate_savings_growth_rate([])
+
+ assert result is None
\ 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 b286e95..0313249 100644
--- a/app/server/services/ts/api-gateway/src/index.ts
+++ b/app/server/services/ts/api-gateway/src/index.ts
@@ -171,6 +171,42 @@ app.use(
}),
);
+//compound interest from analytics service
+app.use(
+ createProxyMiddleware({
+ pathFilter: ["/compound-interest"],
+ target: process.env.ANALYTICS_SERVICE_ADDR,
+ changeOrigin: true,
+ }),
+);
+
+//debt payoff prediction from analytics service
+app.use(
+ createProxyMiddleware({
+ pathFilter: ["/predict-debt-payoff"],
+ target: process.env.ANALYTICS_SERVICE_ADDR,
+ changeOrigin: true,
+ }),
+);
+
+//debt-related request
+app.use(
+ createProxyMiddleware({
+ pathFilter: "/api/debts",
+ target: process.env.USER_SERVICE_ADDR,
+ changeOrigin: true,
+ pathRewrite: { "^/api/debts": "/debts" },
+ }),
+);
+//savings-related request
+app.use(
+ createProxyMiddleware({
+ pathFilter: "/api/savings",
+ target: process.env.USER_SERVICE_ADDR,
+ changeOrigin: true,
+ pathRewrite: { "^/api/savings": "/savings" },
+ }),
+);
// market search, quote, and history endpoints from market service
app.use(
createProxyMiddleware({
diff --git a/app/server/services/ts/user/integration/setup.ts b/app/server/services/ts/user/integration/setup.ts
index 83f9098..de537fa 100644
--- a/app/server/services/ts/user/integration/setup.ts
+++ b/app/server/services/ts/user/integration/setup.ts
@@ -55,6 +55,8 @@ export async function createAuthenticatedAccount(
}
const token = login.body.token as string;
+
+ // Create the account
const account = await request(baseUrl)
.post("/api/accounts")
.set("Authorization", `Bearer ${token}`)
@@ -65,6 +67,9 @@ export async function createAuthenticatedAccount(
value: accountOverrides?.value ?? 1000,
subtype: accountOverrides?.subtype ?? "na",
});
+
+ console.log("Account creation response:", account.status, account.body);
+
if (account.status !== 200 || !account.body.id) {
lastFailure = `account=${account.status} ${JSON.stringify(account.body)}`;
await sleep(500);
@@ -78,6 +83,7 @@ export async function createAuthenticatedAccount(
} catch (error) {
lastFailure =
error instanceof Error ? error.message : "unknown setup error";
+ console.log("Setup error:", lastFailure);
await sleep(500);
}
}
diff --git a/app/server/services/ts/user/src/CheckUser.ts b/app/server/services/ts/user/src/CheckUser.ts
new file mode 100644
index 0000000..3a92f34
--- /dev/null
+++ b/app/server/services/ts/user/src/CheckUser.ts
@@ -0,0 +1,39 @@
+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],
+ );
+
+ 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;
+}
diff --git a/app/server/services/ts/user/src/DataConversion.ts b/app/server/services/ts/user/src/DataConversion.ts
new file mode 100644
index 0000000..b5a35f9
--- /dev/null
+++ b/app/server/services/ts/user/src/DataConversion.ts
@@ -0,0 +1,5 @@
+
+//C0j
+export function convertToDateTime(date: string) {
+ return date.slice(0, 19).replace("T", " ");
+}
diff --git a/app/server/services/ts/user/src/handleJWT.ts b/app/server/services/ts/user/src/handleJWT.ts
index c150c66..fc55203 100644
--- a/app/server/services/ts/user/src/handleJWT.ts
+++ b/app/server/services/ts/user/src/handleJWT.ts
@@ -1,16 +1,17 @@
import jwt from "jsonwebtoken";
import type { Request } from "express";
+import { UnauthorizedAccessError } from "./types/UnauthorizedAccess.js";
const JWT_SECRET = process.env.JWT_SECRET;
export const authenticateJWT = (req: Request) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
- throw new Error("Authorization header missing");
+ throw new UnauthorizedAccessError("Authorization header missing");
}
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
- throw new Error("Invalid authorization header format");
+ throw new UnauthorizedAccessError("Invalid authorization header format");
}
const token = parts[1];
//add as any if it doesn't work
@@ -24,7 +25,7 @@ export const authenticateJWT = (req: Request) => {
const userId = decoded.sub;
//console.log("user id: " + userId);
if (!userId) {
- throw new Error("User ID not found in token");
+ throw new UnauthorizedAccessError("User ID not found in token");
}
return userId;
diff --git a/app/server/services/ts/user/src/index.ts b/app/server/services/ts/user/src/index.ts
index 31302da..5017b71 100644
--- a/app/server/services/ts/user/src/index.ts
+++ b/app/server/services/ts/user/src/index.ts
@@ -21,6 +21,8 @@ import { getGoalCountByProfileId } from "./queries/goals.ts";
import { accountsRouter } from "./routes/account.js";
import { profilesRouter } from "./routes/profile.js";
import { transactionsRouter } from "./routes/transaction.js";
+import { debtRouter } from "./routes/debt.ts";
+import { savingRouter } from "./routes/saving.ts";
const app = express();
app.use(buildCorsConfig());
@@ -28,21 +30,33 @@ onExit(async () => await server.close());
app.use(express.json());
-app.use(
- (req: express.Request, res: express.Response, next: express.NextFunction) => {
- // console.log("USER Incoming request: " + req.method + " " + req.url);
- // console.log(req.body);
- next();
- },
-);
+app.use((req, res, next) => {
+ console.log("USER Incoming request: " + req.method + " " + req.url);
+ console.log(req.body);
+ next();
+});
app.use("/accounts", accountsRouter);
app.use("/transactions", transactionsRouter);
app.use("/profiles", profilesRouter);
+app.use("/debts", debtRouter);
+app.use("/savings", savingRouter);
const server = app.listen(PORT, () => {
console.log(`User Service running on port ${PORT}`);
});
+process.on("SIGTERM", () => cleanup);
+
+ async function cleanup() {
+ try{
+ server.close()
+ await pool.end()
+ } catch(error){
+ console.log(error)
+ }
+}
+
+app.get("/charts/expenses", async (req, res) => {
process.on("SIGTERM", () => server.close());
app.get(
@@ -123,7 +137,7 @@ app.get(
},
);
-export { generateDateRange }; //for testing purposes only
+//export { generateDateRange }; //for testing purposes only
// Goal stuff is below------------------------------------------------------
@@ -291,3 +305,5 @@ app.delete("/goals", async (req: express.Request, res: express.Response) => {
}
}
});
+
+export { generateDateRange }; //for testing purposes only
diff --git a/app/server/services/ts/user/src/logic/debt.ts b/app/server/services/ts/user/src/logic/debt.ts
new file mode 100644
index 0000000..a2ee725
--- /dev/null
+++ b/app/server/services/ts/user/src/logic/debt.ts
@@ -0,0 +1,18 @@
+import type { FinancialAccountRequest } from "../types/FinancialAccountRequest.ts";
+import { addDebt, findDebtsBy } from "../queries/debt.ts";
+import type { DebtInfoResponse } from "../types/DebtInfoResponse.ts";
+import { BadRequestError } from "../types/BadRequestError.ts";
+import { getConnectionPool } from "@/sqlUtil.ts";
+
+const db = getConnectionPool()
+export async function getDebts(userId: string): Promise {
+ return await findDebtsBy(db, userId)
+}
+export async function createNewDebt(debt: FinancialAccountRequest, userId: string): Promise {
+ try{
+ return await addDebt(db, debt,userId)
+ } catch (err) {
+ console.error("Error creating a new saving account: ", err);
+ throw err;
+ }
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/logic/saving.ts b/app/server/services/ts/user/src/logic/saving.ts
new file mode 100644
index 0000000..4148f3f
--- /dev/null
+++ b/app/server/services/ts/user/src/logic/saving.ts
@@ -0,0 +1,41 @@
+import { addSavingAccount, findSavingsBy } from "../queries/saving.ts";
+import type { SavingInfoResponse } from "../types/SavingInfoResponse.ts";
+import type { FinancialAccountRequest } from "../types/FinancialAccountRequest.ts";
+import { findTransactionsBy } from "../queries/transactions.ts";
+import type { TransactionDto } from "../types/TransactionDto.ts";
+import { BadRequestError } from "../types/BadRequestError.ts";
+import { getConnectionPool } from "@/sqlUtil.ts";
+const db = getConnectionPool();
+export async function getSavingAccount(userId: string): Promise {
+ return await findSavingsBy(db, userId);
+}
+export async function getSavingAccountTransactionBy(financialAccountId: string | undefined): Promise {
+ if (!financialAccountId)
+ throw new BadRequestError("Missing financial account id")
+
+ const transactions = await findTransactionsBy(db, financialAccountId);
+ const transationDtos : TransactionDto[] = transactions.map((transation) => ({
+ id: transation.id,
+ amount: transation.amount,
+ category: transation.category,
+ description: transation.description,
+ sender: transation.sender,
+ recipient: transation.recipient,
+ date: transation.date ?
+ new Date(transation.date)
+ .toISOString()
+ .replace("T", " ")
+ .replace(/\.\d{3}Z$/, "")
+ :
+ "N/A"
+ }))
+ return transationDtos;
+}
+export async function createSavingAccount(savingInfo: FinancialAccountRequest, userId: string) : Promise{
+ try {
+ return await addSavingAccount(db, savingInfo, userId);
+ } catch (err) {
+ console.error("Error creating a new saving account: ", err);
+ throw err;
+ }
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/queries/debt.ts b/app/server/services/ts/user/src/queries/debt.ts
new file mode 100644
index 0000000..e9fb460
--- /dev/null
+++ b/app/server/services/ts/user/src/queries/debt.ts
@@ -0,0 +1,74 @@
+import { getConnectionPool } from "@/sqlUtil.ts";
+import { RowDataPacket } from "mysql2";
+import type { PoolConnection, ResultSetHeader, Pool } from "mysql2/promise";
+import type { DebtInfoResponse } from "../types/DebtInfoResponse.ts";
+import type { FinancialAccountRequest } from "../types/FinancialAccountRequest.ts";
+import { FinancialAccountType } from "../types/FinancialAccountType.ts";
+
+export async function findDebtsBy(db: Pool, userId: string) : Promise{
+ const query = `
+ SELECT fa.id, fa.name, fa.balance, fa.subtype, fa.last_updated
+ FROM finus.financialAccount fa
+ JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id
+ JOIN finus.profile p ON pfa.profile_id = p.id
+ JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id
+ WHERE uap.account_id = ?
+ AND fa.type = 'Credit Card'
+ AND fa.subtype = 'Loan'
+ GROUP BY fa.id
+ `;
+
+ const [rows] = await db.execute(query, [userId]);
+ console.log(rows)
+ const result: DebtInfoResponse[] = rows.map((row) => ({
+ id: Number(row.id),
+ name: String(row.name),
+ balance: Number(row.balance),
+ type: FinancialAccountType.CREDIT,
+ subtype: String(row.subtype),
+ lastUpdated: row.last_updated
+ ? new Date(row.last_updated).toISOString()
+ : "N/A",
+ }));
+ return result;
+}
+export async function addDebt(db: Pool, newDebt: FinancialAccountRequest, userId: string): Promise {
+
+ const connection : PoolConnection = await db.getConnection();
+ await connection.beginTransaction();
+
+ try {
+ const query = `INSERT INTO finus.financialAccount (name, type, balance, value, subtype) VALUES (?, ?, ?, ?, ?)`;
+ const params = [newDebt.name, newDebt.type, newDebt.balance, newDebt.value, newDebt.subtype];
+
+ const [result] = await connection.execute(query, params);
+ const financialAccountId = result.insertId;
+
+ const profileQuery = `SELECT * from profile p JOIN finusAccount_profile fp on p.id = fp.profile_id where fp.account_id = ?`
+ const [profiles] = await connection.execute(profileQuery, [userId])
+
+ if (profiles.length > 0) {
+ const values = profiles.map((p) => [p.id, financialAccountId]);
+ const linkQuery = `INSERT INTO finus.profile_financialAccount (profile_id, financialAccount_id) VALUES ?`;
+ await connection.query(linkQuery, [values]);
+ }
+
+ await connection.commit();
+
+
+ console.log("From db", result)
+ return {
+ id: Number(financialAccountId),
+ name: String(newDebt.name),
+ balance: Number(newDebt.balance),
+ subtype: String(newDebt.subtype),
+ lastUpdated: new Date().toISOString().replace("T", " ") ?? "N/A",
+ } as DebtInfoResponse;
+ } catch (err) {
+ // Rollback if anything fails
+ await connection.rollback();
+ throw err;
+ } finally {
+ connection.release();
+ }
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/queries/saving.ts b/app/server/services/ts/user/src/queries/saving.ts
new file mode 100644
index 0000000..841c97a
--- /dev/null
+++ b/app/server/services/ts/user/src/queries/saving.ts
@@ -0,0 +1,77 @@
+import { getConnectionPool } from "@/sqlUtil.ts";
+import { RowDataPacket } from "mysql2";
+import type { PoolConnection, ResultSetHeader, Pool } from "mysql2/promise";
+import type { SavingInfoResponse } from "../types/SavingInfoResponse.ts";
+import type { FinancialAccountRequest } from "../types/FinancialAccountRequest.ts";
+import { FinancialAccountType } from "../types/FinancialAccountType.ts";
+const db = getConnectionPool();
+
+export async function findSavingsBy(db: Pool, userId: string) : Promise{
+ const query = `
+ SELECT fa.id, fa.name, fa.balance, fa.subtype, fa.last_updated
+ FROM finus.financialAccount fa
+ JOIN finus.profile_financialAccount pfa ON fa.id = pfa.financialAccount_id
+ JOIN finus.profile p ON pfa.profile_id = p.id
+ JOIN finus.finusAccount_profile uap ON p.id = uap.profile_id
+ WHERE uap.account_id = ?
+ AND fa.type = 'Savings'
+ GROUP BY fa.id
+ `;
+
+ const [rows] = await db.execute(query, [userId]);
+ console.log(rows)
+ const result: SavingInfoResponse[] = rows.map((row) => ({
+ id: Number(row.id),
+ name: String(row.name),
+ balance: Number(row.balance),
+ type: FinancialAccountType.SAVINGS,
+ subtype: String(row.subtype),
+ lastUpdated: row.last_updated
+ ? new Date(row.last_updated).toISOString()
+ : "N/A",
+ }));
+ return result;
+}
+
+export async function addSavingAccount(db: Pool, newSavings: FinancialAccountRequest, userId: string) : Promise{
+
+ const connection : PoolConnection = await db.getConnection();
+ await connection.beginTransaction();
+
+ try {
+ const { name, type, balance, value, subtype } = newSavings;
+ const query = `INSERT INTO finus.financialAccount (name, type, balance, value, subtype) VALUES (?, ?, ?, ?, ?)`;
+ const params = [name, type, balance, value, subtype];
+
+ const [result] = await connection.execute(query, params);
+ const financialAccountId = result.insertId;
+
+ const profileQuery = `SELECT * from profile p JOIN finusAccount_profile fp on p.id = fp.profile_id where fp.account_id = ?`
+ const [profiles] = await connection.execute(profileQuery, [userId])
+
+ if (profiles.length > 0) {
+ const values = profiles.map((p) => [p.id, financialAccountId]);
+ const linkQuery = `INSERT INTO finus.profile_financialAccount (profile_id, financialAccount_id) VALUES ?`;
+ await connection.query(linkQuery, [values]);
+ }
+
+ await connection.commit();
+
+
+ console.log("From db", result)
+ return {
+ id: Number(financialAccountId),
+ name: String(newSavings.name),
+ balance: Number(newSavings.balance),
+ subtype: String(newSavings.subtype),
+ lastUpdated: new Date().toISOString().replace("T", " ") ?? "N/A",
+ } as SavingInfoResponse;
+ } catch (err) {
+ // Rollback if anything fails
+ await connection.rollback();
+ throw err;
+ } finally {
+ connection.release();
+ }
+}
+
diff --git a/app/server/services/ts/user/src/queries/transactions.ts b/app/server/services/ts/user/src/queries/transactions.ts
index 1ebbe58..88e8243 100644
--- a/app/server/services/ts/user/src/queries/transactions.ts
+++ b/app/server/services/ts/user/src/queries/transactions.ts
@@ -21,6 +21,20 @@ export async function getAllTransactionsQuery(
return rows;
}
+export async function findTransactionsBy(
+ db: Pool,
+ financialAccountId: string,
+): Promise {
+ const query = `
+ SELECT * FROM finus.transaction t
+ WHERE t.financialAccount_id = ?
+ ORDER BY t.date DESC
+ `;
+
+ const [rows] = await db.execute(query, [financialAccountId]);
+ return rows;
+}
+
export async function getDateCategoryTransactionsQuery(
pool: Pool,
profileId: number,
diff --git a/app/server/services/ts/user/src/routes/account.ts b/app/server/services/ts/user/src/routes/account.ts
index 316483e..3a2ed8e 100644
--- a/app/server/services/ts/user/src/routes/account.ts
+++ b/app/server/services/ts/user/src/routes/account.ts
@@ -3,20 +3,25 @@
//TODO import db connection
import { Router } from "express";
import type { Request, Response } from "express";
-import type { ResultSetHeader } from "mysql2";
-import { getConnectionPool } from "@/sqlUtil";
+import type {
+ ResultSetHeader,
+ PoolConnection,
+ RowDataPacket,
+} from "mysql2/promise";
import type { financialAccount } from "@/types.js";
import { authenticateJWT } from "../handleJWT.js";
+import { checkUserId } from "../CheckUser.ts";
+import { pool } from "../db.ts";
//import { authenticateJWT } from "../handleJWT.js";
//import { error } from "node:console";
export const accountsRouter = Router();
-const db = getConnectionPool();
-
accountsRouter.post("/", async (req: Request, res: Response) => {
+ let connection: PoolConnection | undefined;
try {
+ connection = await pool.getConnection();
const { name, type, balance, value, subtype } = req.body;
const last_updated = new Date();
@@ -47,16 +52,28 @@ accountsRouter.post("/", async (req: Request, res: Response) => {
.json({ error: "Not authorized to create an account" });
}
- const [result] = await db.query(
+ //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 connection.query(
+ `SELECT profile_id FROM finusAccount_profile WHERE account_id = ?`,
+ [userId],
+ );
+
+ if (!profileRows || (profileRows as RowDataPacket[]).length === 0) {
+ return res.status(404).json({ error: "User profile not found" });
+ }
+
+ const profileId = (profileRows as RowDataPacket[])[0].profile_id;
+
+ const [result] = await connection.query(
`INSERT INTO financialAccount (name, type, balance, value, last_updated, subtype)
VALUES (?, ?, ?, ?, ?, ?)`,
[name, type, balance, value, last_updated, subtype ?? null],
);
- await db.query(
+ await connection.query(
`INSERT INTO profile_financialAccount (profile_id, financialAccount_id)
- VALUES (?,?)`,
- [userId, result.insertId],
+ VALUES (?, ?)`,
+ [profileId, result.insertId],
);
console.log("Created account " + name);
@@ -68,12 +85,16 @@ accountsRouter.post("/", async (req: Request, res: Response) => {
} catch (err) {
console.error("Account creation failed", err);
return res.status(500).json({ error: "Account creation failed" });
+ } finally {
+ if (connection) {
+ connection.release();
+ }
}
});
accountsRouter.get("/", async (req: Request, res: Response) => {
let userId;
-
+ let connection: PoolConnection | undefined;
try {
userId = authenticateJWT(req);
} catch (err) {
@@ -83,11 +104,25 @@ accountsRouter.get("/", async (req: Request, res: Response) => {
if (userId) {
try {
- const [rows] = await db.query(
+ connection = await pool.getConnection();
+
+ //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 connection.query(
+ `SELECT profile_id FROM finusAccount_profile WHERE account_id = ?`,
+ [userId],
+ );
+
+ if (!profileRows || (profileRows as RowDataPacket[]).length === 0) {
+ return res.status(404).json({ error: "User profile not found" });
+ }
+
+ const profileId = (profileRows as RowDataPacket[])[0].profile_id;
+
+ const [rows] = await connection.query(
`SELECT * FROM financialAccount JOIN profile_financialAccount pfa
ON financialAccount.id = pfa.financialAccount_id
WHERE pfa.profile_id = ?`,
- [userId],
+ [profileId],
);
console.log(rows);
@@ -96,12 +131,17 @@ accountsRouter.get("/", async (req: Request, res: Response) => {
} catch (err) {
console.error("Failed to retrieve user's account", err);
return res.status(500).json({ error: "Failed to retrieve account(s)" });
+ } finally {
+ if (connection) {
+ connection.release();
+ }
}
}
});
accountsRouter.put("/", async (req: Request, res: Response) => {
let userId;
+ let connection: PoolConnection | undefined;
//Checks if was given by the requests
if (isAccount(req.body)) {
@@ -121,20 +161,22 @@ accountsRouter.put("/", async (req: Request, res: Response) => {
.json({ error: "User not authorized to update account" });
}
- //Check if the user is the owner of the account that's bineg updated
- if (!(await checkUserId(userId, account.id))) {
- console.error("User is not own of the account");
- return res
- .status(401)
- .json({ error: "User is not authorized to update accounts" });
- }
-
try {
+ connection = await pool.getConnection();
+
+ //Check if the user is the owner of the account that's bineg updated
+ if (!(await checkUserId(connection, userId, account.id))) {
+ console.error("User is not own of the account");
+ return res
+ .status(401)
+ .json({ error: "User is not authorized to update accounts" });
+ }
+
const { id, name, type, balance, value, subtype } = req.body;
const last_updated = new Date();
- await db.query(
+ await connection.query(
`UPDATE financialAccount
SET name = ?, type = ?, balance = ?, value = ?, last_updated = ?, subtype = ?
WHERE id= ?`,
@@ -149,12 +191,16 @@ accountsRouter.put("/", async (req: Request, res: Response) => {
} catch (err) {
console.error("Failed to update user's account", err);
return res.status(500).json({ error: "Failed to update user's account" });
+ } finally {
+ if (connection) {
+ connection.release();
+ }
}
});
accountsRouter.delete("/", async (req: Request, res: Response) => {
let userId;
-
+ let connection: PoolConnection | undefined;
const { id } = req.body;
try {
@@ -174,52 +220,38 @@ accountsRouter.delete("/", async (req: Request, res: Response) => {
.json({ error: "Bad request: No account was givens" });
}
- //Checks if user is owner of the acount
- if (!(await checkUserId(userId, id))) {
- console.error("User cannot delete account they didn't create");
- return res
- .status(401)
- .json({ error: "User not authorized to delete this account" });
- }
-
try {
- await db.query(
+ connection = await pool.getConnection();
+
+ //Checks if user is owner of the acount
+ if (!(await checkUserId(connection, userId, id))) {
+ console.error("User cannot delete account they didn't create");
+ return res
+ .status(401)
+ .json({ error: "User not authorized to delete this account" });
+ }
+
+ await connection.query(
`DELETE FROM financialAccount
WHERE id=?`,
[id],
);
+ await connection.query(
+ "DELETE FROM transaction WHERE financialAccount_id=?",
+ [id],
+ );
+
return res.status(200).json({ message: "Account successfully deleted" });
} catch (err) {
console.error("Failed to delete user's account", err);
return res.status(500).json({ error: "Failed to delete user's account" });
- }
-});
-
-//Checks the user is the owner of the account
-async function checkUserId(
- userId: number,
- accountId: number,
-): Promise {
- let result = false;
- try {
- //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 =?`,
- [userId, accountId],
- );
-
- console.log(rows.length);
- if (rows.length > 0) {
- result = true;
+ } finally {
+ if (connection) {
+ connection.release();
}
- } catch (error) {
- console.error(error);
}
-
- return result;
-}
+});
function isAccount(reqBody: unknown): reqBody is financialAccount {
//Checks if it exists and is an object
diff --git a/app/server/services/ts/user/src/routes/debt.ts b/app/server/services/ts/user/src/routes/debt.ts
new file mode 100644
index 0000000..8fbe77c
--- /dev/null
+++ b/app/server/services/ts/user/src/routes/debt.ts
@@ -0,0 +1,44 @@
+import { Router } from "express";
+import type { Request, Response } from "express";
+import type { ResultSetHeader } from "mysql2";
+import { pool } from "../db.ts";
+import type { financialAccount } from "@/types.js";
+import { authenticateJWT } from "../handleJWT.js";
+import { UnauthorizedAccessError } from "../types/UnauthorizedAccess.ts";
+import { createNewDebt, getDebts } from "../logic/debt.ts";
+import { BadRequestError } from "../types/BadRequestError.ts";
+export const debtRouter = Router();
+
+debtRouter.get("/", async (req: Request, res: Response) => {
+ try {
+ const userId = authenticateJWT(req);
+ getDebts(userId).then((debts) => {
+ res.status(200).json(debts);
+ });
+ } catch (err: any) {
+ switch (err.constructor) {
+ case UnauthorizedAccessError:
+ console.error("User is not authorized to access debt", err);
+ return res.status(401).json({ error: "User is not authorized to access debt" });
+ default:
+ console.error("Debt access failed", err);
+ res.status(500).json({ error: "Debt access failed" });
+ }
+ }
+});
+debtRouter.post("/", async (req: Request, res: Response) => {
+ try {
+ const userId = authenticateJWT(req);
+ const newDebt = await createNewDebt(req.body, userId);
+ res.status(201).json(newDebt);
+ } catch (err: any) {
+ switch (err.constructor) {
+ case UnauthorizedAccessError:
+ console.error("User is not authorized to create debt", err);
+ return res.status(401).json({ error: "User is not authorized to create debt" });
+ default:
+ console.error("Debt creation failed", err);
+ res.status(500).json({ error: "Debt creation failed" });
+ }
+ }
+});
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/routes/saving.ts b/app/server/services/ts/user/src/routes/saving.ts
new file mode 100644
index 0000000..9384027
--- /dev/null
+++ b/app/server/services/ts/user/src/routes/saving.ts
@@ -0,0 +1,76 @@
+import { Router } from "express";
+import type { Request, Response } from "express";
+import { authenticateJWT } from "../handleJWT.js";
+import { UnauthorizedAccessError } from "../types/UnauthorizedAccess.ts";
+import { createSavingAccount, getSavingAccount, getSavingAccountTransactionBy } from "../logic/saving.ts";
+import { BadRequestError } from "../types/BadRequestError.ts";
+export const savingRouter = Router();
+
+savingRouter.get("/", async (req: Request, res: Response) => {
+ try {
+ const userId = authenticateJWT(req);
+ const savings = await getSavingAccount(userId)
+ res.status(200).json(savings)
+ } catch (err: any) {
+ switch (err.constructor) {
+ case UnauthorizedAccessError:
+ console.error("User is not authorized to access saving", err);
+ return res.status(401).json({ error: "User is not authorized to access saving" });
+ default:
+ console.error("Saving access failed", err);
+ res.status(500).json({ error: "Saving access failed" });
+ }
+ }
+});
+savingRouter.get("/:financialAccountId/transactions", async (req: Request, res: Response) => {
+ try {
+ const userId = authenticateJWT(req);
+ const financialAccountId = req.params.financialAccountId?.toString()
+ console.log("Financial account id: ", financialAccountId)
+ const transactions = await getSavingAccountTransactionBy(financialAccountId)
+ res.status(200).json(transactions)
+ } catch (err: any) {
+ switch (err.constructor) {
+ case BadRequestError:
+ return res.status(400).json({error: err.message})
+ case UnauthorizedAccessError:
+ console.error("User is not authorized to access saving", err);
+ return res.status(401).json({ error: "User is not authorized to access saving" });
+ default:
+ console.error("Saving access failed", err);
+ res.status(500).json({ error: "Saving access failed" });
+ }
+ }
+});
+savingRouter.post("/", async (req: Request, res: Response) => {
+ try {
+ const userId = authenticateJWT(req);
+ const savingAccount = await createSavingAccount(req.body, userId);
+ res.status(201).json({ data: savingAccount });
+ } catch (err: any) {
+ switch (err.constructor) {
+ case UnauthorizedAccessError:
+ console.error("User is not authorized to create saving", err);
+ return res.status(401).json({ error: "User is not authorized to create saving" });
+ default:
+ console.error("Saving creation failed", err);
+ res.status(500).json({ error: "Saving creation failed" });
+ }
+ }
+});
+savingRouter.post("/projected", async (req: Request, res: Response) => {
+ try {
+ const userId = authenticateJWT(req);
+ console.log(req.body)
+ res.status(200).json({ data: "Projected savings calculated successfully" });
+ } catch (err: any) {
+ switch (err.constructor) {
+ case UnauthorizedAccessError:
+ console.error("User is not authorized to create saving", err);
+ return res.status(401).json({ error: "User is not authorized to create saving" });
+ default:
+ console.error("Saving creation failed", err);
+ res.status(500).json({ error: "Saving creation failed" });
+ }
+ }
+});
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/routes/transaction.ts b/app/server/services/ts/user/src/routes/transaction.ts
index 26e7413..3318ee6 100644
--- a/app/server/services/ts/user/src/routes/transaction.ts
+++ b/app/server/services/ts/user/src/routes/transaction.ts
@@ -1,19 +1,22 @@
// transaction routes for creating and updating transactions
import { Router } from "express";
import type { Request, Response } from "express";
-import type { ResultSetHeader } from "mysql2";
-import { getConnectionPool } from "@/sqlUtil";
+import type { PoolConnection, ResultSetHeader } from "mysql2/promise";
import { authenticateJWT } from "../handleJWT.js";
import type { Transaction } from "@/types.js";
+import { checkUserId } from "../CheckUser.ts";
+import { convertToDateTime } from "../DataConversion.ts";
+import { pool as connection, pool } from "../db.ts";
export const transactionsRouter = Router();
-const db = getConnectionPool();
// Get all transactions for a specific financial account
transactionsRouter.get("/", async (req: Request, res: Response) => {
let userId;
+ let connection: PoolConnection | undefined;
try {
+ connection = await pool.getConnection();
const financialAccount_id = Number(req.query.financialAccount_id);
if (!financialAccount_id) {
@@ -29,7 +32,7 @@ transactionsRouter.get("/", async (req: Request, res: Response) => {
.json({ error: "User not authorized to create a transaction" });
}
- if (!(await checkUserId(userId, financialAccount_id))) {
+ if (!(await checkUserId(connection, userId, financialAccount_id))) {
console.error(
"User is not authorized to create a transaction on this account",
);
@@ -38,7 +41,7 @@ transactionsRouter.get("/", async (req: Request, res: Response) => {
});
}
- const [rows] = await db.query(
+ const [rows] = await connection.query(
`SELECT * FROM transaction WHERE financialAccount_id = ?`,
[financialAccount_id],
);
@@ -54,7 +57,9 @@ transactionsRouter.get("/", async (req: Request, res: Response) => {
transactionsRouter.post("/", async (req: Request, res: Response) => {
let userId;
+ let connection: PoolConnection | undefined;
try {
+ connection = await pool.getConnection();
const {
financialAccount_id,
amount,
@@ -76,7 +81,7 @@ transactionsRouter.post("/", async (req: Request, res: Response) => {
.json({ error: "User not authorized to create a transaction" });
}
- if (!(await checkUserId(userId, financialAccount_id))) {
+ if (!(await checkUserId(connection, userId, financialAccount_id))) {
console.error(
"User is not authorized to create a transaction on this account",
);
@@ -85,7 +90,7 @@ transactionsRouter.post("/", async (req: Request, res: Response) => {
});
}
- const [result] = await db.query(
+ const [result] = await connection.query(
`INSERT INTO transaction (financialAccount_id, amount, description, sender, recipient, date, category )
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
@@ -106,14 +111,20 @@ transactionsRouter.post("/", async (req: Request, res: Response) => {
} catch (err) {
console.error("Transaction creation failed", err);
res.status(500).json({ error: "Transaction creation failed" });
+ } finally {
+ if (connection) {
+ connection.release();
+ }
}
});
// Update an existing transaction
transactionsRouter.put("/", async (req: Request, res: Response) => {
let userId;
+ let connection: PoolConnection | undefined;
try {
+ connection = await pool.getConnection();
const {
id,
financialAccount_id,
@@ -135,7 +146,7 @@ transactionsRouter.put("/", async (req: Request, res: Response) => {
.json({ error: "User not authorized to create a transaction" });
}
- if (!(await checkUserId(userId, financialAccount_id))) {
+ if (!(await checkUserId(connection, userId, financialAccount_id))) {
console.error(
"User is not authorized to create a transaction on this account",
);
@@ -144,7 +155,7 @@ transactionsRouter.put("/", async (req: Request, res: Response) => {
});
}
- await db.query(
+ await connection.query(
`UPDATE transaction
SET amount=?, description=?, sender=?, recipient=?, date=?
WHERE id=?`,
@@ -161,6 +172,10 @@ transactionsRouter.put("/", async (req: Request, res: Response) => {
res.json({ message: "Transaction successfully updated" });
} catch (err) {
console.error("Transaction update failed", err);
+ } finally {
+ if (connection) {
+ connection.release();
+ }
}
});
@@ -184,14 +199,14 @@ transactionsRouter.delete("/", async (req: Request, res: Response) => {
.json({ error: "User not authorized to delete transaction" });
}
- if (!(await checkUserId(userId, financialAccount_id))) {
+ if (!(await checkUserId(connection, userId, financialAccount_id))) {
console.error("User is not authorized to delete this transaction");
return res
.status(401)
.json({ error: "User not authorized to delete this transaction" });
}
- await db.query(`DELETE FROM transaction WHERE id=?`, [id]);
+ await connection.query(`DELETE FROM transaction WHERE id=?`, [id]);
res.status(200).json({ message: "Transaction deleted" });
} catch (err) {
@@ -205,8 +220,9 @@ transactionsRouter.post(
"/csvTransaction",
async (req: Request, res: Response) => {
let userId;
-
+ let connection: PoolConnection | undefined;
try {
+ connection = await pool.getConnection();
const { financialAccount_id, transactions } = req.body;
if (!financialAccount_id || !Array.isArray(transactions)) {
@@ -224,7 +240,7 @@ transactionsRouter.post(
}
// Check account ownership
- if (!(await checkUserId(userId, financialAccount_id))) {
+ if (!(await checkUserId(connection, userId, financialAccount_id))) {
console.error("User is not authorized to import into this account");
return res.status(401).json({
error: "User not authorized to import into this account",
@@ -269,8 +285,6 @@ transactionsRouter.post(
.json({ error: "No valid transactions to import" });
}
- const connection = await db.getConnection();
-
try {
await connection.beginTransaction();
@@ -324,33 +338,37 @@ transactionsRouter.post(
} catch (err) {
console.error("CSV transaction import failed", err);
res.status(500).json({ error: "CSV transaction import failed" });
+ } finally {
+ if (connection) {
+ connection.release();
+ }
}
},
);
//Checks the user is the owner of the account
-async function checkUserId(
- userId: number,
- accountId: number,
-): Promise {
- let result = false;
- try {
- //Checks if the profile has an account with that id
- const [rows] = await db.query(
- `SELECT * FROM profile_financialAccount
- WHERE profile_id =? AND financialAccount_id =?`,
- [userId, accountId],
- );
-
- console.log(rows.length);
- result = rows.length > 0;
- } catch (error) {
- console.error(error);
- }
-
- return result;
-}
-
-function convertToDateTime(date: string) {
- return date.slice(0, 19).replace("T", " ");
-}
+// async function checkUserId(
+// userId: number,
+// accountId: number,
+// ): Promise {
+// let result = false;
+// try {
+// //Checks if the profile has an account with that id
+// const [rows] = await db.query(
+// `SELECT * FROM profile_financialAccount
+// WHERE profile_id =? AND financialAccount_id =?`,
+// [userId, accountId],
+// );
+
+// console.log(rows.length);
+// result = rows.length > 0;
+// } catch (error) {
+// console.error(error);
+// }
+
+// return result;
+// }
+
+// function convertToDateTime(date: string) {
+// return date.slice(0, 19).replace("T", " ");
+// }
diff --git a/app/server/services/ts/user/src/types/BadRequestError.ts b/app/server/services/ts/user/src/types/BadRequestError.ts
new file mode 100644
index 0000000..1d6736a
--- /dev/null
+++ b/app/server/services/ts/user/src/types/BadRequestError.ts
@@ -0,0 +1,7 @@
+export class BadRequestError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "UnauthorizedAccessError";
+ Object.setPrototypeOf(this, BadRequestError.prototype);
+ }
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/DebtInfoResponse.ts b/app/server/services/ts/user/src/types/DebtInfoResponse.ts
new file mode 100644
index 0000000..9155782
--- /dev/null
+++ b/app/server/services/ts/user/src/types/DebtInfoResponse.ts
@@ -0,0 +1,9 @@
+import type { FinancialAccountType } from "./FinancialAccountType.ts";
+export interface DebtInfoResponse {
+ id: number;
+ balance: number;
+ name: string;
+ type: FinancialAccountType.CREDIT;
+ subtype: string;
+ lastUpdated?: string;
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/ExpectedDebtDueDate.ts b/app/server/services/ts/user/src/types/ExpectedDebtDueDate.ts
new file mode 100644
index 0000000..8142464
--- /dev/null
+++ b/app/server/services/ts/user/src/types/ExpectedDebtDueDate.ts
@@ -0,0 +1,8 @@
+export interface ExpectedDebtDueDate {
+ id: number;
+ category: string;
+ minimumPayment: number;
+ remainingAmount: number;
+ nextDueDate: string;
+ period: number; // in days
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/ExpectedDebtDueDateResponse.ts b/app/server/services/ts/user/src/types/ExpectedDebtDueDateResponse.ts
new file mode 100644
index 0000000..679e15c
--- /dev/null
+++ b/app/server/services/ts/user/src/types/ExpectedDebtDueDateResponse.ts
@@ -0,0 +1,7 @@
+export interface ExpectedDebtDueDateResponse {
+ id: number;
+ category: string;
+ minimumPayment: number;
+ amountPerInstallment: number[];
+ installmentDates: string[]; // Array of expected due dates in ISO format
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/FinancialAccountRequest.ts b/app/server/services/ts/user/src/types/FinancialAccountRequest.ts
new file mode 100644
index 0000000..1128825
--- /dev/null
+++ b/app/server/services/ts/user/src/types/FinancialAccountRequest.ts
@@ -0,0 +1,20 @@
+import type { FinancialAccountType } from "./FinancialAccountType.ts";
+import type { SavingAccountType } from "./SavingAccountType.ts";
+
+export interface FinancialAccountRequest {
+ /*amount: number;
+ dueDate: string;
+ category: string;
+ interestRate?: number;
+ installment?: {
+ totalInstallments: number;
+ period: number; //in days
+ initialDate: string;
+ minimumPayment?: number;
+ };*/
+ name: string;
+ type: FinancialAccountType;
+ balance: number;
+ value: number;
+ subtype: SavingAccountType | "Loan" | "n/a";
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/FinancialAccountType.ts b/app/server/services/ts/user/src/types/FinancialAccountType.ts
new file mode 100644
index 0000000..9a71622
--- /dev/null
+++ b/app/server/services/ts/user/src/types/FinancialAccountType.ts
@@ -0,0 +1,7 @@
+export enum FinancialAccountType {
+ SAVINGS = 'Savings',
+ CREDIT = 'Credit Card',
+ INVESTMENT = 'Investment',
+ CHEQUING = 'Chequing',
+ UNCONFIRMED = 'Unconfirmed',
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/InvestmentType.ts b/app/server/services/ts/user/src/types/InvestmentType.ts
new file mode 100644
index 0000000..372e2c3
--- /dev/null
+++ b/app/server/services/ts/user/src/types/InvestmentType.ts
@@ -0,0 +1,4 @@
+export enum InvestmentType {
+ STOCK = "stock",
+ FIXED_INTEREST = "fixedInterest"
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/SavingAccountType.ts b/app/server/services/ts/user/src/types/SavingAccountType.ts
new file mode 100644
index 0000000..e79bdab
--- /dev/null
+++ b/app/server/services/ts/user/src/types/SavingAccountType.ts
@@ -0,0 +1,7 @@
+export enum SavingAccountType {
+ RRSP = 'RRSP',
+ TFSA = 'TFSA',
+ FHSA = 'FHSA',
+ RESP = 'RESP',
+ RDSP = 'RDSP'
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/SavingInfoRequest.ts b/app/server/services/ts/user/src/types/SavingInfoRequest.ts
new file mode 100644
index 0000000..fa50fef
--- /dev/null
+++ b/app/server/services/ts/user/src/types/SavingInfoRequest.ts
@@ -0,0 +1,8 @@
+import type { SavingAccountType } from "./SavingAccountType.ts";
+
+export interface SavingInfoRequest {
+ name: string,
+ balance: number,
+ accountType: SavingAccountType,
+ interestRate?: number,
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/SavingInfoResponse.ts b/app/server/services/ts/user/src/types/SavingInfoResponse.ts
new file mode 100644
index 0000000..d3a22af
--- /dev/null
+++ b/app/server/services/ts/user/src/types/SavingInfoResponse.ts
@@ -0,0 +1,11 @@
+import type { FinancialAccountType } from "./FinancialAccountType.ts";
+import type { SavingAccountType } from "./SavingAccountType.ts";
+
+export interface SavingInfoResponse {
+ id: number;
+ balance: number;
+ name: string;
+ type: FinancialAccountType.SAVINGS;
+ subtype: SavingAccountType;
+ lastUpdated?: string;
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/TransactionDto.ts b/app/server/services/ts/user/src/types/TransactionDto.ts
new file mode 100644
index 0000000..df22358
--- /dev/null
+++ b/app/server/services/ts/user/src/types/TransactionDto.ts
@@ -0,0 +1,3 @@
+import type { Transaction } from "./Transaction.ts";
+
+export interface TransactionDto extends Omit{};
\ No newline at end of file
diff --git a/app/server/services/ts/user/src/types/UnauthorizedAccess.ts b/app/server/services/ts/user/src/types/UnauthorizedAccess.ts
new file mode 100644
index 0000000..546fdad
--- /dev/null
+++ b/app/server/services/ts/user/src/types/UnauthorizedAccess.ts
@@ -0,0 +1,8 @@
+export class UnauthorizedAccessError extends Error {
+ constructor(message: string) {
+ super(message); // Call the constructor of the base class `Error`
+ this.name = "UnauthorizedAccessError"; // Set the error name to your custom error class name
+ // Set the prototype explicitly to maintain the correct prototype chain
+ Object.setPrototypeOf(this, UnauthorizedAccessError.prototype);
+ }
+}
\ No newline at end of file
diff --git a/app/server/services/ts/user/test/logic_debts.test.ts b/app/server/services/ts/user/test/logic_debts.test.ts
new file mode 100644
index 0000000..c69a25e
--- /dev/null
+++ b/app/server/services/ts/user/test/logic_debts.test.ts
@@ -0,0 +1,212 @@
+import { vi, describe, expect, it, beforeEach } from "vitest";
+import type { Pool, PoolConnection } from "mysql2/promise";
+import { findDebtsBy, addDebt } from "../src/queries/debt.ts";
+import { FinancialAccountType } from "../src/types/FinancialAccountType.ts";
+import type { FinancialAccountRequest } from "../src/types/FinancialAccountRequest.ts";
+
+describe("findDebtsBy", () => {
+ const subtype = "Loan"
+ it("should return mapped debt accounts", async () => {
+ // Setup fake data
+ const mockRows = [
+ {
+ id: "1",
+ name: "Debt A",
+ balance: 1000,
+ subtype: subtype,
+ last_updated: "2022-10-17T00:00:00.000Z",
+ },
+ {
+ id: "2",
+ name: "Debt B",
+ balance: 9000,
+ subtype: subtype,
+ last_updated: "2026-02-08T19:00:00.000Z",
+ },
+ ];
+
+ const mockDb = {
+ execute: vi.fn().mockResolvedValue([mockRows]),
+ } as unknown as Pool;
+
+ const result = await findDebtsBy(mockDb, "30");
+
+ // Assert
+ expect(mockDb.execute).toHaveBeenCalled();
+
+ expect(result.length).toEqual(2)
+ expect(result[0]).toEqual(
+ {
+ id: 1,
+ name: "Debt A",
+ balance: 1000,
+ type: FinancialAccountType.CREDIT,
+ subtype: subtype,
+ lastUpdated: new Date("2022-10-17T00:00:00.000Z").toISOString(),
+ },
+ );
+ expect(result[1]).toEqual(
+ {
+ id: 2,
+ name: "Debt B",
+ balance: 9000,
+ type: FinancialAccountType.CREDIT,
+ subtype: subtype,
+ lastUpdated: new Date("2026-02-08T19:00:00.000Z").toISOString(),
+ },
+ );
+ });
+
+ it("should return empty array if no rows", async () => {
+ const mockDb = {
+ execute: vi.fn().mockResolvedValue([[]]),
+ } as unknown as Pool;
+
+ const result = await findDebtsBy(mockDb, "20");
+
+ expect(result).toEqual([]);
+ });
+
+ it("should handle null/invalid last_updated safely", async () => {
+ const mockRows = [
+ {
+ id: "3",
+ name: "Debt C",
+ balance: 500,
+ type: FinancialAccountType.CREDIT,
+ subtype: subtype,
+ last_updated: null,
+ },
+ ];
+
+ const mockDb = {
+ execute: vi.fn().mockResolvedValue([mockRows]),
+ } as unknown as Pool;
+
+ const result = await findDebtsBy(mockDb, "50");
+
+ expect(result[0]?.lastUpdated).toBe("N/A");
+ });
+
+ it("should call DB with correct query and userId", async () => {
+ const mockDb = {
+ execute: vi.fn().mockResolvedValue([[]]),
+ } as unknown as Pool;
+
+ await findDebtsBy(mockDb, "1");
+
+ expect(mockDb.execute).toHaveBeenCalledWith(
+ expect.stringContaining("SELECT"),
+ ["1"]
+ );
+ });
+});
+
+describe("addDebt", () => {
+ let mockConnection: Partial;
+ let mockDb: Partial;
+
+ beforeEach(() => {
+ mockConnection = {
+ beginTransaction: vi.fn(),
+ commit: vi.fn(),
+ rollback: vi.fn(),
+ release: vi.fn(),
+ execute: vi.fn(),
+ query: vi.fn(),
+ };
+
+ mockDb = {
+ getConnection: vi.fn().mockResolvedValue(mockConnection),
+ };
+ });
+
+ const newDebts : FinancialAccountRequest = {
+ name: "Testing Debt Financial Account",
+ type: FinancialAccountType.CREDIT,
+ balance: 1000,
+ value: 1000,
+ subtype: "Loan",
+ };
+
+ const userId = "10"
+
+ it("should insert debt account and link profiles", async () => {
+ (mockConnection.execute as any)
+ // INSERT financialAccount
+ .mockResolvedValueOnce([{ insertId: 10 }])
+ // SELECT profiles
+ .mockResolvedValueOnce([[{ id: 1 }, { id: 2 }]]);
+
+ (mockConnection.query as any).mockResolvedValueOnce([]);
+
+ const result = await addDebt(
+ mockDb as Pool,
+ newDebts,
+ userId
+ );
+
+ // Assert
+ expect(mockConnection.beginTransaction).toHaveBeenCalled();
+ expect(mockConnection.execute).toHaveBeenCalledTimes(2);
+ expect(mockConnection.query).toHaveBeenCalled();
+ expect(mockConnection.commit).toHaveBeenCalled();
+ expect(mockConnection.release).toHaveBeenCalled();
+
+ expect(result.id).toBe(10);
+ expect(result.name).toBe("Testing Debt Financial Account");
+ expect(result.balance).toBe(1000);
+ });
+
+ // Should not insert into link table due to no profile found
+ it("should skip linking if no profiles found", async () => {
+ (mockConnection.execute as any)
+ .mockResolvedValueOnce([{ insertId: 20 }])
+ .mockResolvedValueOnce([[]]); // no profiles
+
+ const result = await addDebt(
+ mockDb as Pool,
+ newDebts,
+ userId
+ );
+
+ expect(mockConnection.query).not.toHaveBeenCalled();
+ expect(mockConnection.commit).toHaveBeenCalled();
+ expect(result.id).toBe(20);
+ });
+
+ // Should rollback on error
+ it("should rollback if any query fails", async () => {
+ (mockConnection.execute as any)
+ .mockResolvedValueOnce([{ insertId: 30 }])
+ .mockRejectedValueOnce(new Error("DB error")); // fail on profile query
+
+ await expect(
+ addDebt(mockDb as Pool, newDebts, userId)
+ ).rejects.toThrow("DB error");
+
+ expect(mockConnection.rollback).toHaveBeenCalled();
+ expect(mockConnection.commit).not.toHaveBeenCalled();
+ expect(mockConnection.release).toHaveBeenCalled();
+ });
+
+ // Should call queries with correct params
+ it("should call INSERT with correct values", async () => {
+ (mockConnection.execute as any)
+ .mockResolvedValueOnce([{ insertId: 40 }])
+ .mockResolvedValueOnce([[]]);
+
+ await addDebt(mockDb as Pool, newDebts, "user-1");
+
+ expect(mockConnection.execute).toHaveBeenCalledWith(
+ expect.stringContaining("INSERT INTO finus.financialAccount"),
+ [
+ newDebts.name,
+ newDebts.type,
+ newDebts.balance,
+ newDebts.value,
+ newDebts.subtype,
+ ]
+ );
+ });
+});
\ No newline at end of file
diff --git a/app/server/services/ts/user/test/logic_savings.test.ts b/app/server/services/ts/user/test/logic_savings.test.ts
new file mode 100644
index 0000000..3c14579
--- /dev/null
+++ b/app/server/services/ts/user/test/logic_savings.test.ts
@@ -0,0 +1,121 @@
+import { vi, describe, expect, it, beforeEach } from "vitest";
+import * as transactionQueries from "../src/queries/transactions.ts";
+import { getSavingAccountTransactionBy } from "../src/logic/saving.ts";
+
+vi.mock("../src/queries/transactions", () => ({
+ findTransactionsBy: vi.fn(),
+}));
+
+describe("getSavingAccountTransactionBy", () => {
+ const mockFindTransactionsBy = transactionQueries.findTransactionsBy as vi.Mock;
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // Valid transactions
+ it("should return mapped TransactionDto", async () => {
+ const mockTransactions = [
+ {
+ id: 1,
+ financialAccountId: 1,
+ amount: 45.30,
+ category: "Income",
+ description: "Bi-weekly Tips",
+ sender: "Huy",
+ recipient: "Restaurant Owner Jeff",
+ date: "2026-03-20T18:30:00.456Z",
+ },
+ {
+ id: 2,
+ financialAccountId: 50,
+ amount: 9000,
+ category: "Salary",
+ description: "Monthly salary",
+ sender: "Hospital Manager",
+ recipient: "Huy",
+ date: "2026-03-15T12:00:00.789Z",
+ },
+ ]; //mocking data returned from querying
+
+ mockFindTransactionsBy.mockResolvedValue(mockTransactions);
+
+ const result = await getSavingAccountTransactionBy("1");
+
+ expect(transactionQueries.findTransactionsBy).toHaveBeenCalledWith(
+ expect.anything(),
+ "1"
+ );
+
+ expect(result).toEqual([
+ {
+ id: 1,
+ amount: 45.30,
+ category: "Income",
+ description: "Bi-weekly Tips",
+ sender: "Huy",
+ recipient: "Restaurant Owner Jeff",
+ date: "2026-03-20 18:30:00",
+ },
+ {
+ id: 2,
+ amount: 9000,
+ category: "Salary",
+ description: "Monthly salary",
+ sender: "Hospital Manager",
+ recipient: "Huy",
+ date: "2026-03-15 12:00:00",
+ },
+ ]);
+ });
+
+ // Empty transactions
+ it("should return empty array if no transactions", async () => {
+ mockFindTransactionsBy.mockResolvedValue([]);
+
+ const result = await getSavingAccountTransactionBy("1");
+
+ expect(result).toEqual([]);
+ });
+
+ // Empty date
+ it("should assign N/A to missing date", async () => {
+ const mockTransactions = [
+ {
+ id: 2,
+ financialAccountId: 10,
+ amount: 5000,
+ category: "Student fee savings",
+ description: "savings to pay annual student fee",
+ sender: "Parents",
+ recipient: "Huy",
+ date: null,
+ },
+ ];
+
+ mockFindTransactionsBy.mockResolvedValue(
+ mockTransactions
+ );
+
+ const result = await getSavingAccountTransactionBy("1");
+
+ expect(result[0].date).toBe("N/A");
+ });
+
+ // Throw error for missing financialAccountId
+ it("should throw error if financialAccountId is missing", async () => {
+ await expect(
+ getSavingAccountTransactionBy(undefined)
+ ).rejects.toThrow("Missing financial account id");
+ });
+
+ // Triggering db error
+ it("should throw if findTransactionsBy fails", async () => {
+ mockFindTransactionsBy.mockRejectedValue(
+ new Error("DB error")
+ );
+
+ await expect(
+ getSavingAccountTransactionBy("1")
+ ).rejects.toThrow("DB error");
+ });
+});
\ No newline at end of file
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 0eb7c26..d4fe83c 100644
--- a/app/server/services/ts/user/test/logic_transactions.test.ts
+++ b/app/server/services/ts/user/test/logic_transactions.test.ts
@@ -8,6 +8,7 @@ import {
createEmptyTransactions,
createTransactionsWithMissingFields,
} from "./factories/transaction.factory.ts";
+import type { Transaction } from "../src/types/Transaction.ts";
vi.mock("../src/queries/transactions", () => ({
getAllTransactionsQuery: vi.fn(),
@@ -85,3 +86,4 @@ describe("getTransactionsData", () => {
});
});
});
+
diff --git a/app/server/services/ts/user/test/queries_savings.test.ts b/app/server/services/ts/user/test/queries_savings.test.ts
new file mode 100644
index 0000000..da07a06
--- /dev/null
+++ b/app/server/services/ts/user/test/queries_savings.test.ts
@@ -0,0 +1,211 @@
+import { vi, describe, expect, it, beforeEach } from "vitest";
+import { findSavingsBy, addSavingAccount } from "../src/queries/saving.ts";
+import type { Pool, PoolConnection } from "mysql2/promise";
+import { SavingAccountType } from "../src/types/SavingAccountType.ts";
+import { FinancialAccountType } from "../src/types/FinancialAccountType.ts";
+
+describe("findSavingsBy", () => {
+ it("should return mapped saving accounts", async () => {
+ // Setup fake data
+ const mockRows = [
+ {
+ id: "1",
+ name: "Savings A",
+ balance: 1000,
+ subtype: SavingAccountType.TFSA,
+ last_updated: "2022-10-17T00:00:00.000Z",
+ },
+ {
+ id: "2",
+ name: "Savings B",
+ balance: 9000,
+ subtype: SavingAccountType.RRSP,
+ last_updated: "2026-02-08T19:00:00.000Z",
+ },
+ ];
+
+ const mockDb = {
+ execute: vi.fn().mockResolvedValue([mockRows]),
+ } as unknown as Pool;
+
+ const result = await findSavingsBy(mockDb, "30");
+
+ // Assert
+ expect(mockDb.execute).toHaveBeenCalled();
+
+ expect(result.length).toEqual(2)
+ expect(result[0]).toEqual(
+ {
+ id: 1,
+ name: "Savings A",
+ balance: 1000,
+ type: FinancialAccountType.SAVINGS,
+ subtype: SavingAccountType.TFSA,
+ lastUpdated: new Date("2022-10-17T00:00:00.000Z").toISOString(),
+ },
+ );
+ expect(result[1]).toEqual(
+ {
+ id: 2,
+ name: "Savings B",
+ balance: 9000,
+ type: FinancialAccountType.SAVINGS,
+ subtype: SavingAccountType.RRSP,
+ lastUpdated: new Date("2026-02-08T19:00:00.000Z").toISOString(),
+ },
+ );
+ });
+
+ it("should return empty array if no rows", async () => {
+ const mockDb = {
+ execute: vi.fn().mockResolvedValue([[]]),
+ } as unknown as Pool;
+
+ const result = await findSavingsBy(mockDb, "20");
+
+ expect(result).toEqual([]);
+ });
+
+ it("should handle null/invalid last_updated safely", async () => {
+ const mockRows = [
+ {
+ id: "3",
+ name: "Savings C",
+ balance: 500,
+ type: FinancialAccountType.SAVINGS,
+ subtype: SavingAccountType.RESP,
+ last_updated: null,
+ },
+ ];
+
+ const mockDb = {
+ execute: vi.fn().mockResolvedValue([mockRows]),
+ } as unknown as Pool;
+
+ const result = await findSavingsBy(mockDb, "50");
+
+ expect(result[0]?.lastUpdated).toBe("N/A");
+ });
+
+ it("should call DB with correct query and userId", async () => {
+ const mockDb = {
+ execute: vi.fn().mockResolvedValue([[]]),
+ } as unknown as Pool;
+
+ await findSavingsBy(mockDb, "1");
+
+ expect(mockDb.execute).toHaveBeenCalledWith(
+ expect.stringContaining("SELECT"),
+ ["1"]
+ );
+ });
+});
+
+describe("addSavingAccount", () => {
+ let mockConnection: Partial;
+ let mockDb: Partial;
+
+ beforeEach(() => {
+ mockConnection = {
+ beginTransaction: vi.fn(),
+ commit: vi.fn(),
+ rollback: vi.fn(),
+ release: vi.fn(),
+ execute: vi.fn(),
+ query: vi.fn(),
+ };
+
+ mockDb = {
+ getConnection: vi.fn().mockResolvedValue(mockConnection),
+ };
+ });
+
+ const newSavings = {
+ name: "Test Saving",
+ type: FinancialAccountType.SAVINGS,
+ balance: 1000,
+ value: 1000,
+ subtype: SavingAccountType.RRSP,
+ };
+
+ const userId = "10"
+
+ it("should insert saving account and link profiles", async () => {
+ (mockConnection.execute as any)
+ // INSERT financialAccount
+ .mockResolvedValueOnce([{ insertId: 10 }])
+ // SELECT profiles
+ .mockResolvedValueOnce([[{ id: 1 }, { id: 2 }]]);
+
+ (mockConnection.query as any).mockResolvedValueOnce([]);
+
+ const result = await addSavingAccount(
+ mockDb as Pool,
+ newSavings,
+ userId
+ );
+
+ // Assert
+ expect(mockConnection.beginTransaction).toHaveBeenCalled();
+ expect(mockConnection.execute).toHaveBeenCalledTimes(2);
+ expect(mockConnection.query).toHaveBeenCalled(); // linking table
+ expect(mockConnection.commit).toHaveBeenCalled();
+ expect(mockConnection.release).toHaveBeenCalled();
+
+ expect(result.id).toBe(10);
+ expect(result.name).toBe("Test Saving");
+ expect(result.balance).toBe(1000);
+ });
+
+ // Should not insert into link table due to no profile found
+ it("should skip linking if no profiles found", async () => {
+ (mockConnection.execute as any)
+ .mockResolvedValueOnce([{ insertId: 20 }])
+ .mockResolvedValueOnce([[]]); // no profiles
+
+ const result = await addSavingAccount(
+ mockDb as Pool,
+ newSavings,
+ userId
+ );
+
+ expect(mockConnection.query).not.toHaveBeenCalled();
+ expect(mockConnection.commit).toHaveBeenCalled();
+ expect(result.id).toBe(20);
+ });
+
+ // Should rollback on error
+ it("should rollback if any query fails", async () => {
+ (mockConnection.execute as any)
+ .mockResolvedValueOnce([{ insertId: 30 }])
+ .mockRejectedValueOnce(new Error("DB error")); // fail on profile query
+
+ await expect(
+ addSavingAccount(mockDb as Pool, newSavings, userId)
+ ).rejects.toThrow("DB error");
+
+ expect(mockConnection.rollback).toHaveBeenCalled();
+ expect(mockConnection.commit).not.toHaveBeenCalled();
+ expect(mockConnection.release).toHaveBeenCalled();
+ });
+
+ // Should call queries with correct params
+ it("should call INSERT with correct values", async () => {
+ (mockConnection.execute as any)
+ .mockResolvedValueOnce([{ insertId: 40 }])
+ .mockResolvedValueOnce([[]]);
+
+ await addSavingAccount(mockDb as Pool, newSavings, "user-1");
+
+ expect(mockConnection.execute).toHaveBeenCalledWith(
+ expect.stringContaining("INSERT INTO finus.financialAccount"),
+ [
+ newSavings.name,
+ newSavings.type,
+ newSavings.balance,
+ newSavings.value,
+ newSavings.subtype,
+ ]
+ );
+ });
+});
\ No newline at end of file
diff --git a/documentation/sequence-diagrams/user-projection/user-flow-diagram.png b/documentation/sequence-diagrams/user-projection/user-flow-diagram.png
new file mode 100644
index 0000000..e40ca92
Binary files /dev/null and b/documentation/sequence-diagrams/user-projection/user-flow-diagram.png differ