diff --git a/budget-tracker/.gitignore b/budget-tracker/.gitignore
new file mode 100644
index 000000000..618c20f69
--- /dev/null
+++ b/budget-tracker/.gitignore
@@ -0,0 +1,8 @@
+build/
+.gradle/
+*.class
+*.jar
+*.log
+budget_data.json
+.idea/
+*.iml
diff --git a/budget-tracker/build.gradle b/budget-tracker/build.gradle
new file mode 100644
index 000000000..581533611
--- /dev/null
+++ b/budget-tracker/build.gradle
@@ -0,0 +1,72 @@
+plugins {
+ id 'java'
+ id 'application'
+ id 'jacoco'
+ id 'checkstyle'
+}
+
+group = 'com.budgettracker'
+version = '1.0.0'
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+}
+
+application {
+ mainClass = 'com.budgettracker.BudgetTrackerApp'
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'com.google.code.gson:gson:2.10.1'
+
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
+
+jacoco {
+ toolVersion = '0.8.11'
+}
+
+jacocoTestReport {
+ dependsOn test
+ reports {
+ xml.required = true
+ html.required = true
+ }
+}
+
+jacocoTestCoverageVerification {
+ dependsOn jacocoTestReport
+ violationRules {
+ rule {
+ limit {
+ counter = 'LINE'
+ value = 'COVEREDRATIO'
+ minimum = 0.80
+ }
+ }
+ }
+}
+
+checkstyle {
+ toolVersion = '10.14.2'
+ maxWarnings = 0
+}
+
+tasks.withType(Checkstyle).configureEach {
+ reports {
+ xml.required = true
+ html.required = true
+ }
+}
+
+check.dependsOn jacocoTestCoverageVerification
diff --git a/budget-tracker/config/checkstyle/checkstyle.xml b/budget-tracker/config/checkstyle/checkstyle.xml
new file mode 100644
index 000000000..bf6829f0d
--- /dev/null
+++ b/budget-tracker/config/checkstyle/checkstyle.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/budget-tracker/gradle/wrapper/gradle-wrapper.properties b/budget-tracker/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..b82aa23a4
--- /dev/null
+++ b/budget-tracker/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/budget-tracker/gradlew b/budget-tracker/gradlew
new file mode 100755
index 000000000..1aa94a426
--- /dev/null
+++ b/budget-tracker/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/budget-tracker/gradlew.bat b/budget-tracker/gradlew.bat
new file mode 100644
index 000000000..7101f8e46
--- /dev/null
+++ b/budget-tracker/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/budget-tracker/settings.gradle b/budget-tracker/settings.gradle
new file mode 100644
index 000000000..e2c1c81ee
--- /dev/null
+++ b/budget-tracker/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'budget-tracker'
diff --git a/budget-tracker/src/main/java/com/budgettracker/BudgetTrackerApp.java b/budget-tracker/src/main/java/com/budgettracker/BudgetTrackerApp.java
new file mode 100644
index 000000000..be02dc258
--- /dev/null
+++ b/budget-tracker/src/main/java/com/budgettracker/BudgetTrackerApp.java
@@ -0,0 +1,134 @@
+package com.budgettracker;
+
+import com.budgettracker.service.BudgetService;
+import com.budgettracker.storage.StorageService;
+
+import java.util.Scanner;
+import java.util.Set;
+
+public class BudgetTrackerApp {
+
+ private static final String DATA_FILE = "budget_data.json";
+ private static final String MENU = """
+
+ ============ BUDGET TRACKER ============
+ 1. Add Income
+ 2. Add Expense
+ 3. View Summary
+ 4. List All Transactions
+ 5. Filter by Category
+ 6. Exit
+ ========================================
+ Choose an option (1-6):\s""";
+
+ private final BudgetService budgetService;
+ private final Scanner scanner;
+
+ public BudgetTrackerApp(BudgetService budgetService, Scanner scanner) {
+ this.budgetService = budgetService;
+ this.scanner = scanner;
+ }
+
+ public static void main(String[] args) {
+ StorageService storageService = new StorageService(DATA_FILE);
+ BudgetService budgetService = new BudgetService(storageService);
+ Scanner scanner = new Scanner(System.in);
+
+ BudgetTrackerApp app = new BudgetTrackerApp(budgetService, scanner);
+ app.run();
+ }
+
+ public void run() {
+ System.out.println("Welcome to Budget Tracker!");
+ System.out.println("Your data is saved to: " + DATA_FILE);
+
+ boolean running = true;
+ while (running) {
+ System.out.print(MENU);
+ String choice = scanner.nextLine().trim();
+
+ switch (choice) {
+ case "1" -> handleAddIncome();
+ case "2" -> handleAddExpense();
+ case "3" -> budgetService.printSummary();
+ case "4" -> budgetService.printTransactions();
+ case "5" -> handleFilterByCategory();
+ case "6" -> {
+ System.out.println("Goodbye!");
+ running = false;
+ }
+ default -> System.out.println("Error: Invalid option. Please enter a number between 1 and 6.");
+ }
+ }
+ }
+
+ void handleAddIncome() {
+ System.out.print("Enter description: ");
+ String description = scanner.nextLine();
+
+ double amount = readAmount();
+ if (amount < 0) {
+ return;
+ }
+
+ try {
+ budgetService.addIncome(description, amount);
+ System.out.printf("Income added: %s - $%.2f%n", description.trim(), amount);
+ } catch (RuntimeException e) {
+ System.out.println("Error: " + e.getMessage());
+ }
+ }
+
+ void handleAddExpense() {
+ System.out.print("Enter category (e.g., Food, Transport, Rent): ");
+ String category = scanner.nextLine();
+
+ System.out.print("Enter description: ");
+ String description = scanner.nextLine();
+
+ double amount = readAmount();
+ if (amount < 0) {
+ return;
+ }
+
+ try {
+ budgetService.addExpense(category, description, amount);
+ System.out.printf("Expense added: [%s] %s - $%.2f%n",
+ category.trim(), description.trim(), amount);
+ } catch (RuntimeException e) {
+ System.out.println("Error: " + e.getMessage());
+ }
+ }
+
+ void handleFilterByCategory() {
+ Set categories = budgetService.getCategories();
+ if (categories.isEmpty()) {
+ System.out.println("No categories found. Add some expenses first.");
+ return;
+ }
+ System.out.println("Available categories: " + String.join(", ", categories));
+ System.out.print("Enter category to filter by: ");
+ String category = scanner.nextLine();
+ try {
+ budgetService.printFilteredTransactions(category);
+ } catch (RuntimeException e) {
+ System.out.println("Error: " + e.getMessage());
+ }
+ }
+
+ double readAmount() {
+ System.out.print("Enter amount: ");
+ String input = scanner.nextLine().trim();
+ try {
+ double amount = Double.parseDouble(input);
+ if (amount <= 0 || !Double.isFinite(amount)) {
+ System.out.println("Error: Amount must be a positive number.");
+ return -1;
+ }
+ return amount;
+ } catch (NumberFormatException e) {
+ System.out.println("Error: Invalid amount. Please enter a valid number (e.g., 100.50).");
+ return -1;
+ }
+ }
+}
diff --git a/budget-tracker/src/main/java/com/budgettracker/model/Transaction.java b/budget-tracker/src/main/java/com/budgettracker/model/Transaction.java
new file mode 100644
index 000000000..72deacc79
--- /dev/null
+++ b/budget-tracker/src/main/java/com/budgettracker/model/Transaction.java
@@ -0,0 +1,56 @@
+package com.budgettracker.model;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+public class Transaction {
+
+ private static final DateTimeFormatter DISPLAY_FORMAT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private TransactionType type;
+ private String category;
+ private String description;
+ private double amount;
+ private String timestamp;
+
+ public Transaction(TransactionType type, String category, String description, double amount) {
+ this.type = type;
+ this.category = category;
+ this.description = description;
+ this.amount = amount;
+ this.timestamp = LocalDateTime.now().format(DISPLAY_FORMAT);
+ }
+
+ public TransactionType getType() {
+ return type;
+ }
+
+ public String getCategory() {
+ return category;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public double getAmount() {
+ return amount;
+ }
+
+ public String getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(String timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public String toString() {
+ String typeStr = type == TransactionType.INCOME ? "INCOME " : "EXPENSE";
+ String categoryStr = (category == null || category.isEmpty()) ? "N/A" : category;
+ return String.format("%-22s %-8s %-14s %-25s $%.2f",
+ timestamp, typeStr, categoryStr, description, amount);
+ }
+}
diff --git a/budget-tracker/src/main/java/com/budgettracker/model/TransactionType.java b/budget-tracker/src/main/java/com/budgettracker/model/TransactionType.java
new file mode 100644
index 000000000..b62a0aed4
--- /dev/null
+++ b/budget-tracker/src/main/java/com/budgettracker/model/TransactionType.java
@@ -0,0 +1,6 @@
+package com.budgettracker.model;
+
+public enum TransactionType {
+ INCOME,
+ EXPENSE
+}
diff --git a/budget-tracker/src/main/java/com/budgettracker/service/BudgetService.java b/budget-tracker/src/main/java/com/budgettracker/service/BudgetService.java
new file mode 100644
index 000000000..ebc0d0fab
--- /dev/null
+++ b/budget-tracker/src/main/java/com/budgettracker/service/BudgetService.java
@@ -0,0 +1,152 @@
+package com.budgettracker.service;
+
+import com.budgettracker.model.Transaction;
+import com.budgettracker.model.TransactionType;
+import com.budgettracker.storage.StorageService;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+public class BudgetService {
+
+ private final List transactions;
+ private final StorageService storageService;
+
+ public BudgetService(StorageService storageService) {
+ this.storageService = storageService;
+ this.transactions = storageService.load();
+ }
+
+ public Transaction addIncome(String description, double amount) {
+ if (description == null || description.trim().isEmpty()) {
+ throw new IllegalArgumentException("Description cannot be empty.");
+ }
+ if (amount <= 0 || !Double.isFinite(amount)) {
+ throw new IllegalArgumentException("Amount must be a positive number.");
+ }
+ Transaction transaction = new Transaction(TransactionType.INCOME, "", description.trim(), amount);
+ transactions.add(transaction);
+ if (!storageService.save(transactions)) {
+ transactions.remove(transactions.size() - 1);
+ throw new RuntimeException("Failed to save transaction to disk.");
+ }
+ return transaction;
+ }
+
+ public Transaction addExpense(String category, String description, double amount) {
+ if (category == null || category.trim().isEmpty()) {
+ throw new IllegalArgumentException("Category cannot be empty.");
+ }
+ if (description == null || description.trim().isEmpty()) {
+ throw new IllegalArgumentException("Description cannot be empty.");
+ }
+ if (amount <= 0 || !Double.isFinite(amount)) {
+ throw new IllegalArgumentException("Amount must be a positive number.");
+ }
+ Transaction transaction = new Transaction(
+ TransactionType.EXPENSE, category.trim(), description.trim(), amount);
+ transactions.add(transaction);
+ if (!storageService.save(transactions)) {
+ transactions.remove(transactions.size() - 1);
+ throw new RuntimeException("Failed to save transaction to disk.");
+ }
+ return transaction;
+ }
+
+ public double getTotalIncome() {
+ return transactions.stream()
+ .filter(t -> t.getType() == TransactionType.INCOME)
+ .mapToDouble(Transaction::getAmount)
+ .sum();
+ }
+
+ public double getTotalExpenses() {
+ return transactions.stream()
+ .filter(t -> t.getType() == TransactionType.EXPENSE)
+ .mapToDouble(Transaction::getAmount)
+ .sum();
+ }
+
+ public double getNetBalance() {
+ return getTotalIncome() - getTotalExpenses();
+ }
+
+ public List getTransactions() {
+ return Collections.unmodifiableList(transactions);
+ }
+
+ public Set getCategories() {
+ return transactions.stream()
+ .map(Transaction::getCategory)
+ .filter(c -> c != null && !c.isEmpty())
+ .collect(Collectors.toCollection(TreeSet::new));
+ }
+
+ public List filterByCategory(String category) {
+ if (category == null || category.trim().isEmpty()) {
+ throw new IllegalArgumentException("Category cannot be empty.");
+ }
+ String target = category.trim().toLowerCase();
+ return transactions.stream()
+ .filter(t -> t.getCategory() != null
+ && t.getCategory().toLowerCase().equals(target))
+ .collect(Collectors.toList());
+ }
+
+ public void printFilteredTransactions(String category) {
+ List filtered = filterByCategory(category);
+ System.out.println();
+ if (filtered.isEmpty()) {
+ System.out.printf("No transactions found for category: %s%n", category.trim());
+ System.out.println();
+ return;
+ }
+ System.out.printf("============ TRANSACTIONS: %s ============%n", category.trim().toUpperCase());
+ System.out.printf("%-22s %-8s %-14s %-25s %s%n",
+ "Date/Time", "Type", "Category", "Description", "Amount");
+ System.out.println("--------------------------------------------------------");
+ for (Transaction t : filtered) {
+ System.out.println(t);
+ }
+ System.out.println("========================================================");
+ System.out.printf("Total: %d transaction(s)%n", filtered.size());
+ System.out.println();
+ }
+
+ public void printSummary() {
+ double income = getTotalIncome();
+ double expenses = getTotalExpenses();
+ double balance = getNetBalance();
+
+ System.out.println();
+ System.out.println("==================== BUDGET SUMMARY ====================");
+ System.out.printf(" Total Income: $%10.2f%n", income);
+ System.out.printf(" Total Expenses: $%10.2f%n", expenses);
+ System.out.println("--------------------------------------------------------");
+ System.out.printf(" Net Balance: $%10.2f%n", balance);
+ System.out.println("========================================================");
+ System.out.println();
+ }
+
+ public void printTransactions() {
+ System.out.println();
+ if (transactions.isEmpty()) {
+ System.out.println("No transactions recorded yet.");
+ System.out.println();
+ return;
+ }
+ System.out.println("=================== ALL TRANSACTIONS ===================");
+ System.out.printf("%-22s %-8s %-14s %-25s %s%n",
+ "Date/Time", "Type", "Category", "Description", "Amount");
+ System.out.println("--------------------------------------------------------");
+ for (Transaction t : transactions) {
+ System.out.println(t);
+ }
+ System.out.println("========================================================");
+ System.out.printf("Total: %d transaction(s)%n", transactions.size());
+ System.out.println();
+ }
+}
diff --git a/budget-tracker/src/main/java/com/budgettracker/storage/StorageService.java b/budget-tracker/src/main/java/com/budgettracker/storage/StorageService.java
new file mode 100644
index 000000000..5ecaa346b
--- /dev/null
+++ b/budget-tracker/src/main/java/com/budgettracker/storage/StorageService.java
@@ -0,0 +1,82 @@
+package com.budgettracker.storage;
+
+import com.budgettracker.model.Transaction;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonIOException;
+import com.google.gson.reflect.TypeToken;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Writer;
+import java.lang.reflect.Type;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.List;
+
+public class StorageService {
+
+ private static final Type TRANSACTION_LIST_TYPE =
+ new TypeToken>() {}.getType();
+
+ private final Path filePath;
+ private final Gson gson;
+
+ public StorageService(String filePath) {
+ this.filePath = Path.of(filePath);
+ this.gson = new GsonBuilder().setPrettyPrinting().create();
+ }
+
+ public List load() {
+ if (!Files.exists(filePath)) {
+ return new ArrayList<>();
+ }
+ try (Reader reader = Files.newBufferedReader(filePath)) {
+ List transactions = gson.fromJson(reader, TRANSACTION_LIST_TYPE);
+ return transactions != null ? new ArrayList<>(transactions) : new ArrayList<>();
+ } catch (IOException e) {
+ System.err.println("Error reading data file: " + e.getMessage());
+ return new ArrayList<>();
+ } catch (com.google.gson.JsonSyntaxException e) {
+ System.err.println("Error: Data file is corrupted. Starting with empty data.");
+ return new ArrayList<>();
+ } catch (JsonIOException e) {
+ System.err.println("Error reading data file: " + e.getMessage());
+ return new ArrayList<>();
+ }
+ }
+
+ public boolean save(List transactions) {
+ Path tmpPath = Path.of(filePath + ".tmp");
+ try {
+ Path parent = filePath.getParent();
+ if (parent != null && !Files.exists(parent)) {
+ Files.createDirectories(parent);
+ }
+ try (Writer writer = Files.newBufferedWriter(tmpPath)) {
+ gson.toJson(transactions, writer);
+ }
+ Files.move(tmpPath, filePath,
+ StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ return true;
+ } catch (IOException e) {
+ System.err.println("Error saving data: " + e.getMessage());
+ deleteTmpFile(tmpPath);
+ return false;
+ } catch (JsonIOException e) {
+ System.err.println("Error saving data: " + e.getMessage());
+ deleteTmpFile(tmpPath);
+ return false;
+ }
+ }
+
+ private void deleteTmpFile(Path tmpPath) {
+ try {
+ Files.deleteIfExists(tmpPath);
+ } catch (IOException ignored) {
+ // best-effort cleanup
+ }
+ }
+}
diff --git a/budget-tracker/src/test/java/com/budgettracker/BudgetTrackerAppTest.java b/budget-tracker/src/test/java/com/budgettracker/BudgetTrackerAppTest.java
new file mode 100644
index 000000000..7713b667e
--- /dev/null
+++ b/budget-tracker/src/test/java/com/budgettracker/BudgetTrackerAppTest.java
@@ -0,0 +1,312 @@
+package com.budgettracker;
+
+import com.budgettracker.service.BudgetService;
+import com.budgettracker.storage.StorageService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.file.Path;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class BudgetTrackerAppTest {
+
+ @TempDir
+ Path tempDir;
+
+ private BudgetService budgetService;
+ private ByteArrayOutputStream outputStream;
+ private PrintStream originalOut;
+
+ @BeforeEach
+ void setUp() {
+ String filePath = tempDir.resolve("test.json").toString();
+ StorageService storageService = new StorageService(filePath);
+ budgetService = new BudgetService(storageService);
+ originalOut = System.out;
+ outputStream = new ByteArrayOutputStream();
+ System.setOut(new PrintStream(outputStream));
+ }
+
+ @org.junit.jupiter.api.AfterEach
+ void tearDown() {
+ System.setOut(originalOut);
+ }
+
+ private BudgetTrackerApp createApp(String input) {
+ Scanner scanner = new Scanner(input);
+ return new BudgetTrackerApp(budgetService, scanner);
+ }
+
+ @Test
+ void addIncomeFlow() {
+ String input = "1\nSalary\n5000\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Income added: Salary - $5000.00"));
+ assertEquals(1, budgetService.getTransactions().size());
+ assertEquals(5000.0, budgetService.getTotalIncome(), 0.01);
+
+ }
+
+ @Test
+ void addExpenseFlow() {
+ String input = "2\nFood\nGroceries\n150.50\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Expense added: [Food] Groceries - $150.50"));
+ assertEquals(1, budgetService.getTransactions().size());
+ assertEquals(150.50, budgetService.getTotalExpenses(), 0.01);
+
+ }
+
+ @Test
+ void viewSummaryFlow() {
+ budgetService.addIncome("Salary", 5000);
+ budgetService.addExpense("Food", "Lunch", 25);
+
+ String input = "3\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("BUDGET SUMMARY"));
+
+ }
+
+ @Test
+ void listTransactionsFlow() {
+ budgetService.addIncome("Salary", 3000);
+ budgetService.addExpense("Rent", "Monthly", 1200);
+
+ String input = "4\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("ALL TRANSACTIONS"));
+ assertTrue(output.contains("Salary"));
+ assertTrue(output.contains("Monthly"));
+
+ }
+
+ @Test
+ void invalidMenuOption() {
+ String input = "9\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Invalid option"));
+
+ }
+
+ @Test
+ void invalidAmountShowsError() {
+ String input = "1\nSalary\nabc\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Invalid amount"));
+ assertEquals(0, budgetService.getTransactions().size());
+
+ }
+
+ @Test
+ void negativeAmountShowsError() {
+ String input = "1\nSalary\n-100\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("must be a positive number"));
+ assertEquals(0, budgetService.getTransactions().size());
+
+ }
+
+ @Test
+ void zeroAmountShowsError() {
+ String input = "2\nFood\nLunch\n0\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("must be a positive number"));
+
+ }
+
+ @Test
+ void emptyDescriptionForIncomeShowsError() {
+ String input = "1\n\n100\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Description cannot be empty"));
+
+ }
+
+ @Test
+ void emptyFieldsForExpenseShowsError() {
+ String input = "2\n\nGroceries\n100\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Category cannot be empty"));
+
+ }
+
+ @Test
+ void exitShowsGoodbye() {
+ String input = "6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ assertTrue(outputStream.toString().contains("Goodbye!"));
+
+ }
+
+ @Test
+ void multipleOperationsInSequence() {
+ String input = "1\nSalary\n5000\n2\nFood\nGroceries\n200\n3\n4\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Income added"));
+ assertTrue(output.contains("Expense added"));
+ assertTrue(output.contains("BUDGET SUMMARY"));
+ assertTrue(output.contains("ALL TRANSACTIONS"));
+ assertEquals(2, budgetService.getTransactions().size());
+
+ }
+
+ @Test
+ void handleAddIncomeDirectly() {
+ String input = "Test Income\n250.75\n";
+ BudgetTrackerApp app = createApp(input);
+ app.handleAddIncome();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Income added: Test Income - $250.75"));
+
+ }
+
+ @Test
+ void handleAddExpenseDirectly() {
+ String input = "Transport\nBus fare\n3.50\n";
+ BudgetTrackerApp app = createApp(input);
+ app.handleAddExpense();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Expense added: [Transport] Bus fare - $3.50"));
+
+ }
+
+ @Test
+ void readAmountWithValidInput() {
+ String input = "42.99\n";
+ BudgetTrackerApp app = createApp(input);
+ double amount = app.readAmount();
+ assertEquals(42.99, amount, 0.01);
+
+ }
+
+ @Test
+ void readAmountWithInvalidInput() {
+ String input = "not_a_number\n";
+ BudgetTrackerApp app = createApp(input);
+ double amount = app.readAmount();
+ assertEquals(-1, amount, 0.01);
+
+ }
+
+ @Test
+ void readAmountWithNegativeInput() {
+ String input = "-50\n";
+ BudgetTrackerApp app = createApp(input);
+ double amount = app.readAmount();
+ assertEquals(-1, amount, 0.01);
+
+ }
+
+ @Test
+ void readAmountRejectsNaN() {
+ String input = "NaN\n";
+ BudgetTrackerApp app = createApp(input);
+ double amount = app.readAmount();
+ assertEquals(-1, amount, 0.01);
+ }
+
+ @Test
+ void readAmountRejectsInfinity() {
+ String input = "Infinity\n";
+ BudgetTrackerApp app = createApp(input);
+ double amount = app.readAmount();
+ assertEquals(-1, amount, 0.01);
+ }
+
+ @Test
+ void filterByCategoryFlow() {
+ budgetService.addExpense("Food", "Lunch", 15);
+ budgetService.addExpense("Food", "Dinner", 30);
+ budgetService.addExpense("Transport", "Bus", 5);
+
+ String input = "5\nFood\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("TRANSACTIONS: FOOD"));
+ assertTrue(output.contains("Lunch"));
+ assertTrue(output.contains("Dinner"));
+ assertFalse(output.contains("Bus"));
+ }
+
+ @Test
+ void filterByCategoryNoMatch() {
+ budgetService.addExpense("Food", "Lunch", 15);
+
+ String input = "5\nRent\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("No transactions found for category: Rent"));
+ }
+
+ @Test
+ void filterByCategoryNoCategories() {
+ String input = "5\n6\n";
+ BudgetTrackerApp app = createApp(input);
+ app.run();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("No categories found"));
+ }
+
+ @Test
+ void handleFilterByCategoryDirectly() {
+ budgetService.addExpense("Food", "Lunch", 15);
+ budgetService.addExpense("Transport", "Bus", 5);
+
+ String input = "Food\n";
+ BudgetTrackerApp app = createApp(input);
+ app.handleFilterByCategory();
+
+ String output = outputStream.toString();
+ assertTrue(output.contains("Available categories: Food, Transport"));
+ assertTrue(output.contains("TRANSACTIONS: FOOD"));
+ assertTrue(output.contains("Lunch"));
+ }
+}
diff --git a/budget-tracker/src/test/java/com/budgettracker/model/TransactionTest.java b/budget-tracker/src/test/java/com/budgettracker/model/TransactionTest.java
new file mode 100644
index 000000000..17bca8c46
--- /dev/null
+++ b/budget-tracker/src/test/java/com/budgettracker/model/TransactionTest.java
@@ -0,0 +1,56 @@
+package com.budgettracker.model;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TransactionTest {
+
+ @Test
+ void incomeTransactionCreation() {
+ Transaction t = new Transaction(TransactionType.INCOME, "", "Salary", 5000.0);
+ assertEquals(TransactionType.INCOME, t.getType());
+ assertEquals("", t.getCategory());
+ assertEquals("Salary", t.getDescription());
+ assertEquals(5000.0, t.getAmount());
+ assertNotNull(t.getTimestamp());
+ }
+
+ @Test
+ void expenseTransactionCreation() {
+ Transaction t = new Transaction(TransactionType.EXPENSE, "Food", "Groceries", 150.0);
+ assertEquals(TransactionType.EXPENSE, t.getType());
+ assertEquals("Food", t.getCategory());
+ assertEquals("Groceries", t.getDescription());
+ assertEquals(150.0, t.getAmount());
+ }
+
+ @Test
+ void setTimestamp() {
+ Transaction t = new Transaction(TransactionType.INCOME, "", "Test", 100.0);
+ t.setTimestamp("2024-01-15 10:30:00");
+ assertEquals("2024-01-15 10:30:00", t.getTimestamp());
+ }
+
+ @Test
+ void toStringForIncome() {
+ Transaction t = new Transaction(TransactionType.INCOME, "", "Salary", 5000.0);
+ t.setTimestamp("2024-01-15 10:30:00");
+ String str = t.toString();
+ assertTrue(str.contains("INCOME"));
+ assertTrue(str.contains("N/A"));
+ assertTrue(str.contains("Salary"));
+ assertTrue(str.contains("5000.00"));
+ }
+
+ @Test
+ void toStringForExpense() {
+ Transaction t = new Transaction(TransactionType.EXPENSE, "Food", "Groceries", 150.0);
+ t.setTimestamp("2024-01-15 10:30:00");
+ String str = t.toString();
+ assertTrue(str.contains("EXPENSE"));
+ assertTrue(str.contains("Food"));
+ assertTrue(str.contains("Groceries"));
+ assertTrue(str.contains("150.00"));
+ }
+}
diff --git a/budget-tracker/src/test/java/com/budgettracker/service/BudgetServiceTest.java b/budget-tracker/src/test/java/com/budgettracker/service/BudgetServiceTest.java
new file mode 100644
index 000000000..a9e560395
--- /dev/null
+++ b/budget-tracker/src/test/java/com/budgettracker/service/BudgetServiceTest.java
@@ -0,0 +1,293 @@
+package com.budgettracker.service;
+
+import com.budgettracker.model.Transaction;
+import com.budgettracker.model.TransactionType;
+import com.budgettracker.storage.StorageService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class BudgetServiceTest {
+
+ @TempDir
+ Path tempDir;
+
+ private BudgetService budgetService;
+ private PrintStream originalOut;
+
+ @BeforeEach
+ void setUp() {
+ String filePath = tempDir.resolve("test_budget.json").toString();
+ StorageService storageService = new StorageService(filePath);
+ budgetService = new BudgetService(storageService);
+ originalOut = System.out;
+ }
+
+ @AfterEach
+ void tearDown() {
+ System.setOut(originalOut);
+ }
+
+ @Test
+ void addIncomeSuccessfully() {
+ Transaction t = budgetService.addIncome("Salary", 5000.0);
+ assertEquals(TransactionType.INCOME, t.getType());
+ assertEquals("Salary", t.getDescription());
+ assertEquals(5000.0, t.getAmount());
+ assertEquals("", t.getCategory());
+ }
+
+ @Test
+ void addIncomeTrimsDescription() {
+ Transaction t = budgetService.addIncome(" Freelance Work ", 1000.0);
+ assertEquals("Freelance Work", t.getDescription());
+ }
+
+ @Test
+ void addIncomeRejectsEmptyDescription() {
+ assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome("", 100.0));
+ assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome(" ", 100.0));
+ assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome(null, 100.0));
+ }
+
+ @Test
+ void addIncomeRejectsNonPositiveAmount() {
+ assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome("Salary", 0));
+ assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome("Salary", -100));
+ }
+
+ @Test
+ void addExpenseSuccessfully() {
+ Transaction t = budgetService.addExpense("Food", "Groceries", 150.0);
+ assertEquals(TransactionType.EXPENSE, t.getType());
+ assertEquals("Food", t.getCategory());
+ assertEquals("Groceries", t.getDescription());
+ assertEquals(150.0, t.getAmount());
+ }
+
+ @Test
+ void addExpenseTrimsInputs() {
+ Transaction t = budgetService.addExpense(" Transport ", " Bus fare ", 50.0);
+ assertEquals("Transport", t.getCategory());
+ assertEquals("Bus fare", t.getDescription());
+ }
+
+ @Test
+ void addExpenseRejectsEmptyCategory() {
+ assertThrows(IllegalArgumentException.class,
+ () -> budgetService.addExpense("", "Groceries", 100.0));
+ assertThrows(IllegalArgumentException.class,
+ () -> budgetService.addExpense(null, "Groceries", 100.0));
+ }
+
+ @Test
+ void addExpenseRejectsEmptyDescription() {
+ assertThrows(IllegalArgumentException.class,
+ () -> budgetService.addExpense("Food", "", 100.0));
+ assertThrows(IllegalArgumentException.class,
+ () -> budgetService.addExpense("Food", null, 100.0));
+ }
+
+ @Test
+ void addExpenseRejectsNonPositiveAmount() {
+ assertThrows(IllegalArgumentException.class,
+ () -> budgetService.addExpense("Food", "Groceries", 0));
+ assertThrows(IllegalArgumentException.class,
+ () -> budgetService.addExpense("Food", "Groceries", -50));
+ }
+
+ @Test
+ void getTotalIncome() {
+ budgetService.addIncome("Salary", 5000.0);
+ budgetService.addIncome("Bonus", 1000.0);
+ budgetService.addExpense("Food", "Groceries", 200.0);
+ assertEquals(6000.0, budgetService.getTotalIncome(), 0.01);
+ }
+
+ @Test
+ void getTotalExpenses() {
+ budgetService.addIncome("Salary", 5000.0);
+ budgetService.addExpense("Food", "Groceries", 200.0);
+ budgetService.addExpense("Transport", "Bus", 50.0);
+ assertEquals(250.0, budgetService.getTotalExpenses(), 0.01);
+ }
+
+ @Test
+ void getNetBalance() {
+ budgetService.addIncome("Salary", 5000.0);
+ budgetService.addExpense("Rent", "Monthly rent", 1500.0);
+ budgetService.addExpense("Food", "Groceries", 300.0);
+ assertEquals(3200.0, budgetService.getNetBalance(), 0.01);
+ }
+
+ @Test
+ void getNetBalanceIsZeroWhenNoTransactions() {
+ assertEquals(0.0, budgetService.getNetBalance(), 0.01);
+ }
+
+ @Test
+ void getTransactionsReturnsUnmodifiableList() {
+ budgetService.addIncome("Salary", 5000.0);
+ List transactions = budgetService.getTransactions();
+ assertThrows(UnsupportedOperationException.class,
+ () -> transactions.add(new Transaction(TransactionType.INCOME, "", "Hack", 1.0)));
+ }
+
+ @Test
+ void getTransactionsInChronologicalOrder() {
+ budgetService.addIncome("First", 100.0);
+ budgetService.addExpense("Cat", "Second", 200.0);
+ budgetService.addIncome("Third", 300.0);
+
+ List transactions = budgetService.getTransactions();
+ assertEquals(3, transactions.size());
+ assertEquals("First", transactions.get(0).getDescription());
+ assertEquals("Second", transactions.get(1).getDescription());
+ assertEquals("Third", transactions.get(2).getDescription());
+ }
+
+ @Test
+ void printSummaryOutputsCorrectFormat() {
+ budgetService.addIncome("Salary", 5000.0);
+ budgetService.addExpense("Food", "Groceries", 200.0);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ System.setOut(new PrintStream(out));
+
+ budgetService.printSummary();
+
+ String output = out.toString();
+ assertTrue(output.contains("BUDGET SUMMARY"));
+ assertTrue(output.contains("5000.00"));
+ assertTrue(output.contains("200.00"));
+ assertTrue(output.contains("4800.00"));
+ }
+
+ @Test
+ void printTransactionsWhenEmpty() {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ System.setOut(new PrintStream(out));
+
+ budgetService.printTransactions();
+
+ assertTrue(out.toString().contains("No transactions recorded yet."));
+ }
+
+ @Test
+ void printTransactionsShowsAllEntries() {
+ budgetService.addIncome("Salary", 5000.0);
+ budgetService.addExpense("Food", "Groceries", 200.0);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ System.setOut(new PrintStream(out));
+
+ budgetService.printTransactions();
+
+ String output = out.toString();
+ assertTrue(output.contains("ALL TRANSACTIONS"));
+ assertTrue(output.contains("Salary"));
+ assertTrue(output.contains("Groceries"));
+ assertTrue(output.contains("2 transaction(s)"));
+ }
+
+ @Test
+ void getCategoriesReturnsDistinctSortedCategories() {
+ budgetService.addExpense("Food", "Lunch", 10);
+ budgetService.addExpense("Transport", "Bus", 5);
+ budgetService.addExpense("Food", "Dinner", 20);
+ budgetService.addIncome("Salary", 5000);
+
+ java.util.Set categories = budgetService.getCategories();
+ assertEquals(2, categories.size());
+ assertTrue(categories.contains("Food"));
+ assertTrue(categories.contains("Transport"));
+ }
+
+ @Test
+ void getCategoriesEmptyWhenNoExpenses() {
+ budgetService.addIncome("Salary", 5000);
+ assertTrue(budgetService.getCategories().isEmpty());
+ }
+
+ @Test
+ void filterByCategoryReturnMatchingTransactions() {
+ budgetService.addExpense("Food", "Lunch", 10);
+ budgetService.addExpense("Transport", "Bus", 5);
+ budgetService.addExpense("Food", "Dinner", 20);
+
+ List filtered = budgetService.filterByCategory("Food");
+ assertEquals(2, filtered.size());
+ assertTrue(filtered.stream().allMatch(t -> "Food".equals(t.getCategory())));
+ }
+
+ @Test
+ void filterByCategoryIsCaseInsensitive() {
+ budgetService.addExpense("Food", "Lunch", 10);
+ List filtered = budgetService.filterByCategory("food");
+ assertEquals(1, filtered.size());
+ }
+
+ @Test
+ void filterByCategoryReturnsEmptyForNoMatch() {
+ budgetService.addExpense("Food", "Lunch", 10);
+ List filtered = budgetService.filterByCategory("Rent");
+ assertTrue(filtered.isEmpty());
+ }
+
+ @Test
+ void filterByCategoryThrowsOnEmptyCategory() {
+ assertThrows(IllegalArgumentException.class, () -> budgetService.filterByCategory(""));
+ assertThrows(IllegalArgumentException.class, () -> budgetService.filterByCategory(null));
+ }
+
+ @Test
+ void printFilteredTransactionsShowsMatchingEntries() {
+ budgetService.addExpense("Food", "Lunch", 10);
+ budgetService.addExpense("Transport", "Bus", 5);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ System.setOut(new PrintStream(out));
+
+ budgetService.printFilteredTransactions("Food");
+
+ String output = out.toString();
+ assertTrue(output.contains("TRANSACTIONS: FOOD"));
+ assertTrue(output.contains("Lunch"));
+ assertFalse(output.contains("Bus"));
+ }
+
+ @Test
+ void printFilteredTransactionsShowsNoMatchMessage() {
+ budgetService.addExpense("Food", "Lunch", 10);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ System.setOut(new PrintStream(out));
+
+ budgetService.printFilteredTransactions("Rent");
+
+ assertTrue(out.toString().contains("No transactions found for category: Rent"));
+ }
+
+ @Test
+ void dataPersistsAcrossServiceInstances() {
+ String filePath = tempDir.resolve("persist_test.json").toString();
+ StorageService storage = new StorageService(filePath);
+ BudgetService service1 = new BudgetService(storage);
+
+ service1.addIncome("Salary", 3000.0);
+ service1.addExpense("Food", "Lunch", 15.0);
+
+ BudgetService service2 = new BudgetService(new StorageService(filePath));
+ assertEquals(2, service2.getTransactions().size());
+ assertEquals(3000.0, service2.getTotalIncome(), 0.01);
+ assertEquals(15.0, service2.getTotalExpenses(), 0.01);
+ }
+}
diff --git a/budget-tracker/src/test/java/com/budgettracker/storage/StorageServiceTest.java b/budget-tracker/src/test/java/com/budgettracker/storage/StorageServiceTest.java
new file mode 100644
index 000000000..97106c0d1
--- /dev/null
+++ b/budget-tracker/src/test/java/com/budgettracker/storage/StorageServiceTest.java
@@ -0,0 +1,105 @@
+package com.budgettracker.storage;
+
+import com.budgettracker.model.Transaction;
+import com.budgettracker.model.TransactionType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class StorageServiceTest {
+
+ @TempDir
+ Path tempDir;
+
+ private StorageService storageService;
+ private String filePath;
+
+ @BeforeEach
+ void setUp() {
+ filePath = tempDir.resolve("test_budget.json").toString();
+ storageService = new StorageService(filePath);
+ }
+
+ @Test
+ void loadReturnsEmptyListWhenFileDoesNotExist() {
+ List result = storageService.load();
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void saveAndLoadRoundTrip() {
+ List transactions = new ArrayList<>();
+ transactions.add(new Transaction(TransactionType.INCOME, "", "Salary", 5000.0));
+ transactions.add(new Transaction(TransactionType.EXPENSE, "Food", "Groceries", 150.0));
+
+ assertTrue(storageService.save(transactions));
+
+ List loaded = storageService.load();
+ assertEquals(2, loaded.size());
+ assertEquals("Salary", loaded.get(0).getDescription());
+ assertEquals(5000.0, loaded.get(0).getAmount());
+ assertEquals(TransactionType.INCOME, loaded.get(0).getType());
+ assertEquals("Groceries", loaded.get(1).getDescription());
+ assertEquals(150.0, loaded.get(1).getAmount());
+ assertEquals("Food", loaded.get(1).getCategory());
+ }
+
+ @Test
+ void saveCreatesParentDirectories() {
+ String nestedPath = tempDir.resolve("sub/dir/data.json").toString();
+ StorageService nestedService = new StorageService(nestedPath);
+
+ List transactions = new ArrayList<>();
+ transactions.add(new Transaction(TransactionType.INCOME, "", "Test", 100.0));
+
+ assertTrue(nestedService.save(transactions));
+ assertEquals(1, nestedService.load().size());
+ }
+
+ @Test
+ void loadHandlesCorruptedFile() throws IOException {
+ Files.writeString(Path.of(filePath), "this is not json{{{");
+ List result = storageService.load();
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void loadHandlesEmptyFile() throws IOException {
+ Files.writeString(Path.of(filePath), "");
+ List result = storageService.load();
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void loadHandlesNullJsonContent() throws IOException {
+ Files.writeString(Path.of(filePath), "null");
+ List result = storageService.load();
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void saveEmptyList() {
+ assertTrue(storageService.save(new ArrayList<>()));
+ List loaded = storageService.load();
+ assertNotNull(loaded);
+ assertTrue(loaded.isEmpty());
+ }
+
+ @Test
+ void saveToReadOnlyLocationFails() {
+ StorageService badService = new StorageService("/proc/invalid/path/data.json");
+ assertFalse(badService.save(List.of(new Transaction(TransactionType.INCOME, "", "Test", 100.0))));
+ }
+}