diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 00000000000..e18212b18db
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,25 @@
+name: MarkBind Action
+
+on:
+ push:
+ branches:
+ - master
+
+jobs:
+ build_and_deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Install Graphviz
+ run: sudo apt-get install graphviz
+ - name: Install Java
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'zulu'
+ - name: Build & Deploy MarkBind site
+ uses: MarkBind/markbind-action@v2
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ rootDirectory: './docs'
+ baseUrl: '/tp' # assuming your repo name is tp
+ version: '^6.0.2'
diff --git a/.gitignore b/.gitignore
index 284c4ca7cd9..946b231928c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,23 +1,39 @@
-# Gradle build files
-/.gradle/
-/build/
+# Gradle build artifacts
+.gradle/
+build/
src/main/resources/docs/
-# IDEA files
-/.idea/
-/out/
-/*.iml
+# Do NOT ignore your build script.
+# You had "/.build.gradle" which would hide a hidden file ".build.gradle".
+# If that was a typo, delete that line.
-# Storage/log files
-/data/
-/config.json
-/preferences.json
-/*.log.*
+# IntelliJ IDEA
+.idea/
+out/
+*.iml
+
+# Storage / logs
+data/
+config.json
+preferences.json
+*.log
+*.log.*
hs_err_pid[0-9]*.log
-# Test sandbox files
+# Test sandbox
src/test/data/sandbox/
-# MacOS custom attributes files created by Finder
+# macOS and docs output
.DS_Store
docs/_site/
+docs/_markbind/logs/
+
+# SSH keys
+Yes
+Yes.pub
+
+# Keep Gradle wrapper files tracked
+!gradlew
+!gradlew.bat
+!gradle/wrapper/gradle-wrapper.jar
+!gradle/wrapper/gradle-wrapper.properties
diff --git a/README.md b/README.md
index 16208adb9b6..2db1549354f 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,88 @@
-[](https://github.com/se-edu/addressbook-level3/actions)
+# Team W11-2 — Treasura (Early Prototype)
+
+[](https://github.com/AY2526S1-CS2103T-W11-2/tp/actions)
+
+---

-* This is **a sample project for Software Engineering (SE) students**.
+*Figure: Single mockup/screenshot of **Treasura**.*
+
+---
+
+## Overview
+
+**Treasura** is a desktop, **CLI-first** app that streamlines the workflow of **CCA treasurers**: add and manage members, record and review payments, and track simple expenses quickly and reliably.
+
+### Goals (early stage)
+
+- **Fast keyboard-centric flow** for data entry and lookup.
+- **Clean membership registry**: add/search/view members, archive inactive members.
+- **Lightweight bookkeeping**: record payments, see outstanding amounts, add/delete expenses; payments are shown **chronologically** in the UI.
+- **Automatic syncing** after successful changes (no manual backups).
+
+> We are still iterating—commands and UI may evolve.
+
+* This is **a sample project for Software Engineering (SE) students **.
Example usages:
* as a starting point of a course project (as opposed to writing everything from scratch)
* as a case study
-* The project simulates an ongoing software project for a desktop application (called _AddressBook_) used for managing contact details.
+ * assist CCA treasurers to keep track of finances
+ * access member details
+* The project simulates an ongoing software project for a desktop application (called _Treasura_) used for managing CCA finance details.
* It is **written in OOP fashion**. It provides a **reasonably well-written** code base **bigger** (around 6 KLoC) than what students usually write in beginner-level SE modules, without being overwhelmingly big.
* It comes with a **reasonable level of user and developer documentation**.
-* It is named `AddressBook Level 3` (`AB3` for short) because it was initially created as a part of a series of `AddressBook` projects (`Level 1`, `Level 2`, `Level 3` ...).
+* It is named `Treasura` because it was initially created to assist CCA treasures in keeping track of finances and member expenses.
* For the detailed documentation of this project, see the **[Address Book Product Website](https://se-education.org/addressbook-level3)**.
* This project is a **part of the se-education.org** initiative. If you would like to contribute code to this project, see [se-education.org](https://se-education.org/#contributing-to-se-edu) for more info.
+---
+
+## Early Command Sketch (subject to change)
+
+```bash
+# Add member
+# Adds a new member. On success, Treasura prints a single-line confirmation with all stored fields,
+# including an auto-assigned ID.
+member add n/NAME p/PHONE [e/EMAIL] [m/MATRICULATION NUMBER] [t/TAG]
+# Example:
+member add n/Chia Sin Jie p/91234567 e/sj@nus.edu.sg m/A00000000X t/year1
+
+# Find member (by ID or name)
+# Displays the full profile. Duplicate names prompt for disambiguation.
+find id/
+find n/
+# Examples:
+find id/M1234
+find n/John Tan
+
+# Archive member
+# Moves an active member into an archive store and hides them from the default list.
+member archive /
+# Example:
+member archive /a1b2c3d4
+
+# Record payment
+# Records a payment for a member (by ID or name), storing amount/date/remarks.
+# Payments are shown chronologically in the UI; outstanding amounts appear in the profile.
+payment add m/ amt/AMOUNT [d/YYYY-MM-DD] [r/REMARKS]
+# Examples:
+payment add m/M1234 amt/20 d/2025-09-01 r/"Membership fee"
+payment add m/"John Tan" amt/50 r/"CCA T-shirt"
+
+# View payments (chronological UI)
+# No command required; the payments panel lists entries by time automatically.
+
+# Add expense
+# Adds a new expense. Expense IDs must be unique.
+expense add n/NAME x/ID amt/VALUE d/YYYY-MM-DD [t/TAG]... [note/NOTE]
+# Example:
+expense add n/Equipment x/123 amt/200.00 d/2025-09-09 t/Sports t/Essential note/"Bought 5 basketballs"
+
+# Delete expense
+# Removes an expense by ID. If delete confirmations are enabled, confirm/yes is required.
+expense delete x/ID [confirm/yes]
+# Example:
+expense delete x/303 confirm/yes
+
+# Auto-sync
+# After successful state-changing commands (add/edit/delete/archive), data is automatically saved to storage.
diff --git a/build.gradle b/build.gradle
index 4326923798c..4b62ca7f0ef 100644
--- a/build.gradle
+++ b/build.gradle
@@ -69,4 +69,8 @@ shadowJar {
archiveFileName = 'addressbook.jar'
}
+run {
+ enableAssertions = true
+}
+
defaultTasks 'clean', 'test'
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 00000000000..1748e487fbd
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,23 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+_markbind/logs/
+
+# Dependency directories
+node_modules/
+
+# Production build files (change if you output the build to a different directory)
+_site/
+
+# Env
+.env
+.env.local
+
+# IDE configs
+.vscode/
+.idea/*
+*.iml
diff --git a/docs/AboutUs.md b/docs/AboutUs.md
index ff3f04abd02..52f190f76d7 100644
--- a/docs/AboutUs.md
+++ b/docs/AboutUs.md
@@ -1,59 +1,61 @@
---
-layout: page
-title: About Us
+layout: default.md
+title: "About Us"
---
-We are a team based in the [School of Computing, National University of Singapore](https://www.comp.nus.edu.sg).
+# About Us
+
+We are a team based in the [School of Computing, National University of Singapore](http://www.comp.nus.edu.sg).
You can reach us at the email `seer[at]comp.nus.edu.sg`
## Project team
-### John Doe
+### Danton Yap
-
+
-[[homepage](http://www.comp.nus.edu.sg/~damithch)]
-[[github](https://github.com/johndoe)]
-[[portfolio](team/johndoe.md)]
+[[github](http://github.com/dnt0n)] [[portfolio](team/dnt0n.md)]
-* Role: Project Advisor
+* Role: Developer
+* Responsibilities: Data
-### Jane Doe
+### Chia Sin Jie
-
+
-[[github](http://github.com/johndoe)]
-[[portfolio](team/johndoe.md)]
+[[github](http://github.com/sjc)] [[portfolio](team/sjc.md)]
-* Role: Team Lead
-* Responsibilities: UI
+* Role: Developer
+* Responsibilities: Data
-### Johnny Doe
+### Foo Shao Jun
-
+
-[[github](http://github.com/johndoe)] [[portfolio](team/johndoe.md)]
+[[github](http://github.com/enamourous)]
+[[portfolio](team/enamourous.md)]
* Role: Developer
-* Responsibilities: Data
+* Responsibilities: Dev Ops + Threading
+
-### Jean Doe
+### Roshan Kuna
-
+
-[[github](http://github.com/johndoe)]
-[[portfolio](team/johndoe.md)]
+[[github](https://github.com/Roshan1572)]
+[[portfolio](team/roshan1572.md)]
* Role: Developer
-* Responsibilities: Dev Ops + Threading
+* Responsibilities: UI
-### James Doe
+### Jianrong Gu
-
+
-[[github](http://github.com/johndoe)]
-[[portfolio](team/johndoe.md)]
+[[github](http://github.com/jianronggu)]
+[[portfolio](team/jianronggu.md)]
* Role: Developer
* Responsibilities: UI
diff --git a/docs/Configuration.md b/docs/Configuration.md
index 13cf0faea16..32f6255f3b9 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -1,6 +1,8 @@
---
-layout: page
-title: Configuration guide
+ layout: default.md
+ title: "Configuration guide"
---
+# Configuration guide
+
Certain properties of the application can be controlled (e.g user preferences file location, logging level) through the configuration file (default: `config.json`).
diff --git a/docs/DevOps.md b/docs/DevOps.md
index 4724701da81..747f3f9a82d 100644
--- a/docs/DevOps.md
+++ b/docs/DevOps.md
@@ -1,12 +1,15 @@
---
-layout: page
-title: DevOps guide
+ layout: default.md
+ title: "DevOps guide"
+ pageNav: 3
---
-* Table of Contents
-{:toc}
+# DevOps guide
---------------------------------------------------------------------------------------------------------------------
+
+
+
+
## Build automation
diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md
index 72d04ce9249..73bb15885b3 100644
--- a/docs/DeveloperGuide.md
+++ b/docs/DeveloperGuide.md
@@ -1,15 +1,19 @@
---
-layout: page
-title: Developer Guide
+ layout: default.md
+ title: "Developer Guide"
+ pageNav: 3
---
-* Table of Contents
-{:toc}
+
+# Treasura Developer Guide
+
+
+
--------------------------------------------------------------------------------------------------------------------
## **Acknowledgements**
-* {list here sources of all reused/adapted ideas, code, documentation, and third-party libraries -- include links to the original source as well}
+This project is based on the AddressBook-Level3 project created by the [_SE-EDU initiative_](https://se-education.org/).
--------------------------------------------------------------------------------------------------------------------
@@ -21,14 +25,9 @@ Refer to the guide [_Setting up and getting started_](SettingUp.md).
## **Design**
-
-
-:bulb: **Tip:** The `.puml` files used to create diagrams are in this document `docs/diagrams` folder. Refer to the [_PlantUML Tutorial_ at se-edu/guides](https://se-education.org/guides/tutorials/plantUml.html) to learn how to create and edit diagrams.
-
-
### Architecture
-
+
The ***Architecture Diagram*** given above explains the high-level design of the App.
@@ -47,22 +46,22 @@ The bulk of the app's work is done by the following four components:
* [**`Model`**](#model-component): Holds the data of the App in memory.
* [**`Storage`**](#storage-component): Reads data from, and writes data to, the hard disk.
-[**`Commons`**](#common-classes) represents a collection of classes used by multiple other components.
+* [**`Commons`**](#common-classes) represents a collection of classes used by multiple other components.
**How the architecture components interact with each other**
-The *Sequence Diagram* below shows how the components interact with each other for the scenario where the user issues the command `delete 1`.
+The *Sequence Diagram* below shows how the components interact with each other for the scenario where the user issues the command `archive 1`.
-
+
Each of the four main components (also shown in the diagram above),
* defines its *API* in an `interface` with the same name as the Component.
-* implements its functionality using a concrete `{Component Name}Manager` class (which follows the corresponding API `interface` mentioned in the previous point.
+* implements its functionality using a concrete `{Component Name}Manager` class (which follows the corresponding API `interface` mentioned in the previous point.)
For example, the `Logic` component defines its API in the `Logic.java` interface and implements its functionality using the `LogicManager.java` class which follows the `Logic` interface. Other components interact with a given component through its interface rather than the concrete class (reason: to prevent outside component's being coupled to the implementation of a component), as illustrated in the (partial) class diagram below.
-
+
The sections below give more details of each component.
@@ -70,11 +69,11 @@ The sections below give more details of each component.
The **API** of this component is specified in [`Ui.java`](https://github.com/se-edu/addressbook-level3/tree/master/src/main/java/seedu/address/ui/Ui.java)
-
+
-The UI consists of a `MainWindow` that is made up of parts e.g.`CommandBox`, `ResultDisplay`, `PersonListPanel`, `StatusBarFooter` etc. All these, including the `MainWindow`, inherit from the abstract `UiPart` class which captures the commonalities between classes that represent parts of the visible GUI.
+The UI consists of a `MainWindow` that is made up of parts e.g.`CommandBox`, `ResultDisplay`, `PersonListPanel`, `StatusBarFooter` etc. All these, including the `MainWindow`, derived from the abstract `UiPart` class which captures the commonalities between classes that represent parts of the visible GUI.
-The `UI` component uses the JavaFx UI framework. The layout of these UI parts are defined in matching `.fxml` files that are in the `src/main/resources/view` folder. For example, the layout of the [`MainWindow`](https://github.com/se-edu/addressbook-level3/tree/master/src/main/java/seedu/address/ui/MainWindow.java) is specified in [`MainWindow.fxml`](https://github.com/se-edu/addressbook-level3/tree/master/src/main/resources/view/MainWindow.fxml)
+The `UI` component uses the JavaFX UI framework. The layout of these UI parts are defined in matching `.fxml` files that are in the `src/main/resources/view` folder. For example, the layout of the [`MainWindow`](https://github.com/se-edu/addressbook-level3/tree/master/src/main/java/seedu/address/ui/MainWindow.java) is specified in [`MainWindow.fxml`](https://github.com/se-edu/addressbook-level3/tree/master/src/main/resources/view/MainWindow.fxml)
The `UI` component,
@@ -89,59 +88,75 @@ The `UI` component,
Here's a (partial) class diagram of the `Logic` component:
-
+
-The sequence diagram below illustrates the interactions within the `Logic` component, taking `execute("delete 1")` API call as an example.
+The sequence diagram below illustrates the interactions within the `Logic` component, taking `execute("archive 1")` API call as an example.
-
+
-
:information_source: **Note:** The lifeline for `DeleteCommandParser` should end at the destroy marker (X) but due to a limitation of PlantUML, the lifeline continues till the end of diagram.
-
+
+
+**Note:** The lifeline for `archiveCommandParser` should end at the destroy marker (X) but due to a limitation of PlantUML, the lifeline continues till the end of diagram.
+
How the `Logic` component works:
-1. When `Logic` is called upon to execute a command, it is passed to an `AddressBookParser` object which in turn creates a parser that matches the command (e.g., `DeleteCommandParser`) and uses it to parse the command.
-1. This results in a `Command` object (more precisely, an object of one of its subclasses e.g., `DeleteCommand`) which is executed by the `LogicManager`.
-1. The command can communicate with the `Model` when it is executed (e.g. to delete a person).
+1. When `Logic` is called upon to execute a command, it is passed to an `AddressBookParser` object which in turn creates a parser that matches the command (e.g., `archiveCommandParser`) and uses it to parse the command.
+1. This results in a `Command` object (more precisely, an object of one of its subclasses e.g., `archiveCommand`) which is executed by the `LogicManager`.
+1. The command can communicate with the `Model` when it is executed (e.g. to archive a member).
Note that although this is shown as a single step in the diagram above (for simplicity), in the code it can take several interactions (between the command object and the `Model`) to achieve.
1. The result of the command execution is encapsulated as a `CommandResult` object which is returned back from `Logic`.
Here are the other classes in `Logic` (omitted from the class diagram above) that are used for parsing a user command:
-
+
How the parsing works:
-* When called upon to parse a user command, the `AddressBookParser` class creates an `XYZCommandParser` (`XYZ` is a placeholder for the specific command name e.g., `AddCommandParser`) which uses the other classes shown above to parse the user command and create a `XYZCommand` object (e.g., `AddCommand`) which the `AddressBookParser` returns back as a `Command` object.
-* All `XYZCommandParser` classes (e.g., `AddCommandParser`, `DeleteCommandParser`, ...) inherit from the `Parser` interface so that they can be treated similarly where possible e.g, during testing.
+* When called upon to parse a user command, the `AddressBookParser` class creates an `XYZCommandParser` (`XYZ` is a placeholder for the specific command name e.g., `AddMemberCommandParser`) which uses the other classes shown above to parse the user command and create a `XYZCommand` object (e.g., `AddMemberCommand`) which the `AddressBookParser` returns back as a `Command` object.
+* All `XYZCommandParser` classes (e.g., `AddMemberCommandParser`, `archiveCommandParser`, ...) inherit from the `Parser` interface so that they can be treated similarly where possible e.g, during testing.
### Model component
**API** : [`Model.java`](https://github.com/se-edu/addressbook-level3/tree/master/src/main/java/seedu/address/model/Model.java)
-
+
The `Model` component,
-* stores the address book data i.e., all `Person` objects (which are contained in a `UniquePersonList` object).
+* stores the Treasura data i.e., all `Person` objects (which are contained in a `UniquePersonList` object).
* stores the currently 'selected' `Person` objects (e.g., results of a search query) as a separate _filtered_ list which is exposed to outsiders as an unmodifiable `ObservableList` that can be 'observed' e.g. the UI can be bound to this list so that the UI automatically updates when the data in the list change.
* stores a `UserPref` object that represents the user’s preferences. This is exposed to the outside as a `ReadOnlyUserPref` objects.
* does not depend on any of the other three components (as the `Model` represents data entities of the domain, they should make sense on their own without depending on other components)
-
:information_source: **Note:** An alternative (arguably, a more OOP) model is given below. It has a `Tag` list in the `AddressBook`, which `Person` references. This allows `AddressBook` to only require one `Tag` object per unique tag, instead of each `Person` needing their own `Tag` objects.
+
+
+**Note on terminology**
+
+Treasura uses the class `Person` internally because it is inherited from AddressBook Level 3 (AB3).
+However, in the context of this project:
+
+| Context | Term Used |
+|---------|-----------|
+| What the user interacts with / what the CCA treasurer sees | **Member** |
+| What the codebase, model classes, and UML refer to | **Person** (`Person`, `UniquePersonList`, etc.) |
+
+For example:
+- In this Developer Guide and User Guide, we say **“archive a member”**, **“add a member”**, etc.
+- In diagrams, method names, and code (`model.setPerson(...)`, `getFilteredPersonList()`), the AB3 class name **`Person`** is retained for accuracy.
+
+Future developers should treat **Member ≡ Person** in meaning. The difference is only in naming (user-facing vs internal code).
-
-
### Storage component
**API** : [`Storage.java`](https://github.com/se-edu/addressbook-level3/tree/master/src/main/java/seedu/address/storage/Storage.java)
-
+
The `Storage` component,
-* can save both address book data and user preference data in JSON format, and read them back into corresponding objects.
+* can save both Treasura data and user preference data in JSON format, and read them back into corresponding objects.
* inherits from both `AddressBookStorage` and `UserPrefStorage`, which means it can be treated as either one (if only the functionality of only one is needed).
* depends on some classes in the `Model` component (because the `Storage` component's job is to save/retrieve objects that belong to the `Model`)
@@ -155,94 +170,128 @@ Classes used by multiple components are in the `seedu.address.commons` package.
This section describes some noteworthy details on how certain features are implemented.
-### \[Proposed\] Undo/redo feature
-
-#### Proposed Implementation
-
-The proposed undo/redo mechanism is facilitated by `VersionedAddressBook`. It extends `AddressBook` with an undo/redo history, stored internally as an `addressBookStateList` and `currentStatePointer`. Additionally, it implements the following operations:
+### AddMember feature
-* `VersionedAddressBook#commit()` — Saves the current address book state in its history.
-* `VersionedAddressBook#undo()` — Restores the previous address book state from its history.
-* `VersionedAddressBook#redo()` — Restores a previously undone address book state from its history.
+The AddMember feature allows Treasura users to add a member into the system.
-These operations are exposed in the `Model` interface as `Model#commitAddressBook()`, `Model#undoAddressBook()` and `Model#redoAddressBook()` respectively.
+The sequence diagram below illustrates the interactions within the `Logic` component for adding members.
-Given below is an example usage scenario and how the undo/redo mechanism behaves at each step.
+
-Step 1. The user launches the application for the first time. The `VersionedAddressBook` will be initialized with the initial address book state, and the `currentStatePointer` pointing to that single address book state.
+
-
+**Note:** The lifeline for `AddMemberCommandParser` should end at the destroy marker (X), but due to a limitation of PlantUML, the lifeline continues till the end of the diagram.
-Step 2. The user executes `delete 5` command to delete the 5th person in the address book. The `delete` command calls `Model#commitAddressBook()`, causing the modified state of the address book after the `delete 5` command executes to be saved in the `addressBookStateList`, and the `currentStatePointer` is shifted to the newly inserted address book state.
+
-
+How the `AddMember` command works:
+1. When the user enters an `add` command, `LogicManager` passes it to `AddressBookParser`.
+2. `AddressBookParser` creates an `AddMemberCommandParser` to parse the command arguments.
+3. `AddMemberCommandParser` validates and parses arguments.
+4. An `AddMember` object is created and executed.
+5. Before execution, the current state is committed for undo/redo functionality.
+6. `AddMember` checks if the current matric number already exists for the specified student(s).
+7. If no duplicates are found, the member is added to the system.
+8. The updated Treasura is saved to storage.
-Step 3. The user executes `add n/David …` to add a new person. The `add` command also calls `Model#commitAddressBook()`, causing another modified address book state to be saved into the `addressBookStateList`.
+### Archive feature
-
+The archive feature allows Treasura users to **soft-delete (archive) a member**.
+Internally, this works by updating the `Person` object’s `archived` flag to `true`.
-
:information_source: **Note:** If a command fails its execution, it will not call `Model#commitAddressBook()`, so the address book state will not be saved into the `addressBookStateList`.
+The sequence diagram below illustrates the interactions within the `Logic` component for archiving members.
-
+
-Step 4. The user now decides that adding the person was a mistake, and decides to undo that action by executing the `undo` command. The `undo` command will call `Model#undoAddressBook()`, which will shift the `currentStatePointer` once to the left, pointing it to the previous address book state, and restores the address book to that state.
+
-
+**Note:** The lifeline for `ArchiveCommandParser` should end at the destroy marker (X), but due to a limitation of PlantUML, the lifeline continues till the end of the diagram.
-
:information_source: **Note:** If the `currentStatePointer` is at index 0, pointing to the initial AddressBook state, then there are no previous AddressBook states to restore. The `undo` command uses `Model#canUndoAddressBook()` to check if this is the case. If so, it will return an error to the user rather
-than attempting to perform the undo.
+
-
+How it works:
+1. User enters `archive 1`.
+2. `LogicManager` passes this to `AddressBookParser`.
+3. `ArchiveCommandParser` parses the index of the member.
+4. An `ArchiveCommand` is created.
+5. Before modifying data, the current state of the model is saved for Undo/Redo.
+6. `ArchiveCommand` retrieves the target member from `model.getFilteredPersonList()` — internally this is a `Person` object.
+7. A new `Person` object is created using `withArchived(true)` and saved via `model.setPerson(...)`.
+8. The archived member is removed from the active filtered list and saved to storage.
+### AddPayment feature
-The following sequence diagram shows how an undo operation goes through the `Logic` component:
+The add payment feature allows Treasura users to **record a new payment** for one or more members in a single command.
-
+The sequence diagram below illustrates the interactions within the `Logic` component for adding payments.
-
:information_source: **Note:** The lifeline for `UndoCommand` should end at the destroy marker (X) but due to a limitation of PlantUML, the lifeline reaches the end of diagram.
+
-
+
-Similarly, how an undo operation goes through the `Model` component is shown below:
+**Note:** The diagram uses a generic `Parser` participant to represent the parser layer (e.g., `AddressBookParser` delegating to `AddPaymentCommandParser`). Depending on the concrete implementation, the parser instance’s lifeline may conceptually end at a destroy marker (X), but PlantUML may render it as continuing to the end of the diagram.
-
+
-The `redo` command does the opposite — it calls `Model#redoAddressBook()`, which shifts the `currentStatePointer` once to the right, pointing to the previously undone state, and restores the address book to that state.
+How the `addpayment` command works:
+1. The user enters an `addpayment` command. `LogicManager` forwards the raw input to the top-level `Parser`.
+2. The `Parser` identifies the command word and delegates to `AddPaymentCommandParser` (conceptually), which:
+ - Parses the **member index list** from the preamble (e.g., `1,2`),
+ - Parses and validates **amount** (`a/`), **date** (`d/`), and **remark** (`r/`),
+ - Constructs an `AddPaymentCommand` encapsulating the parsed arguments.
+3. `Parser` returns the `AddPaymentCommand` to `LogicManager`.
+4. Before mutating model state, the current model snapshot is **committed** to support **Undo/Redo**.
+5. `AddPaymentCommand#execute(model)`:
+ - Retrieves the current `displayedList` via `model.getFilteredPersonList()`.
+ - For **each specified index**:
+ - Resolves the **target member** from `displayedList`.
+ - Creates a new **Payment** object from the parsed amount/date/remark.
+ - Produces an **updated member** with the new payment appended (preserving immutability).
+ - Calls `model.setPerson(target, updated)` to persist the change.
+6. After processing all indices, the command composes a **success message** summarizing the added payment and affected members.
+7. The updated Treasura is **saved to storage**, and the result is returned to the user.
-
:information_source: **Note:** If the `currentStatePointer` is at index `addressBookStateList.size() - 1`, pointing to the latest address book state, then there are no undone AddressBook states to restore. The `redo` command uses `Model#canRedoAddressBook()` to check if this is the case. If so, it will return an error to the user rather than attempting to perform the redo.
+
-
+**Validation highlights**
+- **Indices:** Must refer to members in the current displayed list; invalid indices cause the command to fail without partial writes.
+- **Amount:** Must be a positive monetary value with up to two decimal places.
+- **Date:** Must follow the accepted format (e.g., `YYYY-MM-DD`) and be a valid calendar date that is not in the future.
+- **Remark:** Free text; excessively long remarks may be truncated or rejected depending on constraints.
-Step 5. The user then decides to execute the command `list`. Commands that do not modify the address book, such as `list`, will usually not call `Model#commitAddressBook()`, `Model#undoAddressBook()` or `Model#redoAddressBook()`. Thus, the `addressBookStateList` remains unchanged.
+
-
+### ViewPayment feature
-Step 6. The user executes `clear`, which calls `Model#commitAddressBook()`. Since the `currentStatePointer` is not pointing at the end of the `addressBookStateList`, all address book states after the `currentStatePointer` will be purged. Reason: It no longer makes sense to redo the `add n/David …` command. This is the behavior that most modern desktop applications follow.
+The view payment feature allows Treasura users to **display all payments** associated with a specific member.
+Additionally, using `viewpayment all` generates a consolidated summary of payments for every member in Treasura.
-
+The sequence diagram below illustrates the interactions within the `Logic` component for viewing payments.
-The following activity diagram summarizes what happens when a user executes a new command:
+
-
+
-#### Design considerations:
+**Note:** The diagram models the UI initiating parsing and rendering the results. `ViewPaymentCommand` is **non-mutating** and does not affect the Undo/Redo stack.
-**Aspect: How undo & redo executes:**
+
-* **Alternative 1 (current choice):** Saves the entire address book.
- * Pros: Easy to implement.
- * Cons: May have performance issues in terms of memory usage.
+How the `viewpayment` command works:
+1. The user enters a `viewpayment` command in the UI (e.g., `viewpayment 1`), and the UI forwards the input to `LogicManager`.
+2. `LogicManager` delegates to `ViewPaymentCommandParser` to parse the argument (the target member index).
+3. The parser validates the index and constructs a `ViewPaymentCommand`.
+4. `ViewPaymentCommand#execute(model)` retrieves the current list of members via `model.getFilteredPersonList()`.
+5. The target `Person` is resolved from the displayed list, and the member’s `getPayments()` is invoked to fetch their payments.
+6. A `CommandResult` containing a **summary string** (e.g., a header and/or count) is returned to the UI.
+7. The UI **renders the list of payments** for the selected member.
-* **Alternative 2:** Individual command knows how to undo/redo by
- itself.
- * Pros: Will use less memory (e.g. for `delete`, just save the person being deleted).
- * Cons: We must ensure that the implementation of each individual command are correct.
+
-_{more aspects and alternatives to be added}_
-
-### \[Proposed\] Data archiving
-
-_{Explain here how the data archiving feature will be implemented}_
+**Validation highlights**
+- **Index:** Must refer to a valid entry in the current displayed member list. An invalid index causes the command to fail.
+- **Non-mutating:** The command does **not** change the model (no commit, no Undo/Redo impact).
+- **Empty payments:** If the member has no payments, the UI indicates that there are **no payments to show**.
+
--------------------------------------------------------------------------------------------------------------------
@@ -254,7 +303,7 @@ _{Explain here how the data archiving feature will be implemented}_
* [Configuration guide](Configuration.md)
* [DevOps guide](DevOps.md)
---------------------------------------------------------------------------------------------------------------------
+---
## **Appendix: Requirements**
@@ -262,121 +311,573 @@ _{Explain here how the data archiving feature will be implemented}_
**Target user profile**:
-* has a need to manage a significant number of contacts
+* Must be a NUS CCA Treasurer
* prefer desktop apps over other types
* can type fast
* prefers typing to mouse interactions
* is reasonably comfortable using CLI apps
+* is in charge of managing multiple member payments
-**Value proposition**: manage contacts faster than a typical mouse/GUI driven app
+**Value proposition**: Provides treasurers with a fast, command-driven way to track members, and payments without heavy accounting tools.
### User stories
-Priorities: High (must have) - `* * *`, Medium (nice to have) - `* *`, Low (unlikely to have) - `*`
+**Priorities:**
+High (must have) - `* * *`, Medium (nice to have) - `* *`, Low (unlikely to have) - `*`
+
+| Priority | As a … | I want to … | So that I can… |
+|----------|---------------|------------------------------------------|---------------------------------------|
+| `* * *` | CCA Treasurer | add new member details | build my membership list |
+| `* * *` | CCA Treasurer | view member details | keep track of members |
+| `* * *` | CCA Treasurer | search for member by name or tag | find records quickly |
+| `* * *` | CCA Treasurer | archive inactive member | keep my records clean and uncluttered |
+| `* * *` | CCA Treasurer | record payments from member | know who has paid fees |
+| `* * *` | CCA Treasurer | delete payment from a member | delete unintended payment |
+| `* * *` | CCA Treasurer | see the time and date of payments | track payments chronologically |
+| `* * *` | CCA Treasurer | search for payments | find payment records |
+
+
+## Use cases
-| Priority | As a … | I want to … | So that I can… |
-| -------- | ------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------- |
-| `* * *` | new user | see usage instructions | refer to instructions when I forget how to use the App |
-| `* * *` | user | add a new person | |
-| `* * *` | user | delete a person | remove entries that I no longer need |
-| `* * *` | user | find a person by name | locate details of persons without having to go through the entire list |
-| `* *` | user | hide private contact details | minimize chance of someone else seeing them by accident |
-| `*` | user with many persons in the address book | sort persons by name | locate a person easily |
+(For all use cases below, the **System** is the `Treasura` and the **Actor** is the `user`, unless specified otherwise)
-*{More to be added}*
-### Use cases
+### Member Management Use Cases
-(For all use cases below, the **System** is the `AddressBook` and the **Actor** is the `user`, unless specified otherwise)
+---
-**Use case: Delete a person**
+**Use case: Add a member**
**MSS**
-1. User requests to list persons
-2. AddressBook shows a list of persons
-3. User requests to delete a specific person in the list
-4. AddressBook deletes the person
+1. User requests to add a member by specifying details (name, matric number, phone, email, etc.).
+2. Treasura validates all input fields.
+3. Treasura adds the member to the active list.
- Use case ends.
+ Use case ends.
**Extensions**
-* 2a. The list is empty.
+* 2a. One or more required fields are missing or invalid (e.g., matric format incorrect, duplicate ID).
+ Treasura shows error: *Invalid command format!
+ add: Adds a member to the Treasura. Parameters: n/NAME p/PHONE e/EMAIL m/MATRICULATION NUMBER [t/TAG]...
+ Example: add n/John Doe p/98765432 e/johnd@example.com m/A1234567X t/friends t/owesMoney*.
+ Use case ends.
+
+---
+
+**Use case: Edit a member**
+
+**MSS**
+
+1. User requests to edit details of a specific member using their index.
+2. Treasura validates the input.
+3. Treasura updates the member record.
+
+ Use case ends.
+
+**Extensions**
+* 2a. Index is invalid or out of range.
+ Treasura shows error: *The member index provided is invalid*.
Use case ends.
-* 3a. The given index is invalid.
+* 2b. No prefix is provided (field to edit is unspecified)
+ Treasura shows error: *At least one field to edit must be provided*.
- * 3a1. AddressBook shows an error message.
+---
+
+**Use case: Archive a member**
+
+**MSS**
+
+1. User requests to list members.
+2. Treasura shows a list of members.
+3. User requests to archive a specific member.
+4. Treasura archives the member.
+
+ Use case ends.
+
+**Extensions**
- Use case resumes at step 2.
+* 2a. The list is empty.
+ Use case ends.
+
+* 3a. The specified index is invalid (non-integer or out of range).
+ Treasura shows error: *The member index(es) provided is invalid*.
+ Use case ends.
+
+* 4a. The specified member is already archived.
+ Treasura shows error: *Member is already archived*.
+ Use case ends.
+
+* 4b. Storage failure occurs.
+ Treasura shows error: *Unable to save changes*.
+ Use case ends.
-*{More to be added}*
+---
+
+**Use case: Unarchive a member**
+
+**MSS**
+
+1. User requests to list archived members.
+2. Treasura shows a list of archived members.
+3. User requests to unarchive a specific member.
+4. Treasura unarchives the member and moves them back to the active list.
+
+ Use case ends.
+
+**Extensions**
+
+* 2a. The archived list is empty.
+ Use case ends.
+
+* 3a. The specified index is invalid (non-integer or out of range).
+ Treasura shows error: *The member index(es) provided is invalid*.
+ Use case ends.
+
+* 4a. The specified member is already active (not archived).
+ Treasura shows error: *One or more selected members are not archived: [NAME(S)]*.
+ Use case ends.
+
+---
+
+**Use case: Find members**
+
+**MSS**
+
+1. User enters find command with a keyword or list of keywords.
+2. Treasura searches for members whose names or tags match the keyword(s).
+3. Treasura displays the list of matching members.
+
+ Use case ends.
+
+**Extensions**
+
+* 2a. No member matches the keyword(s).
+ Treasura shows message: *0 members listed!*.
+ Use case ends.
+
+---
+
+**Use case: View member details**
+
+1. User enters `view INDEX`.
+2. Treasura shows full details of the specified member.
+ Use case ends.
+
+Extensions:
+* 1a. Index invalid → *Invalid member index.* Use case ends.
+
+---
+
+**Use case: List all active members**
+
+1. User enters `list`.
+2. Treasura displays all non-archived members.
+ Use case ends.
+
+---
+
+**Use case: List archived members**
+
+1. User enters `listarchived`.
+2. Treasura displays only archived members.
+ Use case ends.
+---
+
+### Payment Management Use Cases
+
+---
+
+**Use case: Add a payment**
+
+**MSS**
+
+1. User requests to add a payment for one or more members.
+2. Treasura validates the indices, amount, and date.
+3. Treasura records the payment(s) under each member.
+4. Treasura displays success message and updated total.
+
+ Use case ends.
+
+**Extensions**
+
+* 2a. Any index is invalid.
+ Treasura shows error: *The member index(es) provided is invalid*.
+ Use case ends.
+
+* 2b. Date or amount format is invalid.
+ Treasura shows error: *Invalid date. Please use the strict format YYYY-MM-DD (e.g., 2025-01-01) and ensure it is not in the future*.
+ Use case ends.
+
+---
+
+**Use case: Edit a payment**
+
+**MSS**
+
+1. User requests to view payments for a member.
+2. Treasura displays the member’s payment list.
+3. User requests to edit a specific payment by index.
+4. Treasura updates the payment with new details.
+5. Treasura confirms successful update.
+
+ Use case ends.
+
+**Extensions**
+
+* 1a. Member index is invalid.
+ Treasura shows error: *The member index(es) provided is invalid*.
+ Use case ends.
+
+* 3a. Payment index does not exist.
+ Treasura shows error: *Payment index is invalid for this member*.
+ Use case ends.
+
+* 4a. New date is invalid.
+ Treasura shows error: *Invalid date. Please use the strict format YYYY-MM-DD (e.g., 2025-01-01) and ensure it is not in the future.*.
+ Use case ends.
+
+---
+
+**Use case: Delete a payment**
+
+**MSS**
+1. User requests to delete a payment using `deletepayment MEMBER_INDEX p/PAYMENT_INDEX`.
+2. Treasura validates the indices.
+3. Treasura deletes the payment record.
+4. Treasura shows confirmation message.
+
+ Use case ends.
+
+**Extensions**
+
+* 2a. Invalid member index.
+ Treasura shows error: *Invalid index specified*.
+ Use case ends.
+
+* 2b. Invalid payment index.
+ Treasura shows error: *Invalid payment index #[INDEX] for member: [NAME]*.
+ Use case ends.
+
+---
+
+**Use case: View payments for a member**
+
+**MSS**
+
+1. User requests to view payments for a specific member.
+2. Treasura retrieves all payments tied to that member.
+3. Treasura displays the list of payments.
+
+ Use case ends.
+
+**Extensions**
+
+* 1a. Invalid member index.
+ Treasura shows error: *The member index(es) provided is invalid*.
+ Use case ends.
+
+* 2a. Member has no payment records.
+ Treasura shows message: *[NAME] has no payments recorded*.
+ Use case ends.
+
+---
+
+**Use case: View all payments**
+
+**MSS**
+
+1. User requests to view all payments using `viewpayment all`.
+2. Treasura aggregates all payments across members.
+3. Treasura displays total per member and overall cumulative total.
+
+ Use case ends.
+
+**Extensions**
+
+* 2a. No payments exist.
+ Treasura shows an empty list.
+ Use case ends.
+
+---
+
+**Use case: Find payments**
+
+**MSS**
+
+1. User requests to find payments for a member using filters (amount/date/remark).
+2. Treasura filters the payment list based on given criteria.
+3. Treasura displays the matching payments.
+
+ Use case ends.
+
+**Extensions**
+
+* 1a. Invalid member index.
+ Treasura shows error: *The member index(es) provided is invalid*.
+ Use case ends.
+
+* 2a. No payments match the filters.
+ Treasura shows message: *No payments found for [NAME] matching [amount | date | remark]*.
+ Use case ends.
+
+
+
+## General System Use Cases
+
+---
+
+**Use case: Undo last action**
+
+**MSS**
+
+1. User requests to undo the most recent reversible command.
+2. Treasura reverts the most recent state change.
+3. Treasura shows a confirmation of the undone action.
+
+ Use case ends.
+
+**Extensions**
+
+* 1a. There is no action to undo.
+ Treasura shows error: *Nothing to undo*.
+
+ Use case ends.
+
+* 2a. The last command is not undoable (e.g., non-state-changing action).
+ Treasura will undo the last mutating action (e.g., the user performs "addpayment" -> "list". "addpayment" will be undone).
+
+ Use case ends.
+
+---
+
+**Use case: Redo a previously undone action**
+
+**MSS**
+
+1. User requests to redo the most recently undone command.
+2. Treasura restores the previously undone state.
+3. Treasura displays confirmation.
+
+ Use case ends.
+
+**Extensions**
+
+* 1a. No command available to redo.
+ Treasura shows error: *Nothing to redo*.
+ Use case ends.
+
+---
+
+
+
+
+**Use case: Exit the application**
+
+**MSS**
+
+1. User enters the `exit` command.
+2. Treasura saves all current data to disk.
+3. Treasura closes the application.
+
+ Use case ends.
+
+
+
### Non-Functional Requirements
-1. Should work on any _mainstream OS_ as long as it has Java `17` or above installed.
-2. Should be able to hold up to 1000 persons without a noticeable sluggishness in performance for typical usage.
-3. A user with above average typing speed for regular English text (i.e. not code, not system admin commands) should be able to accomplish most of the tasks faster using commands than using the mouse.
+### 1. Data Requirements
+* **Data Volatility** — Member and payment data should be stored persistently and remain intact between sessions.
+ Data changes (add, edit, archive, payment updates) are only committed upon successful command execution.
+* **Data Consistency** — The system must prevent conflicting updates (e.g., deleting a payment after it was archived).
+ Undo/redo operations must preserve logical consistency across all entities.
+* **Data Integrity** — Each member must have a unique combination of `Name` and `Matriculation Number`.
+ Archived records must retain their associated payments for traceability.
+* **Data Security** — User data is stored locally in JSON format. The application does not transmit any data externally.
+* **Data Recoverability** — In the event of an abnormal termination, the most recent successful state should be recoverable upon restart.
+
+---
+
+### 2. Technical Requirements
+* **Platform Compatibility** — The application must run on any mainstream OS (Windows, macOS, Linux, Unix) with **Java 17** or above installed.
+* **Build System** — The project uses **Gradle** for compilation, testing, and packaging (shadow JAR for distribution).
+* **Architecture** — Follows the **AB3 MVC architecture** (`Logic`, `Model`, `Storage`, `UI`) for maintainability and modularity.
+* **Logging** — The application should log command execution and errors through `LogsCenter` for debugging and traceability.
+* **Error Handling** — Parsing and validation errors should never crash the application; they must show user-friendly error messages.
+* **Extensibility** — New commands (e.g., `export`, `import`) should be addable without major refactoring due to consistent parser–command structure.
+
+---
+
+### 3. Performance Requirements
+* **Startup Time** — The application should launch and display the active list within **≤ 2 seconds** on a typical laptop.
+* **Command Latency** — Each command (`archive`, `unarchive`, `find`, `addpayment`, `deletepayment`, etc.) must execute within **≤ 150 ms** for a dataset of
+ up to **5,000 members** and **20 payments per member**.
+* **Undo/Redo Depth** — The undo/redo system must support **at least 20 reversible steps** without performance degradation.
+* **Responsiveness** — UI updates should be reflected on the screen within 200 ms of user interaction
+* **Storage Efficiency** — The application should remain performant and responsive even with file sizes up to **10 MB**.
+
+---
-*{More to be added}*
+### 4. Scalability Requirements
+* **Data Volume** — The system must handle at least **1,000 active members** and **20,000 total payments** with no noticeable slowdown.
+* **Feature Scalability** — The architecture should support future extensions such as `export`, `import`, or `statistics` without affecting core logic.
+* **Storage Format** — The JSON-based storage can be evolved (e.g., adding new fields) while maintaining backward compatibility through the adapter pattern.
+* **Multi-entity Extension** — The system can be extended to support new entity types (e.g., CCA Events) using the existing command framework.
+
+---
+
+### 5. Usability Requirements
+* **Command Efficiency** — A user with above-average typing speed should accomplish most tasks faster using text commands than the mouse.
+* **Command Feedback** — All commands must provide clear success or error messages in the result display.
+* **Error Recovery** — Invalid commands must not corrupt data and should guide the user toward correct syntax via `MESSAGE_USAGE`.
+* **Consistency** — Command syntax and usage follow AB3 conventions (e.g., prefixes like `n/`, `e/`, `m/`, `p/`).
+* **Learnability** — First-time users should be able to perform basic actions (add, find, archive, view) within **10 minutes** of exploration.
+* **Accessibility** — The UI should be readable and usable on screens as small as **1280×720**, with high-contrast text for visibility.
+
+---
+
+### 6. Constraints
+* **Offline Operation** — The application must operate fully offline without network connectivity.
+* **Single User Environment** — Only one user instance interacts with the data file at any time (no concurrency control required).
+* **No External Database** — All data must be stored locally; the use of external servers or cloud databases is not permitted.
+* **File Corruption Handling** — If the data file becomes unreadable, the app should display a clear error message and fall back to an empty dataset.
+* **Open Source Requirement** — The full source code must be publicly available on GitHub.
+* **Coding Standards** — All code must conform to the project’s Java coding standard and pass Checkstyle verification.
+
+---
### Glossary
-* **Mainstream OS**: Windows, Linux, Unix, MacOS
-* **Private contact detail**: A contact detail that is not meant to be shared with others
+* **Mainstream OS** — Commonly used operating systems such as **Windows**, **Linux**, **Unix**, and **macOS**.
+* **Matriculation Number** — A unique identification code assigned to each **NUS student** (e.g., A0123456X).
+* **CCA** — Stands for *Co-Curricular Activity*; refers to a **student club, society, or organization** in NUS.
+* **Archived member** — A member who has been soft-deleted from the active list but remains in storage for record-keeping.
+* **Payment Record** — A transaction entry associated with a member, containing an **amount**, **date**, and optional **remarks**.
+* **Predicate** — A filtering condition used in the app’s logic layer to determine which members are displayed in the UI.
+* **Command Word** — The keyword used to trigger a command (e.g., `archive`, `find`, `undo`).
+* **Model** — The component responsible for holding data and business logic; updates the UI through observable lists.
+* **View** — The user interface layer that reflects the current state of the model (e.g., active list, archived list, payment view).
+* **Undo/Redo Stack** — A pair of internal data structures that track the history of changes for reversible commands.
+
+
--------------------------------------------------------------------------------------------------------------------
-## **Appendix: Instructions for manual testing**
+### **Appendix: Instructions for manual testing**
Given below are instructions to test the app manually.
-
:information_source: **Note:** These instructions only provide a starting point for testers to work on;
+#### Launching the application
+1. Ensure that you have Java 17 or above installed.
+2. Download the latest `.jar` file from the **Releases** page (e.g., `Treasura.jar`).
+3. Open a terminal in the directory containing the JAR file.
+4. Run a few basic commands such as add, addpayment, archive, and unarchive
+5. Details for a quick and easy starting workflow catered to manual testing can be found in our User Guide.
+
+
+
+
+**Note:** These instructions only provide a starting point for testers to work on;
testers are expected to do more *exploratory* testing.
-
+
+
+### **Appendix: Planned Enhancements**
+Our team size is 5.
+
+1. **Multi-CCA Support:**
+ Allow users to store and switch between different CCAs’ member and payment data using separate storage files.
+
+2. **Enforce `viewpayment` Precondition:**
+ Currently, `editpayment` and `deletepayment` can be used without viewing a member’s payment list first. This will be fixed by requiring `viewpayment` before editing or deleting payments, ensuring users act within the correct context.
+
+3. **Improve Date Validation Feedback:**
+ The same error message is shown for both invalid date formats and future dates. Future versions will distinguish between the two:
+ - *Invalid format:* “Please use YYYY-MM-DD (e.g., 2025-01-01).”
+ - *Future date:* “Payment date cannot be in the future.”
+
+4. **Enhance Command Error Handling:**
+ When users enter unknown or misplaced prefixes (e.g., `e/fovfv`), the app currently reports unrelated errors such as “Invalid amount.” This will be updated to show clearer messages like:
+ > **“Unknown prefix: e/. Please check your command format.”**
+
+5. **Guarded Member Deletion (archive-first flow):**
+ Introduce an optional permanent delete pathway that operates only on archived members and requires explicit confirmation (e.g., delete 2 confirm). This clarifies the lifecycle distinction between “hide” (archive) and “purge” (delete), prevents accidental loss, and aligns expectations for users familiar with AB3-style deletion. The User Guide will document the archive-first model, the confirmation step, and the cascade effects (e.g., associated payments removed).
-### Launch and shutdown
+6. **Data Portability & Safe Dataset Reset:**
+ Add export/import of full datasets as a single portable snapshot (e.g., JSON/ZIP) and a guarded reset command that wipes only the active dataset after confirmation while auto-backing up the current state. This complements Multi-CCA Support by enabling easy cross-device transfer of a CCA’s records and a clean, auditable way to start fresh for a new cohort without risky manual file operations.
-1. Initial launch
+7. **Archive and Unarchive Duplicate indexes:**
+ Our `archive` and `unarchive` currently accept inputs such as `archive 1,1,1,1` or `unarchive 2,2,2,2`. This has no functional impact on the user or program, but may cause confusion to the user. We plan on implementing a fix for this in the future by rejecting duplicate input.
- 1. Download the jar file and copy into an empty folder
+8. **Find returns incorrect message when including special characters as search terms:**
+ When performing a `find` using special characters such as `find @@@###` or `find Alex %%%$$$` will return the result of the search which would be 0 members, or members matching the name or tag "Alex". However, since our names only consist of letters and tags are alphanumeric, special symbols in the search should return an error.
- 1. Double-click the jar file Expected: Shows the GUI with a set of sample contacts. The window size may not be optimum.
+
-1. Saving window preferences
+### Appendix: Effort
- 1. Resize the window to an optimum size. Move the window to a different location. Close the window.
+### Overview
+The project builds on the AddressBook Level 3 (AB3) foundation but significantly expands its scope and complexity.
+While AB3 manages a single entity type (`Member`), our project introduces **multiple entity states and relationships**:
+* **Archived vs Active members** with distinct filters, views, and persistence logic.
+* **Payment records** linked to each Member, with support for amount, date, and remarks fields.
+* **Undo/Redo** functionality for all mutating commands, increasing both user convenience and implementation complexity.
- 1. Re-launch the app by double-clicking the jar file.
- Expected: The most recent window size and location is retained.
+These extensions required architectural changes across the `Model`, `Logic`, `Storage`, and `UI` layers, while maintaining compatibility with the existing AB3 command architecture.
-1. _{ more test cases … }_
+---
+
+### Challenges Faced
+1. **Multi-file updates and merge conflicts**
+ Introducing new attributes (e.g., `archived` flag, payment list) required synchronized updates across the `Member`, `JsonAdaptedPerson`, `Storage`, and `Ui` classes.
+ Coordinating these updates required coordination and communication to minimise merge conflicts and overwrites.
-### Deleting a person
+2. **Payment interface design**
+ Designing a flexible payment model that stores multiple payments per member with amount, date, and optional remarks demanded careful consideration of immutability and display ordering.
+ Commands like `addpayment`, `deletepayment`, `editpayment`, and `viewpayment` required custom parsing and validation logic distinct from AB3’s single-field operations.
-1. Deleting a person while all persons are being shown
+3. **Archived/Active view management**
+ Implementing `archive`, `unarchive`, and `listarchived` introduced the need for dynamic predicate switching (`PREDICATE_SHOW_ACTIVE_PERSONS` vs `PREDICATE_SHOW_ARCHIVED_PERSONS`).
+ Ensuring that archived members were excluded from normal search and list results, while still being able to manage their payments required defensive programming and extensive testing across commands.
- 1. Prerequisites: List all persons using the `list` command. Multiple persons in the list.
+4. **Undo/Redo functionality**
+ Maintaining consistent application state after consecutive undo/redo operations required snapshot-based history tracking in the `Model`.
+ Edge cases involving sequential operations (e.g., `archive → undo → addpayment → undo → redo`) were challenging to reason about and verify.
- 1. Test case: `delete 1`
- Expected: First contact is deleted from the list. Details of the deleted contact shown in the status message. Timestamp in the status bar is updated.
+5. **UI synchronization**
+ Modifying the UI to display the Archived label and each member's latest payment.
- 1. Test case: `delete 0`
- Expected: No person is deleted. Error details shown in the status message. Status bar remains the same.
+---
- 1. Other incorrect delete commands to try: `delete`, `delete x`, `...` (where x is larger than the list size)
- Expected: Similar to previous.
+### Effort and Achievements
+* **Code effort:** approximately **1.5× the effort of base AB3**, due to additional entity relationships, new commands, validation, and UI enhancements.
+* **Testing effort:** expanded significantly, as new commands (`archive`, `unarchive`, `undo`, `redo`, and payment operations) required both unit and integration tests to maintain >80% coverage.
+* **Collaboration effort:** frequent merges and PR reviews to maintain consistent architecture and coding standards.
-1. _{ more test cases … }_
+**Key achievements:**
+* Successfully implemented **two distinct views** for archived and active members.
+* Created a robust **payment interface** that tracks transaction amount, date, and remarks.
+* Added **undo/redo** functionality, improving user experience and reliability.
+* Enhanced test coverage and logging, ensuring stability under edge cases.
-### Saving data
+---
+
+### Reuse and Efficiency
+A small portion of the project (~10%) reused or adapted existing AB3 utilities and parser logic:
+* The `ArgumentTokenizer`, `ParserUtil`, and `CommandResult` classes were reused with minor extensions.
+* This reuse allowed us to focus effort on implementing new domain logic (e.g., payment handling, undo/redo, archived filtering) rather than reimplementing core infrastructure.
+* The saved effort was redirected toward improving **test depth**, **code readability**, and **UI integration**.
+
+---
-1. Dealing with missing/corrupted data files
+### Summary
+In summary, our project demonstrates a substantial step beyond AB3 in both **functionality** and **complexity**.
+By integrating **multiple data dimensions**, **state management**, and **user-friendly undo/redo capabilities**, we produced a feature-rich, reliable, and user-oriented application that serves the needs of our target audience.
- 1. _{explain how to simulate a missing/corrupted file, and the expected behavior}_
+
-1. _{ more test cases … }_
diff --git a/docs/Documentation.md b/docs/Documentation.md
index 3e68ea364e7..082e652d947 100644
--- a/docs/Documentation.md
+++ b/docs/Documentation.md
@@ -1,29 +1,21 @@
---
-layout: page
-title: Documentation guide
+ layout: default.md
+ title: "Documentation guide"
+ pageNav: 3
---
-**Setting up and maintaining the project website:**
-
-* We use [**Jekyll**](https://jekyllrb.com/) to manage documentation.
-* The `docs/` folder is used for documentation.
-* To learn how set it up and maintain the project website, follow the guide [_[se-edu/guides] **Using Jekyll for project documentation**_](https://se-education.org/guides/tutorials/jekyll.html).
-* Note these points when adapting the documentation to a different project/product:
- * The 'Site-wide settings' section of the page linked above has information on how to update site-wide elements such as the top navigation bar.
- * :bulb: In addition to updating content files, you might have to update the config files `docs\_config.yml` and `docs\_sass\minima\_base.scss` (which contains a reference to `AB-3` that comes into play when converting documentation pages to PDF format).
-* If you are using Intellij for editing documentation files, you can consider enabling 'soft wrapping' for `*.md` files, as explained in [_[se-edu/guides] **Intellij IDEA: Useful settings**_](https://se-education.org/guides/tutorials/intellijUsefulSettings.html#enabling-soft-wrapping)
+# Documentation Guide
+* We use [**MarkBind**](https://markbind.org/) to manage documentation.
+* The `docs/` folder contains the source files for the documentation website.
+* To learn how set it up and maintain the project website, follow the guide [[se-edu/guides] Working with Forked MarkBind sites](https://se-education.org/guides/tutorials/markbind-forked-sites.html) for project documentation.
**Style guidance:**
* Follow the [**_Google developer documentation style guide_**](https://developers.google.com/style).
+* Also relevant is the [_se-edu/guides **Markdown coding standard**_](https://se-education.org/guides/conventions/markdown.html).
-* Also relevant is the [_[se-edu/guides] **Markdown coding standard**_](https://se-education.org/guides/conventions/markdown.html)
-
-**Diagrams:**
-
-* See the [_[se-edu/guides] **Using PlantUML**_](https://se-education.org/guides/tutorials/plantUml.html)
-**Converting a document to the PDF format:**
+**Converting to PDF**
-* See the guide [_[se-edu/guides] **Saving web documents as PDF files**_](https://se-education.org/guides/tutorials/savingPdf.html)
+* See the guide [_se-edu/guides **Saving web documents as PDF files**_](https://se-education.org/guides/tutorials/savingPdf.html).
diff --git a/docs/Gemfile b/docs/Gemfile
deleted file mode 100644
index c8385d85874..00000000000
--- a/docs/Gemfile
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-source "https://rubygems.org"
-
-git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
-
-gem 'jekyll'
-gem 'github-pages', group: :jekyll_plugins
-gem 'wdm', '~> 0.1.0' if Gem.win_platform?
-gem 'webrick'
diff --git a/docs/Logging.md b/docs/Logging.md
index 5e4fb9bc217..589644ad5c6 100644
--- a/docs/Logging.md
+++ b/docs/Logging.md
@@ -1,8 +1,10 @@
---
-layout: page
-title: Logging guide
+ layout: default.md
+ title: "Logging guide"
---
+# Logging guide
+
* We are using `java.util.logging` package for logging.
* The `LogsCenter` class is used to manage the logging levels and logging destinations.
* The `Logger` for a class can be obtained using `LogsCenter.getLogger(Class)` which will log messages according to the specified logging level.
diff --git a/docs/SettingUp.md b/docs/SettingUp.md
index aef33ec72fd..5e8b9d11991 100644
--- a/docs/SettingUp.md
+++ b/docs/SettingUp.md
@@ -1,27 +1,33 @@
---
-layout: page
-title: Setting up and getting started
+ layout: default.md
+ title: "Setting up and getting started"
+ pageNav: 3
---
-* Table of Contents
-{:toc}
+# Setting up and getting started
+
+
--------------------------------------------------------------------------------------------------------------------
## Setting up the project in your computer
-
:exclamation: **Caution:**
+
+**Caution:**
Follow the steps in the following guide precisely. Things will not work out if you deviate in some steps.
-
+
First, **fork** this repo, and **clone** the fork into your computer.
If you plan to use Intellij IDEA (highly recommended):
+
1. **Configure the JDK**: Follow the guide [_[se-edu/guides] IDEA: Configuring the JDK_](https://se-education.org/guides/tutorials/intellijJdk.html) to ensure Intellij is configured to use **JDK 17**.
-1. **Import the project as a Gradle project**: Follow the guide [_[se-edu/guides] IDEA: Importing a Gradle project_](https://se-education.org/guides/tutorials/intellijImportGradleProject.html) to import the project into IDEA.
- :exclamation: Note: Importing a Gradle project is slightly different from importing a normal Java project.
+1. **Import the project as a Gradle project**: Follow the guide [_[se-edu/guides] IDEA: Importing a Gradle project_](https://se-education.org/guides/tutorials/intellijImportGradleProject.html) to import the project into IDEA.
+
+ Note: Importing a Gradle project is slightly different from importing a normal Java project.
+
1. **Verify the setup**:
1. Run the `seedu.address.Main` and try a few commands.
1. [Run the tests](Testing.md) to ensure they all pass.
@@ -34,10 +40,11 @@ If you plan to use Intellij IDEA (highly recommended):
If using IDEA, follow the guide [_[se-edu/guides] IDEA: Configuring the code style_](https://se-education.org/guides/tutorials/intellijCodeStyle.html) to set up IDEA's coding style to match ours.
-
:bulb: **Tip:**
+
+ **Tip:**
Optionally, you can follow the guide [_[se-edu/guides] Using Checkstyle_](https://se-education.org/guides/tutorials/checkstyle.html) to find how to use the CheckStyle within IDEA e.g., to report problems _as_ you write code.
-
+
1. **Set up CI**
diff --git a/docs/Testing.md b/docs/Testing.md
index 8a99e82438a..78ddc57e670 100644
--- a/docs/Testing.md
+++ b/docs/Testing.md
@@ -1,12 +1,15 @@
---
-layout: page
-title: Testing guide
+ layout: default.md
+ title: "Testing guide"
+ pageNav: 3
---
-* Table of Contents
-{:toc}
+# Testing guide
---------------------------------------------------------------------------------------------------------------------
+
+
+
+
## Running tests
@@ -19,8 +22,10 @@ There are two ways to run tests.
* **Method 2: Using Gradle**
* Open a console and run the command `gradlew clean test` (Mac/Linux: `./gradlew clean test`)
-
:link: **Link**: Read [this Gradle Tutorial from the se-edu/guides](https://se-education.org/guides/tutorials/gradle.html) to learn more about using Gradle.
-
+
+
+**Link**: Read [this Gradle Tutorial from the se-edu/guides](https://se-education.org/guides/tutorials/gradle.html) to learn more about using Gradle.
+
--------------------------------------------------------------------------------------------------------------------
diff --git a/docs/UserGuide.md b/docs/UserGuide.md
index 4e393642d76..9bede563034 100644
--- a/docs/UserGuide.md
+++ b/docs/UserGuide.md
@@ -1,50 +1,288 @@
---
-layout: page
-title: User Guide
+ layout: default.md
+ title: "User Guide"
+ pageNav: 3
---
-AddressBook Level 3 (AB3) is a **desktop app for managing contacts, optimized for use via a Command Line Interface** (CLI) while still having the benefits of a Graphical User Interface (GUI). If you can type fast, AB3 can get your contact management tasks done faster than traditional GUI apps.
+# Treasura User Guide
-* Table of Contents
-{:toc}
+Treasura is a **desktop app for managing CCA members and payments, optimized for use via a Command Line Interface (CLI)** while still having the benefits of a Graphical User Interface (GUI).
+If you can type fast, Treasura can get your CCA management tasks done faster than traditional GUI apps.
+Treasura is primarily targeted towards CCA leaders and treasurers. 🎓💼
---------------------------------------------------------------------------------------------------------------------
-## Quick start
+
+
+
+--------------------------------------------------------------------------------------------------------------------
+## 🚀 Quick start
-1. Ensure you have Java `17` or above installed in your Computer.
+1. Ensure you have Java `17` or above installed on your computer.
**Mac users:** Ensure you have the precise JDK version prescribed [here](https://se-education.org/guides/tutorials/javaInstallationMac.html).
-1. Download the latest `.jar` file from [here](https://github.com/se-edu/addressbook-level3/releases).
+2. Download the latest `.jar` file from [here](https://github.com/AY2526S1-CS2103T-W11-2/tp/releases).
-1. Copy the file to the folder you want to use as the _home folder_ for your AddressBook.
+3. Copy the file to the folder you want to use as the _home folder_ for your Treasura.
-1. Open a command terminal, `cd` into the folder you put the jar file in, and use the `java -jar addressbook.jar` command to run the application.
+4. Open a command terminal, `cd` into the folder you put the jar file in, and use the `java -jar Treasura.jar` command to run the application.
A GUI similar to the below should appear in a few seconds. Note how the app contains some sample data.
- 
+
+
+5. **Start using Treasura**
+ Type commands into the command box and press **Enter** to execute them.
+ For example, typing `help` and pressing Enter opens the help window.
-1. Type the command in the command box and press Enter to execute it. e.g. typing **`help`** and pressing Enter will open the help window.
- Some example commands you can try:
+---
- * `list` : Lists all contacts.
+### 💡 Example Commands to Try
+* `add n/John Doe p/98765432 e/johnd@example.com m/A0123456X t/friend t/owesMoney` — Adds a member named `John Doe` to Treasura.
+* `archive 3` — Archives the 3rd member shown in the current list.
- * `add n/John Doe p/98765432 e/johnd@example.com a/John street, block 123, #01-01` : Adds a contact named `John Doe` to the Address Book.
+Refer to the [Features](#features) below for details of more commands.
- * `delete 3` : Deletes the 3rd contact shown in the current list.
+---
- * `clear` : Deletes all contacts.
+## 🪙 Tutorial: Typical Treasurer Workflow using Treasura
- * `exit` : Exits the app.
+Let’s walk through a day in the life of a CCA treasurer, Alex, as he uses **Treasura** to manage member and payment records for his CCA — *NUS Music Ensemble* — at the start of a new Academic Year.
-1. Refer to the [Features](#features) below for details of each command.
+
---------------------------------------------------------------------------------------------------------------------
+**ℹ️ Note about indexes**
+
+All `INDEX` values used in commands (e.g. `edit`, `archive`, `addpayment`) refer to the numbering shown in the **currently displayed list** in the GUI.
+If you run a command such as `listarchived` or `find`, the indexes will change according to that list.
+
+
+---
+
+### 🎓 A New Semester Begins
+
+It’s the start of AY2025/2026, and Alex has not used Treasura in a while.
+He launches the app and wants to recall the available commands.
+
+He types:
+
+```
+help
+```
+
+The **Help Window** appears, showing a list of all available commands and their formats.
+
+
+Now he’s ready to get started.
+
+---
+
+### 📦 Archiving Old Members
+
+Some seniors have graduated, so Alex needs to archive them from the active member list.
+
+First, he checks the current members:
+
+```
+list
+```
+
+He sees the following list:
+
+
+John and Chloe have graduated, so Alex archives them with:
+
+```
+archive 1,2
+```
+
+Now only the active members remain in the list.
+He can confirm the archived list using:
+
+```
+listarchived
+```
+
+
+---
+
+### 👥 Adding New Members
+
+A new batch of Year 1 students has joined the CCA!
+Alex adds them to the system using the `add` command.
+
+```
+add n/Ethan Wong m/A0256789J p/98761234 e/ethanw@example.com t/performer
+add n/Sarah Tan m/A0267890L p/96543210 e/sarahtan@example.com t/exco
+add n/Lucas Koh m/A0268912M p/91234567 e/lucask@example.com t/logistics
+```
+
+To double-check, he runs:
+
+```
+list
+```
+
+and confirms that all new members appear correctly.
+
+
+
+
+Note that newly added members will appear at the bottom of the member list.
+
+
+
+---
+
+### 💰 Collecting CCA Shirt Payments
+
+The CCA has ordered new shirts costing **$21.00 each**, and some members have already paid.
+Alex records these payments in one go for multiple members.
+
+```
+addpayment 1,2 a/21.00 d/2025-09-10 r/CCA Shirt Fee
+```
+
+(Here, `1` refers to Ethan, and `2` to Sarah, based on the current list.)
+
+
+A few days later, Lucas pays as well, so Alex first finds Lucas in the list:
+```
+find Lucas
+```
+
+Then he records his payment separately:
+
+```
+addpayment 1 a/21.00 d/2025-09-12 r/CCA Shirt Fee
+```
+
+---
+
+### 🔍 Checking if a Member Has Paid
+
+Later, the president asks if *Ethan Wong* has already paid for the shirt.
+Alex first finds Ethan:
+
+```
+find ethan
+```
+
+To view Ethan's payments, Alex uses:
+
+```
+viewpayment 1
+```
+
+This shows Ethan’s payment history, confirming that he paid on **2025-09-10**.
+
-## Features
+---
+
+### 🧾 Finding a Specific Payment
+
+Alex also wants to verify if Ethan has paid for *Membership Fees*,
+but the payment list is getting long. To locate the payment precisely, he runs:
+
+```
+findpayment 1 r/membership
+```
+
+This filters only Ethan’s payments that include the remark *“membership”*.
+
+
+---
+
+### ✏️ Correcting a Payment Error
+
+After checking receipts, Alex realizes he made a mistake —
+Lucas actually paid **$23.00** because he ordered a Large shirt,
+but Alex accidentally recorded it as **$21.00**.
+
+He first finds Lucas with `find lucas` command, then lists all his payments with `viewpayment 1` command. Lastly, he fixes the payment error with:
+
+```
+editpayment 1 p/1 a/23.00
+```
+
+This updates Lucas’s first payment record to reflect the correct amount.
+
-**:information_source: Notes about the command format:**
+**ℹ️ Important note about payments**
+
+When using `editpayment` or `deletepayment`, you must specify both the member’s index and the payment’s index (with a p/ prefix).
+The payment index is the number shown after running the `viewpayment` command (not `findpayment`).
+
+
+
+**Tip:** Always use `viewpayment` first to check a member’s payment list and `INDEX` before editing or deleting any payment!
+
+
+
+
+
+---
+
+### 😅 Fixing a Typo (Undoing a Command)
+
+While editing a payment, Alex accidentally typed the wrong amount.
+No worries — he can simply undo his last change:
+
+```
+undo
+```
+
+The previous correct state is restored.
+Alex then re-applies the correct edit carefully.
+
+Note: all mutating commands can be undone using the `undo` command
+
+---
+
+### 📊 Checking Total Collections
+
+It’s nearing the end of the semester, and Alex wants to see **how much the CCA has collected in total** from all members — including membership fees, T-shirt payments, and event contributions.
+
+He uses the `viewpayment all` command to quickly view every recorded payment across all members and calculate the **cumulative total**:
+
+```
+viewpayment all
+```
+
+
+(here we assume all members have been unarchived)
+
+Treasura neatly lists all members and their respective total payment, together with a **grand total sum across all payments**.
+
+This helps Alex double-check that all funds have been properly recorded before submitting his finance report.
+
+Note that payment sums of archived members are included for financial consistency.
+
+
+---
+
+### ✅ End of the Day
+
+By the end of the session, Alex has:
+
+- Archived past members
+- Added new members
+- Recorded CCA shirt payments
+- Verified and corrected payment records
+- Used `undo` to revert a mistaken edit
+- Checked the summary of payments and grand total
+
+**All without his hands ever leaving the keyboard or opening an EXCEL spreadsheet!**
+
+
+
+**Tip:** This workflow can easily be adapted for other events (e.g., workshop fees, camp payments, or ticketed performances). Just adjust the payment remarks and dates accordingly.
+
+
+## ⚙️ Full Features
+
+
+
+**Notes about the command format:**
* Words in `UPPER_CASE` are the parameters to be supplied by the user.
e.g. in `add n/NAME`, `NAME` is a parameter which can be used as `add n/John Doe`.
@@ -53,130 +291,436 @@ AddressBook Level 3 (AB3) is a **desktop app for managing contacts, optimized fo
e.g `n/NAME [t/TAG]` can be used as `n/John Doe t/friend` or as `n/John Doe`.
* Items with `…` after them can be used multiple times including zero times.
- e.g. `[t/TAG]…` can be used as ` ` (i.e. 0 times), `t/friend`, `t/friend t/family` etc.
+ e.g. `[t/TAG]…` can be used as ` ` (i.e. 0 times), `t/friend`, `t/friend t/family` etc.
+
+* Our commands are case-insensitive. i.e. `AddPayment` and `ADDPAYMENT` will be interpreted as `addpayment`
* Parameters can be in any order.
e.g. if the command specifies `n/NAME p/PHONE_NUMBER`, `p/PHONE_NUMBER n/NAME` is also acceptable.
-* Extraneous parameters for commands that do not take in parameters (such as `help`, `list`, `exit` and `clear`) will be ignored.
- e.g. if the command specifies `help 123`, it will be interpreted as `help`.
+* Extraneous parameters for commands that do not take in parameters (such as `undo`, `list`, `exit`) will return an error if a parameter is given.
+ e.g. if the command specifies `undo 123`, it will cause an error.
+
+
+
+**Caution:**
+`help` is the one exception to this rule, to provide leeway for unfamiliar users.
+
* If you are using a PDF version of this document, be careful when copying and pasting commands that span multiple lines as space characters surrounding line-breaks may be omitted when copied over to the application.
-
-### Viewing help : `help`
+
-Shows a message explaining how to access the help page.
+
-
+**Tip:** For best results, always run `list` or `listarchived` before executing commands that use an **INDEX**.
+
-Format: `help`
+---
+### 🆘 Viewing Help : `help`
-### Adding a person: `add`
+Shows a message containing all functions.
-Adds a person to the address book.
+
-Format: `add n/NAME p/PHONE_NUMBER e/EMAIL a/ADDRESS [t/TAG]…`
+**Format:** `help`
-
:bulb: **Tip:**
-A person can have any number of tags (including 0)
-
+---
+
+## 🧍 Member Management
+
+### Adding a Member: `add`
+Adds a new member to Treasura.
+
+**Format:**
+`add n/NAME m/MATRICULATION_NUMBER p/PHONE_NUMBER e/EMAIL [t/TAG]…`
+
+**Notes:**
+* Each **Matriculation Number must be unique**. This will be the unique identifier for a member.
+* Must follow **NUS format**: `A` + 7 digits + uppercase letter (e.g., `A0123456X`).
+* Tags are optional and can be used for roles (e.g., `exco`, `performer`).
+* Phone numbers must be 8 digits.
+* Emails can be of any form following an @. Edit and undo features are present in the event of typos for email.
+* Newly added members will appear at the bottom of the current list.
-Examples:
-* `add n/John Doe p/98765432 e/johnd@example.com a/John street, block 123, #01-01`
-* `add n/Betsy Crowe t/friend e/betsycrowe@example.com a/Newgate Prison p/1234567 t/criminal`
+**Examples:**
+- `add n/John Doe m/A0123456X p/98765432 e/john@example.com`
+- `add n/Betsy Crowe m/A0234567Y p/91234567 e/betsy@example.com t/exco t/publicity`
-### Listing all persons : `list`
+---
+
+### Listing All Members: `list`
+Displays all **active** members.
+
+**Format:**
+`list`
+
+
+
+**Caution:**
+Adding an argument will cause an error!
+
+
+
+---
-Shows a list of all persons in the address book.
+
-Format: `list`
+### Finding Members: `find`
+Finds members whose names or tags match the given keywords.
-### Editing a person : `edit`
+**Format:**
+`find KEYWORD [MORE_KEYWORDS]`
+
+**Notes:**
+* The search is case-insensitive. e.g. `hans` will match `Hans`.
+* The order of the keywords does not matter. e.g. `Hans Bo` will match `Bo Hans`.
+* Only full words will be matched for names e.g. `Han` will not match `Hans`.
+* When finding via tag, any case-insensitive, exact tag match will be shown (e.g. `OWESmoney` matches `owesMoney`, but `owe` does not).
+* Members matching at least one keyword will be returned (i.e. `OR` search).
+* Archived members are not included in the search results.
+
+**Examples:**
+* `find John` — returns all members with the name “John”.
+* `find Alex David` — returns members named “Alex” or “David”.
+* `find Alex family` — returns members named “Alex” or tagged with “family”.
+
+**Expected output:**
+
+
+
+
+
+---
-Edits an existing person in the address book.
+### Editing a Member: `edit`
+Edits details of an existing member.
-Format: `edit INDEX [n/NAME] [p/PHONE] [e/EMAIL] [a/ADDRESS] [t/TAG]…`
+**Format:**
+`edit INDEX [n/NAME] [m/MATRICULATION_NUMBER] [p/PHONE_NUMBER] [e/EMAIL] [t/TAG]…`
-* Edits the person at the specified `INDEX`. The index refers to the index number shown in the displayed person list. The index **must be a positive integer** 1, 2, 3, …
-* At least one of the optional fields must be provided.
-* Existing values will be updated to the input values.
-* When editing tags, the existing tags of the person will be removed i.e adding of tags is not cumulative.
-* You can remove all the person’s tags by typing `t/` without
- specifying any tags after it.
+**Notes:**
+* **INDEX** refers to the member’s number in the displayed list.
+* At least one field must be provided.
+* Editing tags replaces all existing tags. Use `t/` to remove all tags.
+* Updated Matriculation Numbers must remain **unique** and **NUS-formatted**.
-Examples:
-* `edit 1 p/91234567 e/johndoe@example.com` Edits the phone number and email address of the 1st person to be `91234567` and `johndoe@example.com` respectively.
-* `edit 2 n/Betsy Crower t/` Edits the name of the 2nd person to be `Betsy Crower` and clears all existing tags.
+**Examples:**
+- `edit 1 p/91234567 e/johndoe@example.com`
+- `edit 2 n/Betsy Crower t/`
+- `edit 3 m/A0987654Z`
+
+---
+
+
+
+### Archiving a Member: `archive`
+Archives a member, hiding them from the active list but keeping their records.
+
+**Format:**
+`archive INDEX[,INDEX]`
+
+**Notes:**
+* Run `list` first to check the indices before archiving members.
+* Archives the person at the specified `INDEX`.
+* The index **must be a positive integer** 1, 2, 3, …
+
+**Examples:**
+- `archive 1` — archives the 1st member.
+- `archive 1,3,4` — archives the 1st, 3rd and 4th members.
+
+
+
+**Tip:** Members who have been archived still keep their payment and member details. Their details can be viewed via using `listarchived` and `viewpayment INDEX` or `view INDEX`.
+
+
+**Expected output:**
+
+
+
+---
-### Locating persons by name: `find`
+### Listing Archived Members: `listarchived`
+Displays all archived members.
-Finds persons whose names contain any of the given keywords.
+**Format:**
+`listarchived`
-Format: `find KEYWORD [MORE_KEYWORDS]`
+**Example:**
+- `listarchived` — lists all archived members.
-* The search is case-insensitive. e.g `hans` will match `Hans`
-* The order of the keywords does not matter. e.g. `Hans Bo` will match `Bo Hans`
-* Only the name is searched.
-* Only full words will be matched e.g. `Han` will not match `Hans`
-* Persons matching at least one keyword will be returned (i.e. `OR` search).
- e.g. `Hans Bo` will return `Hans Gruber`, `Bo Yang`
+
-Examples:
-* `find John` returns `john` and `John Doe`
-* `find alex david` returns `Alex Yeoh`, `David Li`
- 
+**Caution:**
+Adding an argument will cause an error!
-### Deleting a person : `delete`
+
-Deletes the specified person from the address book.
+**Expected output:**
-Format: `delete INDEX`
+
-* Deletes the person at the specified `INDEX`.
+---
+
+### Unarchiving a Member: `unarchive`
+Restores an archived member to the active list.
+
+**Format:**
+`unarchive INDEX[,INDEX]`
+
+**Notes:**
+* The index refers to the index number shown in the displayed person list, after using `listarchived`
+* Restored members retain all previous details and payments.
+* The index **must be a positive integer** 1, 2, 3, …
+
+**Examples:**
+* `listarchived` followed by `unarchive 2` unarchives the 2nd person in the archived list.
+* `listarchived` followed by `unarchive 1,2,4` unarchives the 1st, 2nd and 4th members in the archived list.
+
+**Expected output:**
+
+
+
+---
+
+### Viewing a Member: `view`
+View a member's details in the result box.
+
+**Format:**
+`view INDEX`
+
+**Notes:**
* The index refers to the index number shown in the displayed person list.
* The index **must be a positive integer** 1, 2, 3, …
+* After using the command, the member's name, phone number, email, matric number, tags, archive status and number of payments wil be displayed.
+
+**Examples:**
+* `view 1` provides the detail summary for the 1st person in the current displayed list.
+
+
+
+---
+
+## 💰 Payment Management
+
+All payment commands function for archived members using indices from listarchived.
+
+
+### Adding a Payment: `addpayment`
+
+Adds a payment to one or more members specified by their indices.
+
+**Format:**
+`addpayment INDEX[,INDEX]... a/AMOUNT d/DATE [r/REMARKS]`
+
+**Notes:**
+* The index refers to the member(s) shown in the current displayed list.
+* `a/AMOUNT` is the payment amount in dollars and cents (e.g., 25.00).
+* `d/DATE` follows the `YYYY-MM-DD` format. Invalid and future dates are not allowed.
+* `[r/REMARKS]` is optional for short notes such as “Membership Fee” or “CCA Shirt”. Special characters are allowed.
+* `addpayment` can be performed for archived members using indices from `listarchived`
+
+**Examples:**
+* `addpayment 1 a/20.00 d/2025-03-12 r/Membership fee`
+
+
+
+---
+
+### Edit payment(s): `editpayment`
+
+Edits an existing payment record for the specified member.
+
+**Format:**
+`editpayment PERSON_INDEX p/PAYMENT_INDEX [a/AMOUNT] [d/DATE] [r/REMARKS]`
-Examples:
-* `list` followed by `delete 2` deletes the 2nd person in the address book.
-* `find Betsy` followed by `delete 1` deletes the 1st person in the results of the `find` command.
+**Notes:**
+* `PERSON_INDEX` is the index of the member.
+* `p/PAYMENT_INDEX` refers to the payment number listed in that member’s payment history.
+* You can update one or more details: amount, date, or remarks.
-### Clearing all entries : `clear`
+**Examples:**
+* `editpayment 1 p/2 a/30.00` — updates payment #2 for member #1 to $30.00.
+* `editpayment 3 p/1 r/Corrected to event fee` — changes the remark for payment #1 of member #3.
-Clears all entries from the address book.
+---
+
+### Viewing Payments: `viewpayment`
+Displays payment details for a specific member, or for all members.
+
+**Format:**
+`viewpayment INDEX`
+or
+`viewpayment all`
+
+**Notes:**
+* Use `viewpayment INDEX` to show all payments made by a single member.
+* Use `viewpayment all` to view payments for every member in Treasura.
+* If the payment history is too long, feel free to use `findpayment`.
+
+**Examples:**
+* `viewpayment 2` — shows all payments made by the 2nd member.
+* `viewpayment all` — lists all recorded payments in Treasura.
+
+---
+
+### Delete payment(s): `deletepayment`
-Format: `clear`
+Deletes payment(s) from a specified member index.
+
+**Format:**
+`deletepayment PERSON_INDEX, p/PAYMENT_INDEX[,PAYMENT_INDEX]...`
+
+**Notes:**
+* `PERSON_INDEX` refers to the member.
+* `p/PAYMENT_INDEX` refers to the payment number(s) to delete from the specified member.
+
+**Examples:**
+* `deletepayment 1 p/2` — deletes payment #2 for member #1.
+* `deletepayment 1 p/1,2,3` — deletes payment #1,2 and 3 for member #1.
+
+
+
+**Tip:** `deletepayment` can be reversed if `undo` is performed.
+
+
+---
-### Exiting the program : `exit`
+### Finding Payments: `findpayment`
+Finds payments made by a specific member using filters.
-Exits the program.
+**Format:**
+`findpayment INDEX [a/AMOUNT] [r/REMARK] [d/DATE]`
-Format: `exit`
+**Notes:**
+* Search within a member’s payment history.
+* Find a payment using only **1 filter** at a time. Multiple filters used in the search will be rejected.
+* Finding a payment by remark is case-insensitive
+
+**Examples:**
+- `findpayment 1 a/50.00`
+- `findpayment 2 r/Workshop`
+- `findpayment 3 d/2025-03-15`
+
+
+## ⚙️ General Commands
+
+
+
+### Undoing an action: `undo`
+
+Undoes the most recent mutating action performed in Treasura.
+
+**Format:** `undo`
+
+**Notes:**
+- Reverses the **last mutating command** (e.g., state-changing commands such as `add`, `edit`, `archive`, `unarchive`, `addpayment`, `editpayment`, `deletepayment`).
+- You can `undo` a `redo` (i.e., undoing reverts the re-applied change).
+- Non-mutating commands (e.g., `list`, `find`, `help`, `viewpayment`, `findpayment`) **do not** affect the undo history.
+
+**Examples:**
+```text
+add n/Ali p/91234567 e/ali@example.com m/A1234567X
+undo ← removes the person that was just added
+archive 1,2,3
+undo ← restores the archived members to active
+redo ← re-archives the members
+undo ← restores the archived members back to active
+```
+
+
+**Caution:**
+Adding an argument will cause an error!
+
+
+
+
+---
+
+### Redoing an action: `redo`
+
+Reapplies the most recently undone mutating action.
+
+**Format:** `redo`
+
+**Notes:**
+* Performs the last change that was previously undone using the `undo` command.
+* If a new mutating command (e.g., `add`, `edit`, `archive`, `unarchive`, `addpayment`, `deletepayment`) is executed after an `undo`, the redo history is cleared.
+ This prevents redoing outdated actions after the user starts a new timeline.
+* Non-mutating commands (e.g., `list`, `find`, `help`, `viewpayment`, `findpayment`) do **not** affect the redo history.
+
+**Examples:**
+```text
+archive 2
+undo ← restores member 2 to the active list
+redo ← re-archives member 2 again
+
+addpayment 1 a/50.00 d/2025-10-27
+undo
+redo ← re-applies the payment of $50.00 for person 1
+```
+
+
+**Caution:**
+Adding an argument will cause an error!
+
+
+
+---
+
+
+### Exiting the Program: `exit`
+Closes Treasura.
+
+**Format:**
+`exit`
+
+
+
+**Caution:**
+Adding an argument will cause an error!
+
+
+
+
+
+---
### Saving the data
-AddressBook data are saved in the hard disk automatically after any command that changes the data. There is no need to save manually.
+Treasura data is saved in the hard disk automatically after any command that changes the data. There is no need to save manually.
### Editing the data file
-AddressBook data are saved automatically as a JSON file `[JAR file location]/data/addressbook.json`. Advanced users are welcome to update data directly by editing that data file.
+Treasura data is saved automatically as a JSON file `[JAR file location]/data/addressbook.json`. Advanced users are welcome to update data directly by editing that data file.
-
:exclamation: **Caution:**
-If your changes to the data file makes its format invalid, AddressBook will discard all data and start with an empty data file at the next run. Hence, it is recommended to take a backup of the file before editing it.
-Furthermore, certain edits can cause the AddressBook to behave in unexpected ways (e.g., if a value entered is outside of the acceptable range). Therefore, edit the data file only if you are confident that you can update it correctly.
-
+
-### Archiving data files `[coming in v2.0]`
+**Caution:**
+If your changes to the data file makes its format invalid, Treasura will discard all data and start with an empty data file at the next run. Hence, it is recommended to take a backup of the file before editing it.
+Furthermore, certain edits can cause the Treasura to behave in unexpected ways (e.g., if a value entered is outside the acceptable range). Therefore, edit the data file only if you are confident that you can update it correctly.
+
-_Details coming soon ..._
--------------------------------------------------------------------------------------------------------------------
## FAQ
-**Q**: How do I transfer my data to another Computer?
-**A**: Install the app in the other computer and overwrite the empty data file it creates with the file that contains the data of your previous AddressBook home folder.
+**Q**: How do I transfer my data to another computer?
+**A**: Install the app in the other computer and overwrite the empty data file it creates with the file that contains the data of your previous Treasura home folder.
+
+**Q**: Can I delete a member?
+**A**: Deleting a member accidentally can wipe out his/her entire payment history, therefore the app only supports archiving a member. You can also use edit command to swap out the details of the unwanted member with that of a new member.
+
+**Q**: How do I streamline the process of tracking members and their payments?
+**A**: Adding a tag to members and a remark to payments is highly recommended, because it allows you to filter through the members and payments quickly, using find and findpayment commands.
+
+**Q**: If I archive a member, will his/her payments be removed?
+**A**: The archived member's payments will be removed from the main payment history, but you can still access them from viewing the payment of the archived list.
+
+**Q**: Can I perform a full reset on Treasura data?
+**A**: At the moment, we do not support mass removal of user data, since the `clear` function was removed to ensure safety. This feature may be implemented in the future with additional safety measures.
--------------------------------------------------------------------------------------------------------------------
@@ -184,17 +728,64 @@ _Details coming soon ..._
1. **When using multiple screens**, if you move the application to a secondary screen, and later switch to using only the primary screen, the GUI will open off-screen. The remedy is to delete the `preferences.json` file created by the application before running the application again.
2. **If you minimize the Help Window** and then run the `help` command (or use the `Help` menu, or the keyboard shortcut `F1`) again, the original Help Window will remain minimized, and no new Help Window will appear. The remedy is to manually restore the minimized Help Window.
+3. Undo history is cleared upon application restart.
+4. There is a constraint of 100 characters for a name, email, remark or 30 characters for tag. Breaching this limit may cause UI errors and unresponsiveness.
+5. Only one Treasura instance can access a data file at a time - opening multiple windows at once might not save data properly.
+6. Member payments are currently displayed in the command result panel. We will be adding a separate dashboard to view payments seamlessly in the future.
--------------------------------------------------------------------------------------------------------------------
+
+
## Command summary
-Action | Format, Examples
---------|------------------
-**Add** | `add n/NAME p/PHONE_NUMBER e/EMAIL a/ADDRESS [t/TAG]…` e.g., `add n/James Ho p/22224444 e/jamesho@example.com a/123, Clementi Rd, 1234665 t/friend t/colleague`
-**Clear** | `clear`
-**Delete** | `delete INDEX` e.g., `delete 3`
-**Edit** | `edit INDEX [n/NAME] [p/PHONE_NUMBER] [e/EMAIL] [a/ADDRESS] [t/TAG]…` e.g.,`edit 2 n/James Lee e/jameslee@example.com`
-**Find** | `find KEYWORD [MORE_KEYWORDS]` e.g., `find James Jake`
-**List** | `list`
-**Help** | `help`
+| Action | Format | Example(s) |
+|---------------------|----------------------------------------------------------------------------|------------------------------------------------------------------------------------|
+| **Add** | `add n/NAME p/PHONE e/EMAIL m/MATRIC [t/TAG]...` | `add n/James Ho p/22224444 e/jamesho@example.com m/A0273010Y t/friend t/treasurer` |
+| **Edit** | `edit INDEX [n/NAME] [p/PHONE] [e/EMAIL] [m/MATRIC] [t/TAG]...` | `edit 2 n/James Lee e/jameslee@example.com` |
+| **Undo** | `undo` | `undo` |
+| **Redo** | `redo` | `redo` |
+| **Find** | `find KEYWORD [MORE_KEYWORDS]...` | `find James treasurer` |
+| **List** | `list` | `list` |
+| **List Archived** | `listarchived` | `listarchived` |
+| **Archive** | `archive INDEX[,INDEX]...` | `archive 1,2,5` |
+| **Unarchive** | `unarchive INDEX[,INDEX]...` | `unarchive 2,5` |
+| **View Member** | `view INDEX` | `view 4` |
+| **Add Payment** | `addpayment INDEX[,INDEX]... a/AMOUNT d/DATE [r/REMARKS]` | `addpayment 1,3 a/25.00 d/2025-10-24 r/Monthly dues` |
+| **Edit Payment** | `editpayment PERSON_INDEX p/PAYMENT_INDEX [a/AMOUNT] [d/DATE] [r/REMARKS]` | `editpayment 2 p/1 a/30.00 r/Corrected` |
+| **Delete Payment** | `deletepayment PERSON_INDEX p/PAYMENT_INDEX[,PAYMENT_INDEX]...` | `deletepayment 1 p/1,2` |
+| **View Payment(s)** | `viewpayment INDEX` or `viewpayment all` | `viewpayment 2`, `viewpayment all` |
+| **Find Payment** | `findpayment INDEX [a/AMOUNT] [r/REMARK] [d/DATE]` | `findpayment 1 a/50.00`, `findpayment 2 r/Workshop`, `findpayment 3 d/2025-03-15` |
+| **Help** | `help` | `help` |
+
+
+### Glossary
+
+* Member = A NUS student part of a CCA
+* CCA = Co-circucular activity
+* Matriculation number = A unique ID given to all NUS students. Starts with A, followed by 7 digits and ending with any upper case letter.
+* JSON = A file format used to store Treasura data.
+* Mutating action = A command that alters any data in Treasura.
+
+
+
+--------------------------------------------------------------------------------------------------------------------
+
+## Command constraints
+
+| Param | Format | Limits | Notes |
+| --------------- | ------------------------------------- |---------------------------| ------------------------- |
+| `NAME` | Printable chars, trimmed | 1–100 chars | No newline |
+| `PHONE` | Positive digits, e.g. 87654321 | 8 digits | No special character (+) |
+| `EMAIL` | Contains exactly one One `@`, has `.` | 5–100 chars | Case preserved |
+| `MATRIC` | `^A\d{7}[A-Z]$` | 9 chars | Must be unique |
+| `TAG` | Alnum, `_` or `-` | 1–30 chars each, ≤10 tags | Case-insensitive, dedupe |
+| `INDEX` | Positive int from current list | 1…list size | Comma list, dedupe, ≤50 |
+| `PAYMENT_INDEX` | Positive int from `viewpayment` | 1…payment count | 1-based |
+| `AMOUNT` | Decimal, ≤2 dp | 0.01–1,000,000.00 | No zero or negative |
+| `DATE` | `YYYY-MM-DD`, valid | 1970-01-01 to today | No future dates |
+| `REMARKS` | Printable, trimmed | 0–100 chars | — |
+| `KEYWORD` | Space-separated tokens | 1–30 chars each, ≤10 | Name field only, OR match |
+| `a/` | Amount exact | — | Same as `AMOUNT` |
+| `d/` | Date exact | — | Same as `DATE` |
+| `r/` | Substring on remarks | 1–30 chars | Case-insensitive |
diff --git a/docs/_config.yml b/docs/_config.yml
deleted file mode 100644
index 6bd245d8f4e..00000000000
--- a/docs/_config.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-title: "AB-3"
-theme: minima
-
-header_pages:
- - UserGuide.md
- - DeveloperGuide.md
- - AboutUs.md
-
-markdown: kramdown
-
-repository: "se-edu/addressbook-level3"
-github_icon: "images/github-icon.png"
-
-plugins:
- - jemoji
diff --git a/docs/_data/projects.yml b/docs/_data/projects.yml
deleted file mode 100644
index 8f3e50cb601..00000000000
--- a/docs/_data/projects.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-- name: "AB-1"
- url: https://se-edu.github.io/addressbook-level1
-
-- name: "AB-2"
- url: https://se-edu.github.io/addressbook-level2
-
-- name: "AB-3"
- url: https://se-edu.github.io/addressbook-level3
-
-- name: "AB-4"
- url: https://se-edu.github.io/addressbook-level4
-
-- name: "Duke"
- url: https://se-edu.github.io/duke
-
-- name: "Collate"
- url: https://se-edu.github.io/collate
-
-- name: "Book"
- url: https://se-edu.github.io/se-book
-
-- name: "Resources"
- url: https://se-edu.github.io/resources
diff --git a/docs/_includes/custom-head.html b/docs/_includes/custom-head.html
deleted file mode 100644
index 8559a67ffad..00000000000
--- a/docs/_includes/custom-head.html
+++ /dev/null
@@ -1,6 +0,0 @@
-{% comment %}
- Placeholder to allow defining custom head, in principle, you can add anything here, e.g. favicons:
-
- 1. Head over to https://realfavicongenerator.net/ to add your own favicons.
- 2. Customize default _includes/custom-head.html in your source directory and insert the given code snippet.
-{% endcomment %}
diff --git a/docs/_includes/head.html b/docs/_includes/head.html
deleted file mode 100644
index 83ac5326933..00000000000
--- a/docs/_includes/head.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
- {%- include custom-head.html -%}
-
- {{page.title}}
-
-
diff --git a/docs/_includes/header.html b/docs/_includes/header.html
deleted file mode 100644
index 33badcd4f99..00000000000
--- a/docs/_includes/header.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
Indexes refer to the currently displayed person list.
+ * Duplicate indexes are permitted in input but will be de-duplicated internally
+ * while preserving the first-seen order.
+ */
+public class ArchiveCommand extends Command {
+
+ public static final String COMMAND_WORD = "archive";
+
+ public static final String MESSAGE_USAGE = COMMAND_WORD
+ + ": Archives one or more persons identified by their indexes in the displayed person list.\n"
+ + "Parameters: INDEX[,INDEX]... (each must be a positive integer)\n"
+ + "Example (single): " + COMMAND_WORD + " 2\n"
+ + "Example (multiple): " + COMMAND_WORD + " 1,3,5";
+
+ public static final String MESSAGE_ALREADY_ARCHIVED = "One or more selected persons are already archived: %s";
+
+ public static final String MESSAGE_SUCCESS = "Archived: %s";
+
+ private static final Logger logger = LogsCenter.getLogger(ArchiveCommand.class);
+
+ private final List targetIndexes;
+
+ /**
+ * Constructs an {@code ArchiveCommand} to archive one or more persons in the displayed list.
+ * Duplicate indexes are removed while preserving the original order.
+ *
+ * @param targetIndexes indexes (1-based in UI) of persons to archive; must not be {@code null}.
+ */
+ public ArchiveCommand(List targetIndexes) {
+ requireNonNull(targetIndexes);
+ this.targetIndexes = removeDuplicates(targetIndexes);
+ }
+
+ /**
+ * Archives the selected persons and returns a {@link CommandResult} with a summary.
+ *
+ *
Behavior:
+ *
+ *
Validates indexes against the currently displayed person list.
+ *
Fails if any targeted person is already archived.
+ *
Archives all valid targets atomically (same failure path for any invalid state).
+ *
Refreshes the filtered list to show active persons after completion.
+ *
+ *
+ * @param model model providing access to persons and mutation APIs; must not be {@code null}.
+ * @return result containing the names of archived persons.
+ * @throws CommandException if an index is out of bounds or a person is already archived.
+ */
+ @Override
+ public CommandResult execute(Model model) throws CommandException {
+ requireNonNull(model);
+ List displayedPersons = model.getFilteredPersonList();
+
+ logger.info(String.format("[ArchiveCommand] Executing with indexes: %s",
+ formatIndexes(targetIndexes)));
+
+ List personsToArchive = validateAndCollect(displayedPersons);
+ List archivedNames = applyArchive(model, personsToArchive);
+
+ model.updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
+ String result = String.format(MESSAGE_SUCCESS, String.join(", ", archivedNames));
+ logger.info("[ArchiveCommand] Success: " + result);
+ return new CommandResult(result);
+ }
+
+ // ----------------------------------------------------
+ // Helper methods
+ // ----------------------------------------------------
+
+ /**
+ * Removes duplicate indexes while preserving their original order.
+ *
+ * @param indexes list that may contain duplicates.
+ * @return new list with duplicates removed.
+ */
+ private List removeDuplicates(List indexes) {
+ Set seen = new LinkedHashSet<>();
+ return indexes.stream()
+ .filter(i -> seen.add(i.getZeroBased()))
+ .toList();
+ }
+
+ /**
+ * Formats indexes as a comma-separated string of their one-based values.
+ *
+ * @param indexes indexes to format.
+ * @return formatted string, e.g., {@code "1, 3, 5"}.
+ */
+ private String formatIndexes(List indexes) {
+ return indexes.stream()
+ .map(Index::getOneBased)
+ .map(Object::toString)
+ .collect(Collectors.joining(", "));
+ }
+
+ /**
+ * Validates all target indexes and collects the corresponding persons to archive.
+ *
+ *
Validation steps:
+ *
+ *
Ensure each index is within bounds of the displayed person list.
+ *
Detect if any targeted person is already archived; if found, fail the command.
+ *
+ *
+ * @param displayedPersons current list of persons shown to the user.
+ * @return list of persons to archive.
+ * @throws CommandException if any index is out of bounds or any person is already archived.
+ */
+ private List validateAndCollect(List displayedPersons) throws CommandException {
+ List personsToArchive = new ArrayList<>(targetIndexes.size());
+ List alreadyArchivedNames = new ArrayList<>();
+
+ for (Index targetIndex : targetIndexes) {
+ Person person = getValidPerson(displayedPersons, targetIndex);
+
+ if (person.isArchived()) {
+ alreadyArchivedNames.add(person.getName().toString());
+ }
+ personsToArchive.add(person);
+ }
+
+ if (!alreadyArchivedNames.isEmpty()) {
+ String names = String.join(", ", alreadyArchivedNames);
+ logger.warning("[ArchiveCommand] Already archived: " + names);
+ throw new CommandException(String.format(MESSAGE_ALREADY_ARCHIVED, names));
+ }
+
+ return personsToArchive;
+ }
+
+ /**
+ * Resolves an index to a valid {@link Person} from the displayed list.
+ *
+ * @param displayedPersons list being referenced by the user's indexes.
+ * @param index index to resolve.
+ * @return the referenced person.
+ * @throws CommandException if the index is out of bounds.
+ */
+ private Person getValidPerson(List displayedPersons, Index index) throws CommandException {
+ int zeroBasedIndex = index.getZeroBased();
+ if (zeroBasedIndex >= displayedPersons.size()) {
+ logger.warning(String.format("[ArchiveCommand] Invalid index: %d (size: %d)",
+ zeroBasedIndex, displayedPersons.size()));
+ throw new CommandException(Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+ return displayedPersons.get(zeroBasedIndex);
+ }
+
+ /**
+ * Applies the archive flag to each person and persists updates through the model.
+ *
+ * @param model model used to mutate the person entries.
+ * @param personsToArchive persons to archive.
+ * @return ordered list of archived person names, for display.
+ */
+ private List applyArchive(Model model, List personsToArchive) {
+ List archivedNames = new ArrayList<>(personsToArchive.size());
+ for (Person originalPerson : personsToArchive) {
+ Person archivedPerson = originalPerson.withArchived(true);
+ model.setPerson(originalPerson, archivedPerson);
+ archivedNames.add(archivedPerson.getName().toString());
+ logger.fine("[ArchiveCommand] Archived: " + archivedPerson.getName());
+ }
+ return archivedNames;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean equals(Object other) {
+ return other == this
+ || (other instanceof ArchiveCommand
+ && targetIndexes.equals(((ArchiveCommand) other).targetIndexes));
+ }
+
+ /**
+ * Indicates that this command mutates the model.
+ *
+ * @return always {@code true}.
+ */
+ @Override
+ public boolean isMutating() {
+ return true;
+ }
+}
diff --git a/src/main/java/seedu/address/logic/commands/ClearCommand.java b/src/main/java/seedu/address/logic/commands/ClearCommand.java
deleted file mode 100644
index 9c86b1fa6e4..00000000000
--- a/src/main/java/seedu/address/logic/commands/ClearCommand.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package seedu.address.logic.commands;
-
-import static java.util.Objects.requireNonNull;
-
-import seedu.address.model.AddressBook;
-import seedu.address.model.Model;
-
-/**
- * Clears the address book.
- */
-public class ClearCommand extends Command {
-
- public static final String COMMAND_WORD = "clear";
- public static final String MESSAGE_SUCCESS = "Address book has been cleared!";
-
-
- @Override
- public CommandResult execute(Model model) {
- requireNonNull(model);
- model.setAddressBook(new AddressBook());
- return new CommandResult(MESSAGE_SUCCESS);
- }
-}
diff --git a/src/main/java/seedu/address/logic/commands/Command.java b/src/main/java/seedu/address/logic/commands/Command.java
index 64f18992160..47eba7219b3 100644
--- a/src/main/java/seedu/address/logic/commands/Command.java
+++ b/src/main/java/seedu/address/logic/commands/Command.java
@@ -17,4 +17,7 @@ public abstract class Command {
*/
public abstract CommandResult execute(Model model) throws CommandException;
+ public boolean isMutating() {
+ return false;
+ }
}
diff --git a/src/main/java/seedu/address/logic/commands/DeleteCommand.java b/src/main/java/seedu/address/logic/commands/DeleteCommand.java
deleted file mode 100644
index 1135ac19b74..00000000000
--- a/src/main/java/seedu/address/logic/commands/DeleteCommand.java
+++ /dev/null
@@ -1,69 +0,0 @@
-package seedu.address.logic.commands;
-
-import static java.util.Objects.requireNonNull;
-
-import java.util.List;
-
-import seedu.address.commons.core.index.Index;
-import seedu.address.commons.util.ToStringBuilder;
-import seedu.address.logic.Messages;
-import seedu.address.logic.commands.exceptions.CommandException;
-import seedu.address.model.Model;
-import seedu.address.model.person.Person;
-
-/**
- * Deletes a person identified using it's displayed index from the address book.
- */
-public class DeleteCommand extends Command {
-
- public static final String COMMAND_WORD = "delete";
-
- public static final String MESSAGE_USAGE = COMMAND_WORD
- + ": Deletes the person identified by the index number used in the displayed person list.\n"
- + "Parameters: INDEX (must be a positive integer)\n"
- + "Example: " + COMMAND_WORD + " 1";
-
- public static final String MESSAGE_DELETE_PERSON_SUCCESS = "Deleted Person: %1$s";
-
- private final Index targetIndex;
-
- public DeleteCommand(Index targetIndex) {
- this.targetIndex = targetIndex;
- }
-
- @Override
- public CommandResult execute(Model model) throws CommandException {
- requireNonNull(model);
- List lastShownList = model.getFilteredPersonList();
-
- if (targetIndex.getZeroBased() >= lastShownList.size()) {
- throw new CommandException(Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
- }
-
- Person personToDelete = lastShownList.get(targetIndex.getZeroBased());
- model.deletePerson(personToDelete);
- return new CommandResult(String.format(MESSAGE_DELETE_PERSON_SUCCESS, Messages.format(personToDelete)));
- }
-
- @Override
- public boolean equals(Object other) {
- if (other == this) {
- return true;
- }
-
- // instanceof handles nulls
- if (!(other instanceof DeleteCommand)) {
- return false;
- }
-
- DeleteCommand otherDeleteCommand = (DeleteCommand) other;
- return targetIndex.equals(otherDeleteCommand.targetIndex);
- }
-
- @Override
- public String toString() {
- return new ToStringBuilder(this)
- .add("targetIndex", targetIndex)
- .toString();
- }
-}
diff --git a/src/main/java/seedu/address/logic/commands/DeletePaymentCommand.java b/src/main/java/seedu/address/logic/commands/DeletePaymentCommand.java
new file mode 100644
index 00000000000..156b417d1a3
--- /dev/null
+++ b/src/main/java/seedu/address/logic/commands/DeletePaymentCommand.java
@@ -0,0 +1,167 @@
+package seedu.address.logic.commands;
+
+import static java.util.Objects.requireNonNull;
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX;
+
+import java.util.List;
+import java.util.logging.Logger;
+
+import seedu.address.commons.core.LogsCenter;
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.Person;
+
+/**
+ * Deletes one or more payments (by their display indices) from a single person.
+ */
+public class DeletePaymentCommand extends Command {
+
+ public static final String COMMAND_WORD = "deletepayment";
+
+ public static final String MESSAGE_USAGE = COMMAND_WORD
+ + ": Deletes one or more payments from the person identified by the displayed index.\n"
+ + "Parameters: PERSON_INDEX p/PAYMENT_INDEX[,PAYMENT_INDEX]...\n"
+ + "Notes: PAYMENT_INDEX refers to the display order shown by 'viewpayment'. "
+ + "Duplicates are not allowed. Indexes must be a positive integer.\n"
+ + "Example: " + COMMAND_WORD + " 2 p/1,2,3";
+
+ public static final String MESSAGE_SUCCESS = "Deleted payment(s) #%s from %s";
+ public static final String MESSAGE_INVALID_PAYMENT_INDEX = "Invalid payment index #%d for person: %s";
+ public static final String MESSAGE_NO_PAYMENTS = "This person has no payments to delete.";
+
+ private static final Logger logger = LogsCenter.getLogger(DeletePaymentCommand.class);
+
+ private final Index personIndex; // one person (1-based input)
+ private final List paymentIndexes; // one or more payment indices (1-based input)
+
+ /**
+ * Creates a DeletePaymentCommand to delete the specified payment(s).
+ */
+ public DeletePaymentCommand(Index personIndex, List paymentIndexes) {
+ requireNonNull(personIndex);
+ requireNonNull(paymentIndexes);
+ this.personIndex = personIndex;
+ this.paymentIndexes = List.copyOf(paymentIndexes);
+ }
+
+ @Override
+ public CommandResult execute(Model model) throws CommandException {
+ requireNonNull(model);
+
+ logger.info(() -> String.format("Executing DeletePaymentCommand for person #%d with payment indexes %s",
+ personIndex.getOneBased(),
+ paymentIndexes.stream().map(Index::getOneBased).toList()));
+
+ final Person target = resolveTargetPerson(model);
+ logger.fine(() -> String.format("Resolved target person: %s (%s)",
+ target.getName(), target.getMatriculationNumber()));
+
+ final List displayList = Payment.inDisplayOrder(target.getPayments());
+ logger.fine(() -> String.format("Person has %d payment(s) before deletion", displayList.size()));
+
+ if (displayList.isEmpty()) {
+ logger.warning(() -> String.format("DeletePayment failed: %s has no payments.", target.getName()));
+ throw new CommandException(MESSAGE_NO_PAYMENTS);
+ }
+
+ final List zeroBased = toZeroBased(paymentIndexes);
+
+ validatePaymentIndices(zeroBased, displayList.size(), target);
+
+ // Resolve payments to delete
+ final List toDelete = zeroBased.stream()
+ .map(displayList::get)
+ .toList();
+
+ logger.fine(() -> String.format("Deleting %d payment(s): %s",
+ toDelete.size(),
+ toDelete.stream().map(Payment::toString).toList()));
+
+ // Apply deletion (by identity, order doesn't matter)
+ Person updated = removePayments(target, toDelete);
+ model.setPerson(target, updated);
+
+ // Build success message
+ final String joined = paymentIndexes.stream()
+ .map(i -> Integer.toString(i.getOneBased()))
+ .collect(java.util.stream.Collectors.joining(","));
+
+ logger.info(() -> String.format("Deleted payment(s) #%s from %s successfully", joined, updated.getName()));
+
+ return new CommandResult(String.format(MESSAGE_SUCCESS, joined, updated.getName()));
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other == this
+ || (other instanceof DeletePaymentCommand)
+ && personIndex.equals(((DeletePaymentCommand) other).personIndex)
+ && paymentIndexes.equals(((DeletePaymentCommand) other).paymentIndexes);
+ }
+
+ @Override
+ public boolean isMutating() {
+ return true;
+ }
+
+ // ========
+ // Helpers
+ // ========
+
+ /**
+ * Resolves the target {@link Person} from the model's filtered list using {@code personIndex}.
+ *
+ * @throws CommandException if the person index is out of bounds.
+ */
+ private Person resolveTargetPerson(Model model) throws CommandException {
+ final List shown = model.getFilteredPersonList();
+ final int pZero = personIndex.getZeroBased();
+ if (pZero < 0 || pZero >= shown.size()) {
+ logger.warning(() -> String.format(
+ "Invalid person index %d (list size: %d)", personIndex.getOneBased(), shown.size()));
+ throw new CommandException(MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+ return shown.get(pZero);
+ }
+
+ /**
+ * Converts a list of 1-based {@link Index} to a list of zero-based ints,
+ * preserving the original input order.
+ */
+ private static List toZeroBased(List indices) {
+ return indices.stream()
+ .map(Index::getZeroBased)
+ .toList();
+ }
+
+ /**
+ * Validates that each zero-based payment index is within {@code [0, size)}.
+ *
+ * @throws CommandException if any index is invalid.
+ */
+ private static void validatePaymentIndices(List zeroBased, int size, Person target)
+ throws CommandException {
+ for (int z : zeroBased) {
+ if (z < 0 || z >= size) {
+ Logger.getLogger(DeletePaymentCommand.class.getName())
+ .warning(() -> String.format("Invalid payment index %d for %s (has %d payments)",
+ z + 1, target.getName(), size));
+ throw new CommandException(String.format(MESSAGE_INVALID_PAYMENT_INDEX, z + 1, target.getName()));
+ }
+ }
+ }
+
+ /**
+ * Produces a new {@link Person} with the given payments removed (by identity).
+ * Deletion order is irrelevant; duplicates are already removed upstream.
+ */
+ private static Person removePayments(Person original, List toDelete) {
+ Person updated = original;
+ for (Payment pay : toDelete) {
+ updated = updated.withRemovedPayment(pay);
+ }
+ return updated;
+ }
+}
diff --git a/src/main/java/seedu/address/logic/commands/EditCommand.java b/src/main/java/seedu/address/logic/commands/EditCommand.java
index 4b581c7331e..e2e0d934f7b 100644
--- a/src/main/java/seedu/address/logic/commands/EditCommand.java
+++ b/src/main/java/seedu/address/logic/commands/EditCommand.java
@@ -1,12 +1,11 @@
package seedu.address.logic.commands;
import static java.util.Objects.requireNonNull;
-import static seedu.address.logic.parser.CliSyntax.PREFIX_ADDRESS;
import static seedu.address.logic.parser.CliSyntax.PREFIX_EMAIL;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_MATRICULATIONNUMBER;
import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME;
import static seedu.address.logic.parser.CliSyntax.PREFIX_PHONE;
import static seedu.address.logic.parser.CliSyntax.PREFIX_TAG;
-import static seedu.address.model.Model.PREDICATE_SHOW_ALL_PERSONS;
import java.util.Collections;
import java.util.HashSet;
@@ -14,15 +13,17 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.logging.Logger;
+import seedu.address.commons.core.LogsCenter;
import seedu.address.commons.core.index.Index;
import seedu.address.commons.util.CollectionUtil;
import seedu.address.commons.util.ToStringBuilder;
import seedu.address.logic.Messages;
import seedu.address.logic.commands.exceptions.CommandException;
import seedu.address.model.Model;
-import seedu.address.model.person.Address;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Person;
import seedu.address.model.person.Phone;
@@ -36,27 +37,30 @@ public class EditCommand extends Command {
public static final String COMMAND_WORD = "edit";
public static final String MESSAGE_USAGE = COMMAND_WORD + ": Edits the details of the person identified "
- + "by the index number used in the displayed person list. "
- + "Existing values will be overwritten by the input values.\n"
- + "Parameters: INDEX (must be a positive integer) "
- + "[" + PREFIX_NAME + "NAME] "
- + "[" + PREFIX_PHONE + "PHONE] "
- + "[" + PREFIX_EMAIL + "EMAIL] "
- + "[" + PREFIX_ADDRESS + "ADDRESS] "
- + "[" + PREFIX_TAG + "TAG]...\n"
- + "Example: " + COMMAND_WORD + " 1 "
- + PREFIX_PHONE + "91234567 "
- + PREFIX_EMAIL + "johndoe@example.com";
+ + "by the index number used in the displayed person list. "
+ + "Existing values will be overwritten by the input values.\n"
+ + "Parameters: INDEX (must be a positive integer) "
+ + "[" + PREFIX_NAME + "NAME] "
+ + "[" + PREFIX_PHONE + "PHONE] "
+ + "[" + PREFIX_EMAIL + "EMAIL] "
+ + "[" + PREFIX_MATRICULATIONNUMBER + "MATRICULATION NUMBER] "
+ + "[" + PREFIX_TAG + "TAG]... (empty argument for t/ prefix will clear all tags)\n"
+ + "Example: " + COMMAND_WORD + " 1 "
+ + PREFIX_PHONE + "91234567 "
+ + PREFIX_EMAIL + "johndoe@example.com";
public static final String MESSAGE_EDIT_PERSON_SUCCESS = "Edited Person: %1$s";
public static final String MESSAGE_NOT_EDITED = "At least one field to edit must be provided.";
- public static final String MESSAGE_DUPLICATE_PERSON = "This person already exists in the address book.";
+ public static final String MESSAGE_DUPLICATE_PERSON =
+ "Edit failed - Another person with the same matriculation number already exists in the member list.";
+
+ private static final Logger logger = LogsCenter.getLogger(EditCommand.class);
private final Index index;
private final EditPersonDescriptor editPersonDescriptor;
/**
- * @param index of the person in the filtered person list to edit
+ * @param index of the person in the filtered person list to edit
* @param editPersonDescriptor details to edit the person with
*/
public EditCommand(Index index, EditPersonDescriptor editPersonDescriptor) {
@@ -72,19 +76,33 @@ public CommandResult execute(Model model) throws CommandException {
requireNonNull(model);
List lastShownList = model.getFilteredPersonList();
+ // Log that an edit attempt is starting
+ logger.info(String.format("Executing edit command for index: %d", index.getOneBased()));
+
if (index.getZeroBased() >= lastShownList.size()) {
+ logger.warning(String.format("Edit failed - invalid index: %d (list size: %d)",
+ index.getOneBased(), lastShownList.size()));
throw new CommandException(Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
}
Person personToEdit = lastShownList.get(index.getZeroBased());
+ logger.fine(String.format("Editing person: %s (%s)",
+ personToEdit.getName(), personToEdit.getMatriculationNumber()));
+
Person editedPerson = createEditedPerson(personToEdit, editPersonDescriptor);
if (!personToEdit.isSamePerson(editedPerson) && model.hasPerson(editedPerson)) {
+ logger.warning(String.format(
+ "Edit failed - duplicate detected for matriculation number: %s",
+ editedPerson.getMatriculationNumber()));
throw new CommandException(MESSAGE_DUPLICATE_PERSON);
}
model.setPerson(personToEdit, editedPerson);
- model.updateFilteredPersonList(PREDICATE_SHOW_ALL_PERSONS);
+
+ logger.info(String.format("Successfully edited person: [%s → %s]",
+ personToEdit.getMatriculationNumber(), editedPerson.getMatriculationNumber()));
+
return new CommandResult(String.format(MESSAGE_EDIT_PERSON_SUCCESS, Messages.format(editedPerson)));
}
@@ -98,10 +116,20 @@ private static Person createEditedPerson(Person personToEdit, EditPersonDescript
Name updatedName = editPersonDescriptor.getName().orElse(personToEdit.getName());
Phone updatedPhone = editPersonDescriptor.getPhone().orElse(personToEdit.getPhone());
Email updatedEmail = editPersonDescriptor.getEmail().orElse(personToEdit.getEmail());
- Address updatedAddress = editPersonDescriptor.getAddress().orElse(personToEdit.getAddress());
+ MatriculationNumber updatedMatriculationNumber =
+ editPersonDescriptor.getMatriculationNumber().orElse(personToEdit.getMatriculationNumber());
Set updatedTags = editPersonDescriptor.getTags().orElse(personToEdit.getTags());
- return new Person(updatedName, updatedPhone, updatedEmail, updatedAddress, updatedTags);
+ // returns new person with prior archive status and payment record
+ return new Person(
+ updatedName,
+ updatedPhone,
+ updatedEmail,
+ updatedMatriculationNumber,
+ updatedTags,
+ personToEdit.isArchived(),
+ personToEdit.getPayments()
+ );
}
@Override
@@ -117,15 +145,20 @@ public boolean equals(Object other) {
EditCommand otherEditCommand = (EditCommand) other;
return index.equals(otherEditCommand.index)
- && editPersonDescriptor.equals(otherEditCommand.editPersonDescriptor);
+ && editPersonDescriptor.equals(otherEditCommand.editPersonDescriptor);
}
@Override
public String toString() {
return new ToStringBuilder(this)
- .add("index", index)
- .add("editPersonDescriptor", editPersonDescriptor)
- .toString();
+ .add("index", index)
+ .add("editPersonDescriptor", editPersonDescriptor)
+ .toString();
+ }
+
+ @Override
+ public boolean isMutating() {
+ return true;
}
/**
@@ -136,10 +169,11 @@ public static class EditPersonDescriptor {
private Name name;
private Phone phone;
private Email email;
- private Address address;
+ private MatriculationNumber matriculationNumber;
private Set tags;
- public EditPersonDescriptor() {}
+ public EditPersonDescriptor() {
+ }
/**
* Copy constructor.
@@ -149,7 +183,7 @@ public EditPersonDescriptor(EditPersonDescriptor toCopy) {
setName(toCopy.name);
setPhone(toCopy.phone);
setEmail(toCopy.email);
- setAddress(toCopy.address);
+ setMatriculationNumber(toCopy.matriculationNumber);
setTags(toCopy.tags);
}
@@ -157,7 +191,7 @@ public EditPersonDescriptor(EditPersonDescriptor toCopy) {
* Returns true if at least one field is edited.
*/
public boolean isAnyFieldEdited() {
- return CollectionUtil.isAnyNonNull(name, phone, email, address, tags);
+ return CollectionUtil.isAnyNonNull(name, phone, email, matriculationNumber, tags);
}
public void setName(Name name) {
@@ -184,12 +218,12 @@ public Optional getEmail() {
return Optional.ofNullable(email);
}
- public void setAddress(Address address) {
- this.address = address;
+ public void setMatriculationNumber(MatriculationNumber matriculationNumber) {
+ this.matriculationNumber = matriculationNumber;
}
- public Optional getAddress() {
- return Optional.ofNullable(address);
+ public Optional getMatriculationNumber() {
+ return Optional.ofNullable(matriculationNumber);
}
/**
@@ -222,21 +256,21 @@ public boolean equals(Object other) {
EditPersonDescriptor otherEditPersonDescriptor = (EditPersonDescriptor) other;
return Objects.equals(name, otherEditPersonDescriptor.name)
- && Objects.equals(phone, otherEditPersonDescriptor.phone)
- && Objects.equals(email, otherEditPersonDescriptor.email)
- && Objects.equals(address, otherEditPersonDescriptor.address)
- && Objects.equals(tags, otherEditPersonDescriptor.tags);
+ && Objects.equals(phone, otherEditPersonDescriptor.phone)
+ && Objects.equals(email, otherEditPersonDescriptor.email)
+ && Objects.equals(matriculationNumber, otherEditPersonDescriptor.matriculationNumber)
+ && Objects.equals(tags, otherEditPersonDescriptor.tags);
}
@Override
public String toString() {
return new ToStringBuilder(this)
- .add("name", name)
- .add("phone", phone)
- .add("email", email)
- .add("address", address)
- .add("tags", tags)
- .toString();
+ .add("name", name)
+ .add("phone", phone)
+ .add("email", email)
+ .add("matriculation number", matriculationNumber)
+ .add("tags", tags)
+ .toString();
}
}
}
diff --git a/src/main/java/seedu/address/logic/commands/EditPaymentCommand.java b/src/main/java/seedu/address/logic/commands/EditPaymentCommand.java
new file mode 100644
index 00000000000..e16e322b5b5
--- /dev/null
+++ b/src/main/java/seedu/address/logic/commands/EditPaymentCommand.java
@@ -0,0 +1,191 @@
+package seedu.address.logic.commands;
+
+import static java.util.Objects.requireNonNull;
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX;
+import static seedu.address.commons.util.CollectionUtil.isAnyNonNull;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.logging.Logger;
+
+import seedu.address.commons.core.LogsCenter;
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.Person;
+
+/**
+ * Edits an existing payment of a person in the address book.
+ * The payment index refers to the index as displayed by 'viewpayment'
+ * (i.e., using the Payment display order).
+ */
+public class EditPaymentCommand extends Command {
+
+ public static final String COMMAND_WORD = "editpayment";
+
+ public static final String MESSAGE_USAGE = COMMAND_WORD + ": Edits one payment of a person.\n"
+ + "Parameters: PERSON_INDEX p/PAYMENT_INDEX [a/AMOUNT] [d/DATE] [r/REMARKS]\n"
+ + "Examples:\n"
+ + " " + COMMAND_WORD + " 1 p/1 a/10.50\n"
+ + " " + COMMAND_WORD + " 2 p/3 d/2025-10-15 r/late fee waived";
+
+ public static final String MESSAGE_NO_FIELDS = "At least one of a/, d/, r/ must be provided.";
+ public static final String MESSAGE_INVALID_PAYMENT_INDEX = "Payment index is invalid for this person.";
+ public static final String MESSAGE_SUCCESS = "Edited payment p/%d for %s. Original payment: %s, new payment: %s";
+
+ private static final Logger logger = LogsCenter.getLogger(EditPaymentCommand.class);
+
+ private final Index personIndex; // 1-based
+ private final int paymentOneBased; // 1-based
+ private final EditPaymentDescriptor descriptor;
+
+ /**
+ * Creates an EditPaymentCommand to edit the specified payment.
+ */
+ public EditPaymentCommand(Index personIndex, int paymentOneBased, EditPaymentDescriptor descriptor) {
+ requireNonNull(personIndex);
+ requireNonNull(descriptor);
+ this.personIndex = personIndex;
+ this.paymentOneBased = paymentOneBased;
+ this.descriptor = descriptor;
+ }
+
+ @Override
+ public CommandResult execute(Model model) throws CommandException {
+ requireNonNull(model);
+ logger.fine(() -> String.format("EditPayment.execute person=%d payment=%d",
+ personIndex.getOneBased(), paymentOneBased));
+
+ if (!descriptor.isAnyFieldEdited()) {
+ throw new CommandException(MESSAGE_NO_FIELDS);
+ }
+
+ List lastShownList = model.getFilteredPersonList();
+ if (personIndex.getZeroBased() >= lastShownList.size()) {
+ throw new CommandException(MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+
+ Person target = lastShownList.get(personIndex.getZeroBased());
+
+ // Resolve by the SAME order used in 'viewpayment'
+ List displayList = Payment.inDisplayOrder(target.getPayments());
+ int displayZero = paymentOneBased - 1;
+ if (displayZero < 0 || displayZero >= displayList.size()) {
+ throw new CommandException(MESSAGE_INVALID_PAYMENT_INDEX);
+ }
+
+ Payment original = displayList.get(displayZero);
+ Payment edited = createEditedPayment(original, descriptor);
+
+ if (original.equals(edited)) {
+ throw new CommandException("The original payment is the same as the edited payment!");
+ }
+
+ // Find the original in the raw list and replace at that index
+ int rawIndex = target.getPayments().indexOf(original);
+ if (rawIndex < 0) {
+ // Defensive guard in case of concurrent changes
+ throw new CommandException("Selected payment could not be located. Please try again.");
+ }
+
+ Person updated = target.withEditedPayment(rawIndex, edited);
+ model.setPerson(target, updated);
+
+ return new CommandResult(String.format(MESSAGE_SUCCESS, paymentOneBased, updated.getName(),
+ original.toString(), edited.toString()));
+ }
+
+ /**
+ * Creates a new Payment using updated fields while preserving recordedAt.
+ */
+ private static Payment createEditedPayment(Payment original, EditPaymentDescriptor d) {
+ Amount amount = d.getAmount().orElse(original.getAmount());
+ LocalDate date = d.getDate().orElse(original.getDate());
+ String remarks = d.getRemarks().orElse(original.getRemarks()); // may be null per model
+
+ // Preserve original recordedAt for auditability
+ return new Payment(amount, date, remarks, original.getRecordedAt());
+ }
+
+ // -------- descriptor --------
+
+ /**
+ * Stores the details to edit a payment.
+ */
+ public static class EditPaymentDescriptor {
+
+ private Amount amount;
+ private LocalDate date;
+ private String remarks;
+
+ public boolean isAnyFieldEdited() {
+ return isAnyNonNull(amount, date, remarks);
+ }
+
+ public void setAmount(Amount a) {
+ this.amount = a;
+ }
+
+ public void setDate(LocalDate d) {
+ this.date = d;
+ }
+
+ public void setRemarks(String r) {
+ this.remarks = r;
+ }
+
+ public Optional getAmount() {
+ return Optional.ofNullable(amount);
+ }
+
+ public Optional getDate() {
+ return Optional.ofNullable(date);
+ }
+
+ public Optional getRemarks() {
+ return Optional.ofNullable(remarks);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof EditPaymentDescriptor)) {
+ return false;
+ }
+ EditPaymentDescriptor that = (EditPaymentDescriptor) o;
+ return Objects.equals(amount, that.amount)
+ && Objects.equals(date, that.date)
+ && Objects.equals(remarks, that.remarks);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(amount, date, remarks);
+ }
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) {
+ return true;
+ }
+ if (!(other instanceof EditPaymentCommand)) {
+ return false;
+ }
+ EditPaymentCommand o = (EditPaymentCommand) other;
+ return personIndex.equals(o.personIndex)
+ && paymentOneBased == o.paymentOneBased
+ && Objects.equals(descriptor, o.descriptor);
+ }
+
+ @Override
+ public boolean isMutating() {
+ return true;
+ }
+}
diff --git a/src/main/java/seedu/address/logic/commands/ExitCommand.java b/src/main/java/seedu/address/logic/commands/ExitCommand.java
index 3dd85a8ba90..df4af545820 100644
--- a/src/main/java/seedu/address/logic/commands/ExitCommand.java
+++ b/src/main/java/seedu/address/logic/commands/ExitCommand.java
@@ -8,7 +8,8 @@
public class ExitCommand extends Command {
public static final String COMMAND_WORD = "exit";
-
+ public static final String MESSAGE_USAGE = COMMAND_WORD + ": Exits the program.\n"
+ + "Example: " + COMMAND_WORD;
public static final String MESSAGE_EXIT_ACKNOWLEDGEMENT = "Exiting Address Book as requested ...";
@Override
diff --git a/src/main/java/seedu/address/logic/commands/FindCommand.java b/src/main/java/seedu/address/logic/commands/FindCommand.java
index 72b9eddd3a7..3366591f26e 100644
--- a/src/main/java/seedu/address/logic/commands/FindCommand.java
+++ b/src/main/java/seedu/address/logic/commands/FindCommand.java
@@ -2,51 +2,57 @@
import static java.util.Objects.requireNonNull;
+import java.util.function.Predicate;
+import java.util.logging.Logger;
+
+import seedu.address.commons.core.LogsCenter;
import seedu.address.commons.util.ToStringBuilder;
import seedu.address.logic.Messages;
import seedu.address.model.Model;
-import seedu.address.model.person.NameContainsKeywordsPredicate;
+import seedu.address.model.person.Person;
/**
- * Finds and lists all persons in address book whose name contains any of the argument keywords.
- * Keyword matching is case insensitive.
+ * Finds and lists all persons whose names or tags contain any of the argument keywords.
+ * Keyword matching is case-insensitive.
*/
public class FindCommand extends Command {
public static final String COMMAND_WORD = "find";
- public static final String MESSAGE_USAGE = COMMAND_WORD + ": Finds all persons whose names contain any of "
+ public static final String MESSAGE_USAGE = COMMAND_WORD + ": Finds all persons whose names or tags contain any of "
+ "the specified keywords (case-insensitive) and displays them as a list with index numbers.\n"
+ "Parameters: KEYWORD [MORE_KEYWORDS]...\n"
- + "Example: " + COMMAND_WORD + " alice bob charlie";
+ + "Example: " + COMMAND_WORD + " alice friend colleague";
+
+ private static final Logger logger = LogsCenter.getLogger(FindCommand.class);
- private final NameContainsKeywordsPredicate predicate;
+ private final Predicate predicate;
- public FindCommand(NameContainsKeywordsPredicate predicate) {
+ public FindCommand(Predicate predicate) {
this.predicate = predicate;
}
@Override
public CommandResult execute(Model model) {
requireNonNull(model);
- model.updateFilteredPersonList(predicate);
- return new CommandResult(
- String.format(Messages.MESSAGE_PERSONS_LISTED_OVERVIEW, model.getFilteredPersonList().size()));
+
+ logger.fine("Executing FindCommand with predicate: " + predicate);
+
+ model.updateFilteredPersonList(
+ Model.PREDICATE_SHOW_ACTIVE_PERSONS.and(predicate) // <- combine here
+ );
+
+ logger.info(String.format("FindCommand executed: %d person(s) found.", model.getFilteredPersonList().size()));
+
+ return new CommandResult(String.format(
+ Messages.MESSAGE_PERSONS_LISTED_OVERVIEW, model.getFilteredPersonList().size()));
}
@Override
public boolean equals(Object other) {
- if (other == this) {
- return true;
- }
-
- // instanceof handles nulls
- if (!(other instanceof FindCommand)) {
- return false;
- }
-
- FindCommand otherFindCommand = (FindCommand) other;
- return predicate.equals(otherFindCommand.predicate);
+ return other == this
+ || (other instanceof FindCommand
+ && predicate.equals(((FindCommand) other).predicate));
}
@Override
diff --git a/src/main/java/seedu/address/logic/commands/FindPaymentCommand.java b/src/main/java/seedu/address/logic/commands/FindPaymentCommand.java
new file mode 100644
index 00000000000..a01d83f54b2
--- /dev/null
+++ b/src/main/java/seedu/address/logic/commands/FindPaymentCommand.java
@@ -0,0 +1,252 @@
+package seedu.address.logic.commands;
+
+import static java.util.Objects.requireNonNull;
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX;
+
+import java.time.LocalDate;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+import seedu.address.commons.core.LogsCenter;
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.Person;
+
+/**
+ * Finds and displays payments of a specific person identified by index,
+ * filtered by one of: amount, remark, or date.
+ *
+ *
Command format:
+ *
+ * findpayment INDEX a/AMOUNT
+ * findpayment INDEX r/REMARK
+ * findpayment INDEX d/DATE
+ *
+ *
+ *
Exactly one filter must be provided.
+ */
+public class FindPaymentCommand extends Command {
+
+ public static final String COMMAND_WORD = "findpayment";
+
+ public static final String MESSAGE_USAGE = COMMAND_WORD
+ + ": Finds payments of the person identified by the displayed index, "
+ + "filtered by amount, remark, or date.\n"
+ + "Parameters: INDEX a/AMOUNT | r/REMARK | d/DATE (Exactly one filter should be provided!)\n"
+ + "Index should be a positive integer. Amount should be a positive number with at most 2 decimal places. "
+ + "Date should be in YYYY-MM-DD format.\n"
+ + "Example:\n"
+ + COMMAND_WORD + " 1 r/CCA\n"
+ + COMMAND_WORD + " 2 d/2023-12-30";
+
+ public static final String MESSAGE_SUCCESS =
+ "Found %d payment(s) for %s:\n%s\n\n"
+ + "Note: Payments shown here are not indexed. "
+ + "Do not use these results for 'editpayment' or 'deletepayment'.\n"
+ + "Use the 'viewpayment' command to obtain the correct payment index.";
+
+ public static final String MESSAGE_NOT_FOUND =
+ "No payments found for %s matching %s.";
+
+ private static final Logger logger = LogsCenter.getLogger(FindPaymentCommand.class);
+
+ private final Index targetIndex;
+ private final Amount amount;
+ private final String remark;
+ private final LocalDate date;
+
+ /**
+ * Creates a {@code FindPaymentCommand} to search for payments of a person.
+ *
+ * @param targetIndex index of the person in the displayed person list.
+ * @param amount amount filter (nullable).
+ * @param remark remark filter (nullable).
+ * @param date date filter (nullable).
+ */
+ public FindPaymentCommand(Index targetIndex, Amount amount, String remark, LocalDate date) {
+ requireNonNull(targetIndex);
+ this.targetIndex = targetIndex;
+ this.amount = amount;
+ this.remark = remark;
+ this.date = date;
+ }
+
+ /**
+ * Executes the findpayment command and returns the result message.
+ *
+ * @param model {@code Model} which contains the list of persons and their payments.
+ * @return {@code CommandResult} containing the formatted search result.
+ * @throws CommandException if the index is invalid or any internal validation fails.
+ */
+ @Override
+ public CommandResult execute(Model model) throws CommandException {
+ requireNonNull(model);
+
+ Person target = getTargetPerson(model);
+ List matchedPayments = findMatchingPayments(target.getPayments());
+
+ if (matchedPayments.isEmpty()) {
+ return new CommandResult(String.format(MESSAGE_NOT_FOUND, target.getName(), describeFilter()));
+ }
+
+ logger.info(String.format("Found %d payments for %s", matchedPayments.size(), target.getName()));
+ String formatted = formatPayments(matchedPayments);
+
+ return new CommandResult(String.format(
+ MESSAGE_SUCCESS, matchedPayments.size(), target.getName(), formatted));
+ }
+
+ // ----------------------------------------------------
+ // Helper Methods
+ // ----------------------------------------------------
+
+ /**
+ * Retrieves the {@code Person} at the given index in the filtered person list.
+ *
+ * @param model active data model containing the person list.
+ * @return the {@code Person} corresponding to {@code targetIndex}.
+ * @throws CommandException if {@code targetIndex} is out of bounds.
+ */
+ private Person getTargetPerson(Model model) throws CommandException {
+ List persons = model.getFilteredPersonList();
+ if (targetIndex.getZeroBased() >= persons.size()) {
+ throw new CommandException(MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+ return persons.get(targetIndex.getZeroBased());
+ }
+
+ /**
+ * Returns a list of payments that match the selected filter (amount, remark, or date).
+ *
+ * @param payments list of all payments belonging to a person.
+ * @return filtered list of payments.
+ */
+ private List findMatchingPayments(List payments) {
+ if (amount != null) {
+ return filterByAmount(payments);
+ }
+ if (remark != null) {
+ return filterByRemark(payments);
+ }
+ if (date != null) {
+ return filterByDate(payments);
+ }
+ // this should never happen as parser enforces exactly one filter
+ assert false : "Parser should ensure exactly one non-null filter.";
+ return List.of();
+ }
+
+ /**
+ * Filters the given list of payments by amount.
+ *
+ * @param payments list of all payments.
+ * @return payments matching the specified amount.
+ */
+ private List filterByAmount(List payments) {
+ return payments.stream()
+ .filter(p -> p.getAmount().equals(amount))
+ .sorted(getPaymentComparator())
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Filters the given list of payments by remark (case-insensitive substring match).
+ *
+ * @param payments list of all payments.
+ * @return payments whose remarks contain the given keyword.
+ */
+ private List filterByRemark(List payments) {
+ String keyword = remark.toLowerCase();
+ return payments.stream()
+ .filter(p -> p.getRemarks() != null && p.getRemarks().toLowerCase().contains(keyword))
+ .sorted(getPaymentComparator())
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Filters the given list of payments by exact date.
+ *
+ * @param payments list of all payments.
+ * @return payments matching the specified date.
+ */
+ private List filterByDate(List payments) {
+ return payments.stream()
+ .filter(p -> p.getDate().equals(date))
+ .sorted(getPaymentComparator())
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Returns a comparator that sorts payments by:
+ *
+ *
+ *
+ *
+ * @return comparator used for consistent ordering of payment results.
+ */
+ private Comparator getPaymentComparator() {
+ return Comparator
+ .comparing(Payment::getDate, Comparator.reverseOrder())
+ .thenComparing(Payment::getAmount, Comparator.reverseOrder())
+ .thenComparing(p -> p.getRemarks() == null ? "" : p.getRemarks().toLowerCase());
+ }
+
+ /**
+ * Formats a list of payments into a multi-line display string.
+ *
+ * @param payments list of payments to display.
+ * @return formatted string for user output.
+ */
+ private String formatPayments(List payments) {
+ return payments.stream()
+ .map(p -> "- " + p)
+ .collect(Collectors.joining("\n"));
+ }
+
+ /**
+ * Returns a human-readable description of the active filter.
+ *
+ * @return description string for use in result messages.
+ */
+ private String describeFilter() {
+ if (amount != null) {
+ return "amount " + amount;
+ }
+ if (date != null) {
+ return "date " + date;
+ }
+ return "remark \"" + remark + "\"";
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) {
+ return true;
+ }
+ if (!(other instanceof FindPaymentCommand)) {
+ return false;
+ }
+ FindPaymentCommand o = (FindPaymentCommand) other;
+ return targetIndex.equals(o.targetIndex)
+ && Objects.equals(amount, o.amount)
+ && Objects.equals(remark, o.remark)
+ && Objects.equals(date, o.date);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
This command is non-mutating as it only performs a read/filter operation.
+ */
+ @Override
+ public boolean isMutating() {
+ return false;
+ }
+}
diff --git a/src/main/java/seedu/address/logic/commands/ListArchivedCommand.java b/src/main/java/seedu/address/logic/commands/ListArchivedCommand.java
new file mode 100644
index 00000000000..4c2b9d19c8b
--- /dev/null
+++ b/src/main/java/seedu/address/logic/commands/ListArchivedCommand.java
@@ -0,0 +1,60 @@
+package seedu.address.logic.commands;
+
+import static java.util.Objects.requireNonNull;
+import static seedu.address.model.Model.PREDICATE_SHOW_ARCHIVED_PERSONS;
+
+import seedu.address.model.Model;
+
+/**
+ * Lists all archived persons in the address book.
+ *
+ * An archived person refers to one that has been soft-deleted,
+ * meaning their data is still retained in storage but hidden from the active list.
+ * This command allows users to view those archived entries.
+ */
+
+public class ListArchivedCommand extends Command {
+
+ public static final String COMMAND_WORD = "listarchived";
+
+ public static final String MESSAGE_USAGE = COMMAND_WORD + ": Lists all archived persons.\n"
+ + "Example: " + COMMAND_WORD;
+
+ public static final String MESSAGE_EMPTY = "No archived persons found. Use 'list' command to show active list.";
+ public static final String MESSAGE_SUCCESS = "Listed all archived persons";
+
+ /**
+ * Executes the command to display all archived persons.
+ *
+ * The model’s filtered person list is updated to show only those
+ * persons that satisfy {@code PREDICATE_SHOW_ARCHIVED_PERSONS}.
+ *
+ * @param model The {@code Model} which the command operates on.
+ * @return A {@code CommandResult} containing the outcome message.
+ * @throws NullPointerException If {@code model} is {@code null}.
+ */
+ @Override
+ public CommandResult execute(Model model) {
+ requireNonNull(model);
+ model.updateFilteredPersonList(PREDICATE_SHOW_ARCHIVED_PERSONS);
+ if (model.getFilteredPersonList().isEmpty()) {
+ return new CommandResult(MESSAGE_EMPTY);
+ }
+ return new CommandResult(MESSAGE_SUCCESS);
+ }
+
+ /**
+ * Compares this command to another object for equality.
+ *
+ * Since {@code ListArchivedCommand} is stateless, all instances are considered equal.
+ *
+ * @param other The other object to compare with.
+ * @return {@code true} if the other object is also a {@code ListArchivedCommand}, {@code false} otherwise.
+ */
+ @Override
+ public boolean equals(Object other) {
+ // Stateless command
+ return other == this || (other instanceof ListArchivedCommand);
+ }
+}
+
diff --git a/src/main/java/seedu/address/logic/commands/ListCommand.java b/src/main/java/seedu/address/logic/commands/ListCommand.java
index 84be6ad2596..d44e6281492 100644
--- a/src/main/java/seedu/address/logic/commands/ListCommand.java
+++ b/src/main/java/seedu/address/logic/commands/ListCommand.java
@@ -1,7 +1,7 @@
package seedu.address.logic.commands;
import static java.util.Objects.requireNonNull;
-import static seedu.address.model.Model.PREDICATE_SHOW_ALL_PERSONS;
+import static seedu.address.model.Model.PREDICATE_SHOW_ACTIVE_PERSONS;
import seedu.address.model.Model;
@@ -11,14 +11,18 @@
public class ListCommand extends Command {
public static final String COMMAND_WORD = "list";
+ public static final String MESSAGE_USAGE = COMMAND_WORD
+ + ": Lists all active persons in the address book.\n"
+ + "Format: " + COMMAND_WORD + "\n"
+ + "Example: " + COMMAND_WORD;
- public static final String MESSAGE_SUCCESS = "Listed all persons";
+ public static final String MESSAGE_SUCCESS = "Listed all active persons";
@Override
public CommandResult execute(Model model) {
requireNonNull(model);
- model.updateFilteredPersonList(PREDICATE_SHOW_ALL_PERSONS);
+ model.updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
return new CommandResult(MESSAGE_SUCCESS);
}
}
diff --git a/src/main/java/seedu/address/logic/commands/RedoCommand.java b/src/main/java/seedu/address/logic/commands/RedoCommand.java
new file mode 100644
index 00000000000..e374e7c3126
--- /dev/null
+++ b/src/main/java/seedu/address/logic/commands/RedoCommand.java
@@ -0,0 +1,59 @@
+package seedu.address.logic.commands;
+
+import seedu.address.model.Model;
+
+/**
+ * Reapplies the most recently undone change in the address book (i.e., performs a redo operation).
+ *
+ * The {@code RedoCommand} relies on the {@link Model} to maintain a redo stack containing
+ * previously undone states. If there are no actions available to redo, a message is shown
+ * to inform the user.
+ */
+public class RedoCommand extends Command {
+ public static final String COMMAND_WORD = "redo";
+ public static final String MESSAGE_SUCCESS = "Redid the last undone change.";
+ public static final String MESSAGE_NOTHING = "Nothing to redo.";
+ public static final String MESSAGE_USAGE = COMMAND_WORD
+ + ": Redoes the last undone change in the address book.\n"
+ + "Format: " + COMMAND_WORD + "\n"
+ + "Example: " + COMMAND_WORD;
+
+ /**
+ * Executes the redo operation.
+ *
+ *
If the {@link Model} has no redoable states (i.e., {@code model.canRedo()} is false),
+ * this method returns a {@link CommandResult} with {@link #MESSAGE_NOTHING}.
+ * Otherwise, it re-applies the most recently undone change using {@link Model#redo()}
+ * and returns a {@link CommandResult} with {@link #MESSAGE_SUCCESS}.
+ *
+ * @param model The model in which to perform the redo operation.
+ * @return The result of executing the redo command, containing feedback for the user.
+ */
+ @Override
+ public CommandResult execute(Model model) {
+ if (!model.canRedo()) {
+ return new CommandResult(MESSAGE_NOTHING);
+ }
+ model.redo();
+ return new CommandResult(MESSAGE_SUCCESS);
+ }
+
+ /**
+ * Returns true if this command is equal to the given object.
+ *
+ * Two {@code RedoCommand} instances are considered equal because this command is stateless
+ * and identical in behavior across all instances.
+ *
+ * @param other The object to compare with.
+ * @return {@code true} if the other object is also a {@code RedoCommand}, {@code false} otherwise.
+ */
+ @Override
+ public boolean equals(Object other) {
+ return other == this || other instanceof RedoCommand;
+ }
+
+ @Override
+ public int hashCode() {
+ return getClass().hashCode();
+ }
+}
diff --git a/src/main/java/seedu/address/logic/commands/UnarchiveCommand.java b/src/main/java/seedu/address/logic/commands/UnarchiveCommand.java
new file mode 100644
index 00000000000..bc85559e457
--- /dev/null
+++ b/src/main/java/seedu/address/logic/commands/UnarchiveCommand.java
@@ -0,0 +1,186 @@
+package seedu.address.logic.commands;
+
+import static java.util.Objects.requireNonNull;
+import static seedu.address.model.Model.PREDICATE_SHOW_ACTIVE_PERSONS;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import seedu.address.commons.core.Messages;
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.person.Person;
+
+/**
+ * Unarchives one or more persons identified by their indexes in the displayed list.
+ *
+ *
Command format:
+ *
+ * unarchive INDEX[,INDEX]...
+ *
+ *
+ *
Indexes refer to the currently displayed person list.
+ * Duplicate indexes are permitted in input but will be de-duplicated internally
+ * while preserving the first-seen order.
+ */
+public class UnarchiveCommand extends Command {
+
+ public static final String COMMAND_WORD = "unarchive";
+
+ public static final String MESSAGE_USAGE = COMMAND_WORD
+ + ": Unarchives one or more persons identified by their indexes in the displayed person list.\n"
+ + "Parameters: INDEX[,INDEX]... (each must be a positive integer)\n"
+ + "Example (single): " + COMMAND_WORD + " 1\n"
+ + "Example (multiple): " + COMMAND_WORD + " 1,2,5";
+
+ public static final String MESSAGE_NOT_ARCHIVED = "One or more selected persons are not archived: %s";
+
+ public static final String MESSAGE_SUCCESS = "Unarchived: %s\nShowing active list.";
+
+ private final List targetIndexes;
+
+ /**
+ * Constructs an {@code UnarchiveCommand} to unarchive one or more persons in the displayed list.
+ * Duplicate indexes are removed while preserving the original order.
+ *
+ * @param targetIndexes indexes (1-based in UI) of persons to unarchive; must not be {@code null}.
+ */
+ public UnarchiveCommand(List targetIndexes) {
+ requireNonNull(targetIndexes);
+ this.targetIndexes = removeDuplicates(targetIndexes);
+ }
+
+ /**
+ * Unarchives the selected persons and returns a {@link CommandResult} with a summary.
+ *
+ *
Behavior:
+ *
+ *
Validates indexes against the currently displayed person list.
+ *
Fails if any targeted person is not archived.
+ *
Unarchives all valid targets atomically (same failure path for any invalid state).
+ *
Refreshes the filtered list to show active persons after completion.
+ *
+ *
+ * @param model model providing access to persons and mutation APIs; must not be {@code null}.
+ * @return result containing the names of unarchived persons.
+ * @throws CommandException if an index is out of bounds or a person is not archived.
+ */
+ @Override
+ public CommandResult execute(Model model) throws CommandException {
+ requireNonNull(model);
+ List displayedPersons = model.getFilteredPersonList();
+
+ List personsToUnarchive = validateAndCollect(displayedPersons);
+ List unarchivedNames = applyUnarchive(model, personsToUnarchive);
+
+ model.updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
+ return new CommandResult(String.format(MESSAGE_SUCCESS, String.join(", ", unarchivedNames)));
+ }
+
+ // ----------------------------------------------------
+ // Helper methods
+ // ----------------------------------------------------
+
+ /**
+ * Removes duplicate indexes while preserving their original order.
+ *
+ * @param indexes list that may contain duplicates.
+ * @return unmodifiable list with duplicates removed.
+ */
+ private List removeDuplicates(List indexes) {
+ Set seen = new LinkedHashSet<>();
+ return indexes.stream()
+ .filter(i -> seen.add(i.getZeroBased()))
+ .collect(Collectors.toUnmodifiableList());
+ }
+
+ /**
+ * Validates all target indexes and collects the corresponding persons to unarchive.
+ *
+ *
Validation steps:
+ *
+ *
Ensure each index is within bounds of the displayed person list.
+ *
Detect if any targeted person is not archived; if found, fail the command.
+ *
+ *
+ * @param displayedPersons current list of persons shown to the user.
+ * @return list of persons to unarchive.
+ * @throws CommandException if any index is out of bounds or any person is not archived.
+ */
+ private List validateAndCollect(List displayedPersons) throws CommandException {
+ List personsToUnarchive = new ArrayList<>(targetIndexes.size());
+ List notArchivedNames = new ArrayList<>();
+
+ for (Index targetIndex : targetIndexes) {
+ Person person = getValidPerson(displayedPersons, targetIndex);
+ if (!person.isArchived()) {
+ notArchivedNames.add(person.getName().toString());
+ }
+ personsToUnarchive.add(person);
+ }
+
+ if (!notArchivedNames.isEmpty()) {
+ throw new CommandException(String.format(MESSAGE_NOT_ARCHIVED,
+ String.join(", ", notArchivedNames)));
+ }
+
+ return personsToUnarchive;
+ }
+
+ /**
+ * Resolves an index to a valid {@link Person} from the displayed list.
+ *
+ * @param displayedPersons list being referenced by the user's indexes.
+ * @param targetIndex index to resolve.
+ * @return the referenced person.
+ * @throws CommandException if the index is out of bounds.
+ */
+ private Person getValidPerson(List displayedPersons, Index targetIndex) throws CommandException {
+ int zeroBasedIndex = targetIndex.getZeroBased();
+ if (zeroBasedIndex >= displayedPersons.size()) {
+ throw new CommandException(Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+ return displayedPersons.get(zeroBasedIndex);
+ }
+
+ /**
+ * Applies the unarchive flag to each person and persists updates through the model.
+ *
+ * @param model model used to mutate the person entries.
+ * @param personsToUnarchive persons to unarchive.
+ * @return ordered list of unarchived person names, for display.
+ */
+ private List applyUnarchive(Model model, List personsToUnarchive) {
+ List unarchivedNames = new ArrayList<>(personsToUnarchive.size());
+ for (Person originalPerson : personsToUnarchive) {
+ Person unarchivedPerson = originalPerson.withArchived(false);
+ model.setPerson(originalPerson, unarchivedPerson);
+ unarchivedNames.add(unarchivedPerson.getName().toString());
+ }
+ return unarchivedNames;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean equals(Object other) {
+ return other == this
+ || (other instanceof UnarchiveCommand
+ && targetIndexes.equals(((UnarchiveCommand) other).targetIndexes));
+ }
+
+ /**
+ * Indicates that this command mutates the model.
+ *
+ * @return always {@code true}.
+ */
+ @Override
+ public boolean isMutating() {
+ return true;
+ }
+}
diff --git a/src/main/java/seedu/address/logic/commands/UndoCommand.java b/src/main/java/seedu/address/logic/commands/UndoCommand.java
new file mode 100644
index 00000000000..6edf2634945
--- /dev/null
+++ b/src/main/java/seedu/address/logic/commands/UndoCommand.java
@@ -0,0 +1,57 @@
+package seedu.address.logic.commands;
+
+import seedu.address.model.Model;
+
+/**
+ * Reverts the most recent mutating action performed in the address book (i.e., performs an undo operation).
+ *
+ * The {@code UndoCommand} relies on the {@link Model} to maintain an internal history of previous states.
+ * If there are no actions left to undo, a message is shown to inform the user.
+ */
+public class UndoCommand extends Command {
+ public static final String COMMAND_WORD = "undo";
+ public static final String MESSAGE_SUCCESS = "Undid the last change.";
+ public static final String MESSAGE_NOTHING = "Nothing to undo.";
+ public static final String MESSAGE_USAGE = COMMAND_WORD
+ + ": Undoes the most recent change made to the address book.\n"
+ + "Format: " + COMMAND_WORD + "\n"
+ + "Example: " + COMMAND_WORD;
+
+ /**
+ * Executes the undo operation.
+ *
+ *
If the {@link Model} has no previous states to revert to (i.e., {@code model.canUndo()} is false),
+ * this method returns a {@link CommandResult} with {@link #MESSAGE_NOTHING}.
+ * Otherwise, it performs the undo operation using {@link Model#undo()} and
+ * returns a {@link CommandResult} with {@link #MESSAGE_SUCCESS}.
+ *
+ * @param model The model in which to perform the undo operation.
+ * @return The result of executing the undo command, containing feedback for the user.
+ */
+ @Override
+ public CommandResult execute(Model model) {
+ if (!model.canUndo()) {
+ return new CommandResult(MESSAGE_NOTHING);
+ }
+ model.undo();
+ return new CommandResult(MESSAGE_SUCCESS);
+ }
+
+ /**
+ * Returns true if this command is equal to the given object.
+ *
+ * Two {@code UndoCommand} instances are considered equal as they are stateless and identical in behavior.
+ *
+ * @param other The object to compare with.
+ * @return {@code true} if the other object is also an {@code UndoCommand}, {@code false} otherwise.
+ */
+ @Override
+ public boolean equals(Object other) {
+ return other == this || other instanceof UndoCommand;
+ }
+
+ @Override
+ public int hashCode() {
+ return getClass().hashCode();
+ }
+}
diff --git a/src/main/java/seedu/address/logic/commands/ViewCommand.java b/src/main/java/seedu/address/logic/commands/ViewCommand.java
new file mode 100644
index 00000000000..6ae413b17c3
--- /dev/null
+++ b/src/main/java/seedu/address/logic/commands/ViewCommand.java
@@ -0,0 +1,52 @@
+package seedu.address.logic.commands;
+
+import static java.util.Objects.requireNonNull;
+
+import java.util.List;
+
+import seedu.address.commons.core.Messages;
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.person.Person;
+
+/**
+ * Displays the details of a specific member in the member list.
+ */
+public class ViewCommand extends Command {
+
+ public static final String COMMAND_WORD = "view";
+ public static final String MESSAGE_USAGE = COMMAND_WORD
+ + ": Views the details of the member identified by the index number used in the displayed member list.\n"
+ + "Parameters: INDEX (must be a positive integer)\n"
+ + "Example: " + COMMAND_WORD + " 1";
+
+ public static final String MESSAGE_VIEW_PERSON_SUCCESS = "Viewing Member Profile:\n%1$s";
+
+ private final Index targetIndex;
+
+ public ViewCommand(Index targetIndex) {
+ this.targetIndex = targetIndex;
+ }
+
+ @Override
+ public CommandResult execute(Model model) throws CommandException {
+ requireNonNull(model);
+ List lastShownList = model.getFilteredPersonList();
+
+ if (targetIndex.getZeroBased() >= lastShownList.size()) {
+ throw new CommandException(Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+
+ Person personToView = lastShownList.get(targetIndex.getZeroBased());
+ return new CommandResult(String.format(MESSAGE_VIEW_PERSON_SUCCESS, personToView.getFormattedProfile()));
+ }
+
+
+ @Override
+ public boolean equals(Object other) {
+ return other == this // short circuit if same object
+ || (other instanceof ViewCommand // instanceof handles nulls
+ && targetIndex.equals(((ViewCommand) other).targetIndex));
+ }
+}
diff --git a/src/main/java/seedu/address/logic/commands/ViewPaymentCommand.java b/src/main/java/seedu/address/logic/commands/ViewPaymentCommand.java
new file mode 100644
index 00000000000..ed0c1756931
--- /dev/null
+++ b/src/main/java/seedu/address/logic/commands/ViewPaymentCommand.java
@@ -0,0 +1,140 @@
+package seedu.address.logic.commands;
+
+import static java.util.Objects.requireNonNull;
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX;
+
+import java.math.BigDecimal;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+import seedu.address.commons.core.LogsCenter;
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.Person;
+
+/**
+ * Shows payments for one person by index, or for everyone if 'all' is used.
+ *
+ * Usage:
+ * viewpayment INDEX
+ * viewpayment all
+ */
+public class ViewPaymentCommand extends Command {
+
+ public static final String COMMAND_WORD = "viewpayment";
+ public static final String MESSAGE_USAGE = COMMAND_WORD + ": Shows recorded payments.\n"
+ + "Parameters: INDEX (positive integer) OR 'all'\n"
+ + "Examples: " + COMMAND_WORD + " 1 | " + COMMAND_WORD + " all";
+
+ private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ISO_LOCAL_DATE;
+ private static final Logger logger = LogsCenter.getLogger(ViewPaymentCommand.class);
+
+ private final Index index; // null => all
+
+ public ViewPaymentCommand(Index index) {
+ this.index = index;
+ }
+
+ public static ViewPaymentCommand forIndex(Index index) {
+ return new ViewPaymentCommand(index);
+ }
+
+ public static ViewPaymentCommand forAll() {
+ return new ViewPaymentCommand(null);
+ }
+
+ @Override
+ public CommandResult execute(Model model) throws CommandException {
+ requireNonNull(model);
+ logger.fine(() -> "Executing viewpayment" + (index == null ? " all" : " " + index.getOneBased()));
+
+ final List people = (index == null)
+ ? model.getAddressBook().getPersonList() // includes archived too
+ : model.getFilteredPersonList();
+
+ // 'all' mode: show per-person totals and a grand total
+ if (index == null) {
+ String perPerson = people.stream()
+ .map(p -> {
+ BigDecimal total = p.getPayments().stream()
+ .map(pay -> pay.getAmount().asBigDecimal())
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ return String.format("- %s%s: $%s", p.isArchived() ? "[Archived] " : "",
+ p.getName(), total.toPlainString());
+ })
+ .collect(Collectors.joining("\n"));
+
+ BigDecimal grand = people.stream()
+ .flatMap(p -> p.getPayments().stream())
+ .map(pay -> pay.getAmount().asBigDecimal())
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ String header = String.format(
+ "Payments summary for %d people. Grand total: $%s",
+ people.size(), grand.toPlainString()
+ );
+ return new CommandResult(header + (perPerson.isEmpty() ? "\n(no payments)" : "\n" + perPerson));
+ }
+
+ // single person mode
+ if (index.getZeroBased() >= people.size()) {
+ throw new CommandException(MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+
+ Person person = people.get(index.getZeroBased());
+ List sorted = Payment.inDisplayOrder(person.getPayments());
+
+ if (sorted.isEmpty()) {
+ return new CommandResult(String.format("%s has no payments recorded.", person.getName()));
+ }
+
+ BigDecimal total = sorted.stream()
+ .map(p -> p.getAmount().asBigDecimal())
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ StringBuilder body = new StringBuilder();
+ for (int i = 0; i < sorted.size(); i++) {
+ Payment p = sorted.get(i);
+ body.append(i + 1)
+ .append(". ")
+ .append(DATE_FMT.format(p.getDate()))
+ .append(" | ")
+ .append("$").append(p.getAmount().toString());
+
+ if (p.getRemarks() != null && !p.getRemarks().isEmpty()) {
+ body.append(" | ").append(p.getRemarks());
+ }
+ if (i < sorted.size() - 1) {
+ body.append("\n");
+ }
+ }
+
+ String header = String.format(
+ "Payments for %s (%d). Total: $%s",
+ person.getName(), sorted.size(), total.toPlainString()
+ );
+ return new CommandResult(header + "\n" + body);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) {
+ return true;
+ }
+ if (!(other instanceof ViewPaymentCommand)) {
+ return false;
+ }
+ ViewPaymentCommand o = (ViewPaymentCommand) other;
+ return (this.index == null && o.index == null)
+ || (this.index != null && this.index.equals(o.index));
+ }
+
+ @Override
+ public int hashCode() {
+ return index == null ? 0 : index.hashCode();
+ }
+}
diff --git a/src/main/java/seedu/address/logic/commands/exceptions/CommandException.java b/src/main/java/seedu/address/logic/commands/exceptions/CommandException.java
index a16bd14f2cd..cf229737f8a 100644
--- a/src/main/java/seedu/address/logic/commands/exceptions/CommandException.java
+++ b/src/main/java/seedu/address/logic/commands/exceptions/CommandException.java
@@ -1,7 +1,7 @@
package seedu.address.logic.commands.exceptions;
/**
- * Represents an error which occurs during execution of a {@link Command}.
+ * Represents an error which occurs during execution of a {@link /Command}.
*/
public class CommandException extends Exception {
public CommandException(String message) {
diff --git a/src/main/java/seedu/address/logic/parser/AddCommandParser.java b/src/main/java/seedu/address/logic/parser/AddCommandParser.java
index 4ff1a97ed77..91529bbeeaa 100644
--- a/src/main/java/seedu/address/logic/parser/AddCommandParser.java
+++ b/src/main/java/seedu/address/logic/parser/AddCommandParser.java
@@ -1,8 +1,8 @@
package seedu.address.logic.parser;
import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
-import static seedu.address.logic.parser.CliSyntax.PREFIX_ADDRESS;
import static seedu.address.logic.parser.CliSyntax.PREFIX_EMAIL;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_MATRICULATIONNUMBER;
import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME;
import static seedu.address.logic.parser.CliSyntax.PREFIX_PHONE;
import static seedu.address.logic.parser.CliSyntax.PREFIX_TAG;
@@ -10,10 +10,10 @@
import java.util.Set;
import java.util.stream.Stream;
-import seedu.address.logic.commands.AddCommand;
+import seedu.address.logic.commands.AddMemberCommand;
import seedu.address.logic.parser.exceptions.ParseException;
-import seedu.address.model.person.Address;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Person;
import seedu.address.model.person.Phone;
@@ -22,32 +22,38 @@
/**
* Parses input arguments and creates a new AddCommand object
*/
-public class AddCommandParser implements Parser {
+public class AddCommandParser implements Parser {
/**
* Parses the given {@code String} of arguments in the context of the AddCommand
* and returns an AddCommand object for execution.
+ *
* @throws ParseException if the user input does not conform the expected format
*/
- public AddCommand parse(String args) throws ParseException {
+ public AddMemberCommand parse(String args) throws ParseException {
ArgumentMultimap argMultimap =
- ArgumentTokenizer.tokenize(args, PREFIX_NAME, PREFIX_PHONE, PREFIX_EMAIL, PREFIX_ADDRESS, PREFIX_TAG);
+ ArgumentTokenizer.tokenize(args, PREFIX_NAME, PREFIX_PHONE, PREFIX_EMAIL,
+ PREFIX_MATRICULATIONNUMBER, PREFIX_TAG);
- if (!arePrefixesPresent(argMultimap, PREFIX_NAME, PREFIX_ADDRESS, PREFIX_PHONE, PREFIX_EMAIL)
- || !argMultimap.getPreamble().isEmpty()) {
- throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT, AddCommand.MESSAGE_USAGE));
+ if (!arePrefixesPresent(argMultimap, PREFIX_NAME, PREFIX_MATRICULATIONNUMBER, PREFIX_PHONE,
+ PREFIX_EMAIL)
+ || !argMultimap.getPreamble().isEmpty()) {
+ throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT,
+ AddMemberCommand.MESSAGE_USAGE));
}
- argMultimap.verifyNoDuplicatePrefixesFor(PREFIX_NAME, PREFIX_PHONE, PREFIX_EMAIL, PREFIX_ADDRESS);
+ argMultimap.verifyNoDuplicatePrefixesFor(PREFIX_NAME, PREFIX_PHONE, PREFIX_EMAIL,
+ PREFIX_MATRICULATIONNUMBER);
Name name = ParserUtil.parseName(argMultimap.getValue(PREFIX_NAME).get());
Phone phone = ParserUtil.parsePhone(argMultimap.getValue(PREFIX_PHONE).get());
Email email = ParserUtil.parseEmail(argMultimap.getValue(PREFIX_EMAIL).get());
- Address address = ParserUtil.parseAddress(argMultimap.getValue(PREFIX_ADDRESS).get());
+ MatriculationNumber matriculationNumber =
+ ParserUtil.parseAddress(argMultimap.getValue(PREFIX_MATRICULATIONNUMBER).get());
Set tagList = ParserUtil.parseTags(argMultimap.getAllValues(PREFIX_TAG));
- Person person = new Person(name, phone, email, address, tagList);
+ Person person = new Person(name, phone, email, matriculationNumber, tagList);
- return new AddCommand(person);
+ return new AddMemberCommand(person);
}
/**
diff --git a/src/main/java/seedu/address/logic/parser/AddPaymentCommandParser.java b/src/main/java/seedu/address/logic/parser/AddPaymentCommandParser.java
new file mode 100644
index 00000000000..08d5c898f08
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/AddPaymentCommandParser.java
@@ -0,0 +1,132 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_AMOUNT;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_DATE;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_REMARKS;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.AddPaymentCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+import seedu.address.model.payment.Amount;
+
+/**
+ * Parses input arguments and creates a new {@code AddPaymentCommand} object.
+ *
+ * Enforces strict date format (YYYY-MM-DD), ensures no duplicate indexes or prefixes,
+ * and rejects invalid or future dates.
+ */
+public class AddPaymentCommandParser implements Parser {
+
+ public static final String MESSAGE_INVALID_AMOUNT =
+ "Invalid amount (must be a positive number, up to 2 decimal places, and at most 1 million).";
+ public static final String MESSAGE_INVALID_DATE =
+ "Invalid date. Please use the strict format YYYY-MM-DD (e.g., 2025-01-01) and ensure it is not in the future.";
+ public static final String MESSAGE_INVALID_REMARKS = "Remarks must be at most 100 characters long";
+
+ @Override
+ public AddPaymentCommand parse(String args) throws ParseException {
+ ArgumentMultimap map = ArgumentTokenizer.tokenize(
+ args, PREFIX_PAYMENT_AMOUNT, PREFIX_PAYMENT_DATE, PREFIX_PAYMENT_REMARKS);
+
+ // Step 1: Validate prefix usage
+ validatePrefixes(map);
+
+ // Step 2: Parse indexes from preamble
+ List indexes = parseIndexes(map.getPreamble());
+
+ // Step 3: Parse values (amount, date, remarks)
+ Amount amount = parseAmount(map.getValue(PREFIX_PAYMENT_AMOUNT).get());
+ LocalDate date = parseDate(map.getValue(PREFIX_PAYMENT_DATE).get());
+ String remarks = map.getValue(PREFIX_PAYMENT_REMARKS).orElse(null);
+ validateRemarks(remarks);
+
+ // Step 4: Build and return command
+ return new AddPaymentCommand(indexes, amount, date, remarks);
+ }
+
+ // ----------------------------------------------------
+ // Helper methods
+ // ----------------------------------------------------
+
+ private void validatePrefixes(ArgumentMultimap map) throws ParseException {
+ map.verifyNoDuplicatePrefixesFor(
+ PREFIX_PAYMENT_AMOUNT, PREFIX_PAYMENT_DATE, PREFIX_PAYMENT_REMARKS);
+
+ if (map.getPreamble().isBlank()
+ || !arePrefixesPresent(map, PREFIX_PAYMENT_AMOUNT, PREFIX_PAYMENT_DATE)) {
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, AddPaymentCommand.MESSAGE_USAGE));
+ }
+ }
+
+ private List parseIndexes(String preamble) throws ParseException {
+ String[] indexTokens = preamble.split(",");
+ List indexes = new ArrayList<>();
+
+ for (String token : indexTokens) {
+ String trimmed = token.trim();
+ if (!trimmed.isEmpty()) {
+ indexes.add(ParserUtil.parseIndex(trimmed));
+ }
+ }
+
+ if (indexes.isEmpty()) {
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, AddPaymentCommand.MESSAGE_USAGE));
+ }
+
+ long uniqueCount = indexes.stream()
+ .map(Index::getZeroBased)
+ .distinct()
+ .count();
+
+ if (indexes.size() != uniqueCount) {
+ throw new ParseException("Duplicate indexes detected. Each index must be unique.");
+ }
+
+ return indexes;
+ }
+
+ private Amount parseAmount(String amountStr) throws ParseException {
+ try {
+ return Amount.parse(amountStr);
+ } catch (IllegalArgumentException ex) {
+ throw new ParseException(MESSAGE_INVALID_AMOUNT, ex);
+ }
+ }
+
+ private void validateRemarks(String remarkStr) throws ParseException {
+ if (remarkStr != null && remarkStr.length() > 100) {
+ throw new ParseException(MESSAGE_INVALID_REMARKS);
+ }
+ }
+
+ /**
+ * Parses a valid LocalDate strictly in YYYY-MM-DD format.
+ *
+ * @throws ParseException if the date format is invalid or in the future.
+ */
+ private LocalDate parseDate(String dateStr) throws ParseException {
+ try {
+ LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE);
+ if (date.isAfter(LocalDate.now())) {
+ throw new ParseException(MESSAGE_INVALID_DATE);
+ }
+ return date;
+ } catch (DateTimeParseException ex) {
+ throw new ParseException(MESSAGE_INVALID_DATE, ex);
+ }
+ }
+
+ private static boolean arePrefixesPresent(ArgumentMultimap argumentMultimap, Prefix... prefixes) {
+ return Stream.of(prefixes).allMatch(prefix -> argumentMultimap.getValue(prefix).isPresent());
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/AddressBookParser.java b/src/main/java/seedu/address/logic/parser/AddressBookParser.java
index 3149ee07e0b..0f5b199840c 100644
--- a/src/main/java/seedu/address/logic/parser/AddressBookParser.java
+++ b/src/main/java/seedu/address/logic/parser/AddressBookParser.java
@@ -8,15 +8,24 @@
import java.util.regex.Pattern;
import seedu.address.commons.core.LogsCenter;
-import seedu.address.logic.commands.AddCommand;
-import seedu.address.logic.commands.ClearCommand;
+import seedu.address.logic.commands.AddMemberCommand;
+import seedu.address.logic.commands.AddPaymentCommand;
+import seedu.address.logic.commands.ArchiveCommand;
import seedu.address.logic.commands.Command;
-import seedu.address.logic.commands.DeleteCommand;
+import seedu.address.logic.commands.DeletePaymentCommand;
import seedu.address.logic.commands.EditCommand;
+import seedu.address.logic.commands.EditPaymentCommand;
import seedu.address.logic.commands.ExitCommand;
import seedu.address.logic.commands.FindCommand;
+import seedu.address.logic.commands.FindPaymentCommand;
import seedu.address.logic.commands.HelpCommand;
+import seedu.address.logic.commands.ListArchivedCommand;
import seedu.address.logic.commands.ListCommand;
+import seedu.address.logic.commands.RedoCommand;
+import seedu.address.logic.commands.UnarchiveCommand;
+import seedu.address.logic.commands.UndoCommand;
+import seedu.address.logic.commands.ViewCommand;
+import seedu.address.logic.commands.ViewPaymentCommand;
import seedu.address.logic.parser.exceptions.ParseException;
/**
@@ -27,7 +36,8 @@ public class AddressBookParser {
/**
* Used for initial separation of command word and args.
*/
- private static final Pattern BASIC_COMMAND_FORMAT = Pattern.compile("(?\\S+)(?.*)");
+ private static final Pattern BASIC_COMMAND_FORMAT = Pattern.compile("(?\\S+)(?"
+ + ".*)");
private static final Logger logger = LogsCenter.getLogger(AddressBookParser.class);
/**
@@ -40,43 +50,72 @@ public class AddressBookParser {
public Command parseCommand(String userInput) throws ParseException {
final Matcher matcher = BASIC_COMMAND_FORMAT.matcher(userInput.trim());
if (!matcher.matches()) {
- throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT, HelpCommand.MESSAGE_USAGE));
+ throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT,
+ HelpCommand.MESSAGE_USAGE));
}
- final String commandWord = matcher.group("commandWord");
+ final String commandWord = matcher.group("commandWord").toLowerCase();
final String arguments = matcher.group("arguments");
- // Note to developers: Change the log level in config.json to enable lower level (i.e., FINE, FINER and lower)
+ // Note to developers: Change the log level in config.json to enable lower level (i.e., FINE, FINER
+ // and lower)
// log messages such as the one below.
// Lower level log messages are used sparingly to minimize noise in the code.
logger.fine("Command word: " + commandWord + "; Arguments: " + arguments);
switch (commandWord) {
- case AddCommand.COMMAND_WORD:
+ case AddMemberCommand.COMMAND_WORD:
return new AddCommandParser().parse(arguments);
case EditCommand.COMMAND_WORD:
return new EditCommandParser().parse(arguments);
- case DeleteCommand.COMMAND_WORD:
- return new DeleteCommandParser().parse(arguments);
-
- case ClearCommand.COMMAND_WORD:
- return new ClearCommand();
-
case FindCommand.COMMAND_WORD:
return new FindCommandParser().parse(arguments);
+ case FindPaymentCommand.COMMAND_WORD:
+ return new FindPaymentCommandParser().parse(arguments);
+
case ListCommand.COMMAND_WORD:
- return new ListCommand();
+ return new ListCommandParser().parse(arguments);
case ExitCommand.COMMAND_WORD:
- return new ExitCommand();
+ return new ExitCommandParser().parse(arguments);
case HelpCommand.COMMAND_WORD:
return new HelpCommand();
+ case ViewCommand.COMMAND_WORD:
+ return new ViewCommandParser().parse(arguments);
+
+ case ArchiveCommand.COMMAND_WORD:
+ return new ArchiveCommandParser().parse(arguments);
+
+ case UnarchiveCommand.COMMAND_WORD:
+ return new UnarchiveCommandParser().parse(arguments);
+
+ case ListArchivedCommand.COMMAND_WORD:
+ return new ListArchivedCommandParser().parse(arguments);
+
+ case AddPaymentCommand.COMMAND_WORD:
+ return new AddPaymentCommandParser().parse(arguments);
+
+ case ViewPaymentCommand.COMMAND_WORD:
+ return new ViewPaymentCommandParser().parse(arguments);
+
+ case DeletePaymentCommand.COMMAND_WORD:
+ return new DeletePaymentCommandParser().parse(arguments);
+
+ case EditPaymentCommand.COMMAND_WORD:
+ return new EditPaymentCommandParser().parse(arguments);
+
+ case UndoCommand.COMMAND_WORD:
+ return new UndoCommandParser().parse(arguments);
+
+ case RedoCommand.COMMAND_WORD:
+ return new RedoCommandParser().parse(arguments);
+
default:
logger.finer("This user input caused a ParseException: " + userInput);
throw new ParseException(MESSAGE_UNKNOWN_COMMAND);
diff --git a/src/main/java/seedu/address/logic/parser/ArchiveCommandParser.java b/src/main/java/seedu/address/logic/parser/ArchiveCommandParser.java
new file mode 100644
index 00000000000..8eff53799ad
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/ArchiveCommandParser.java
@@ -0,0 +1,88 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.ArchiveCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Parses input arguments and creates a new {@code ArchiveCommand} object.
+ *
+ * Expected input format (arguments only, command word handled upstream):
+ *
+ * "1" // single index
+ * "1,2,5" // multiple comma-separated indexes
+ * " 1 , 2 , 3 " // spaces around commas and indexes are allowed
+ *
+ * usage error is raised. Each non-blank token is parsed via {@link ParserUtil#parseIndex(String)}.
+ */
+public class ArchiveCommandParser implements Parser {
+
+ @Override
+ public ArchiveCommand parse(String args) throws ParseException {
+ try {
+ String trimmedArgs = args.trim();
+ validateNonEmpty(trimmedArgs);
+
+ String[] tokens = splitByComma(trimmedArgs);
+ List indexes = parseIndexes(tokens);
+
+ ensureNonEmptyIndexes(indexes);
+
+ return new ArchiveCommand(indexes);
+ } catch (ParseException pe) {
+ throw usageError(pe);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Helper methods
+ // ---------------------------------------------------------------------
+
+ /** Ensures the raw argument string is not empty. */
+ private void validateNonEmpty(String trimmedArgs) throws ParseException {
+ if (trimmedArgs.isEmpty()) {
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, ArchiveCommand.MESSAGE_USAGE));
+ }
+ }
+
+ private String[] splitByComma(String input) {
+ return input.split(",");
+ }
+
+ /**
+ * Parses each non-blank token into an {@link Index} using {@link ParserUtil#parseIndex(String)}.
+ * Blank tokens (e.g., from consecutive commas) are skipped.
+ */
+ private List parseIndexes(String[] tokens) throws ParseException {
+ List indexes = new ArrayList<>();
+ for (String token : tokens) {
+ if (token.isBlank()) {
+ // avoid accepting "1,,,,2"
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, ArchiveCommand.MESSAGE_USAGE));
+ }
+ indexes.add(ParserUtil.parseIndex(token.trim()));
+ }
+ return indexes;
+ }
+
+ /** Ensures at least one valid index was provided. */
+ private void ensureNonEmptyIndexes(List indexes) throws ParseException {
+ if (indexes.isEmpty()) {
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, ArchiveCommand.MESSAGE_USAGE));
+ }
+ }
+
+ /** wrapper method for usage error */
+ private ParseException usageError(ParseException cause) {
+ return new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, ArchiveCommand.MESSAGE_USAGE), cause);
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/CliSyntax.java b/src/main/java/seedu/address/logic/parser/CliSyntax.java
index 75b1a9bf119..52b1f0042b0 100644
--- a/src/main/java/seedu/address/logic/parser/CliSyntax.java
+++ b/src/main/java/seedu/address/logic/parser/CliSyntax.java
@@ -9,7 +9,11 @@ public class CliSyntax {
public static final Prefix PREFIX_NAME = new Prefix("n/");
public static final Prefix PREFIX_PHONE = new Prefix("p/");
public static final Prefix PREFIX_EMAIL = new Prefix("e/");
- public static final Prefix PREFIX_ADDRESS = new Prefix("a/");
+ public static final Prefix PREFIX_MATRICULATIONNUMBER = new Prefix("m/");
public static final Prefix PREFIX_TAG = new Prefix("t/");
+ public static final Prefix PREFIX_PAYMENT_INDEX = new Prefix("p/");
+ public static final Prefix PREFIX_PAYMENT_AMOUNT = new Prefix("a/");
+ public static final Prefix PREFIX_PAYMENT_DATE = new Prefix("d/");
+ public static final Prefix PREFIX_PAYMENT_REMARKS = new Prefix("r/");
}
diff --git a/src/main/java/seedu/address/logic/parser/DeletePaymentCommandParser.java b/src/main/java/seedu/address/logic/parser/DeletePaymentCommandParser.java
new file mode 100644
index 00000000000..df24d408467
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/DeletePaymentCommandParser.java
@@ -0,0 +1,126 @@
+package seedu.address.logic.parser;
+
+import static java.util.Objects.requireNonNull;
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.DeletePaymentCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Parses input arguments for:
+ * PERSON_INDEX p/PAYMENT_INDEX[,PAYMENT_INDEX]...
+ * e.g. "2 p/1,2,3"
+ */
+public class DeletePaymentCommandParser implements Parser {
+
+ public static final String MESSAGE_DUPLICATE_PAYMENT_INDEX =
+ "Duplicate payment indexes are not allowed.";
+
+ private static final String MESSAGE_MISSING_PAY_INDEX =
+ "Missing payment index after 'p/'. Example: p/1,2,3";
+ private static final String MESSAGE_EMPTY_PAY_TOKENS =
+ "Empty payment indexes are not allowed. Remove stray commas/spaces. Example: p/1,2,3";
+
+ @Override
+ public DeletePaymentCommand parse(String args) throws ParseException {
+ requireNonNull(args);
+
+ ArgumentMultimap argMultimap = tokenizeAndVerify(args);
+
+ Index personIndex = parsePersonIndex(argMultimap);
+ List paymentIndexes = parsePaymentIndexes(argMultimap);
+
+ ensureNoDuplicatePaymentIndexes(paymentIndexes);
+
+ return new DeletePaymentCommand(personIndex, paymentIndexes);
+ }
+
+ // ------------------------ helpers ------------------------
+
+ /**
+ * Tokenizes the raw input string using {@link ArgumentTokenizer} and ensures that
+ * the payment index prefix (p/) does not appear more than once.
+ *
+ * @param args the raw user input arguments
+ * @return an {@link ArgumentMultimap} containing the preamble and prefix mappings
+ * @throws ParseException if duplicate prefixes are found
+ */
+ private ArgumentMultimap tokenizeAndVerify(String args) throws ParseException {
+ ArgumentMultimap mm = ArgumentTokenizer.tokenize(args, CliSyntax.PREFIX_PAYMENT_INDEX);
+ mm.verifyNoDuplicatePrefixesFor(CliSyntax.PREFIX_PAYMENT_INDEX);
+ return mm;
+ }
+
+ /**
+ * Parses and validates the person index from the command preamble.
+ * Expects exactly one valid index.
+ *
+ * @param mm the tokenized argument multimap
+ * @return a valid {@link Index} representing the person to edit
+ * @throws ParseException if the person index is missing or invalid
+ */
+ private Index parsePersonIndex(ArgumentMultimap mm) throws ParseException {
+ String preamble = mm.getPreamble().trim();
+ try {
+ return ParserUtil.parseIndex(preamble);
+ } catch (ParseException e) {
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, DeletePaymentCommand.MESSAGE_USAGE));
+ }
+ }
+
+ /**
+ * Parses the list of payment indices from the p/ prefix argument.
+ * Comma-separated values are accepted, e.g. {@code p/1,2,3}.
+ *
+ * @param mm the tokenized argument multimap containing the p/ value
+ * @return a list of {@link Index} objects representing payment indices
+ * @throws ParseException if the prefix is missing, contains empty tokens, or invalid numbers
+ */
+ private List parsePaymentIndexes(ArgumentMultimap mm) throws ParseException {
+ String paymentsRaw = mm.getValue(CliSyntax.PREFIX_PAYMENT_INDEX).orElse("").trim();
+ if (paymentsRaw.isEmpty()) {
+ throw new ParseException(MESSAGE_MISSING_PAY_INDEX);
+ }
+
+ String[] tokens = paymentsRaw.split(",", -1);
+ List paymentIndexes = new ArrayList<>(tokens.length);
+
+ for (String token : tokens) {
+ String t = token.trim();
+ if (t.isEmpty()) {
+ throw new ParseException(MESSAGE_EMPTY_PAY_TOKENS);
+ }
+ // Let ParserUtil enforce numeric, positive, etc.
+ paymentIndexes.add(ParserUtil.parseIndex(t));
+ }
+
+ if (paymentIndexes.isEmpty()) {
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, DeletePaymentCommand.MESSAGE_USAGE));
+ }
+ return paymentIndexes;
+ }
+
+ /**
+ * Ensures that all payment indices in the list are unique.
+ *
+ * @param paymentIndexes the list of payment indices to validate
+ * @throws ParseException if duplicate indices are detected
+ */
+ private void ensureNoDuplicatePaymentIndexes(List paymentIndexes) throws ParseException {
+ Set seenOneBased = new HashSet<>();
+ for (Index idx : paymentIndexes) {
+ int oneBased = idx.getOneBased();
+ if (!seenOneBased.add(oneBased)) {
+ throw new ParseException(MESSAGE_DUPLICATE_PAYMENT_INDEX);
+ }
+ }
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/EditCommandParser.java b/src/main/java/seedu/address/logic/parser/EditCommandParser.java
index 46b3309a78b..3cd53aae4e2 100644
--- a/src/main/java/seedu/address/logic/parser/EditCommandParser.java
+++ b/src/main/java/seedu/address/logic/parser/EditCommandParser.java
@@ -2,8 +2,8 @@
import static java.util.Objects.requireNonNull;
import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
-import static seedu.address.logic.parser.CliSyntax.PREFIX_ADDRESS;
import static seedu.address.logic.parser.CliSyntax.PREFIX_EMAIL;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_MATRICULATIONNUMBER;
import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME;
import static seedu.address.logic.parser.CliSyntax.PREFIX_PHONE;
import static seedu.address.logic.parser.CliSyntax.PREFIX_TAG;
@@ -24,25 +24,30 @@
*/
public class EditCommandParser implements Parser {
+
/**
* Parses the given {@code String} of arguments in the context of the EditCommand
* and returns an EditCommand object for execution.
+ *
* @throws ParseException if the user input does not conform the expected format
*/
public EditCommand parse(String args) throws ParseException {
requireNonNull(args);
ArgumentMultimap argMultimap =
- ArgumentTokenizer.tokenize(args, PREFIX_NAME, PREFIX_PHONE, PREFIX_EMAIL, PREFIX_ADDRESS, PREFIX_TAG);
+ ArgumentTokenizer.tokenize(args, PREFIX_NAME, PREFIX_PHONE, PREFIX_EMAIL,
+ PREFIX_MATRICULATIONNUMBER, PREFIX_TAG);
Index index;
try {
index = ParserUtil.parseIndex(argMultimap.getPreamble());
} catch (ParseException pe) {
- throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT, EditCommand.MESSAGE_USAGE), pe);
+ throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT,
+ EditCommand.MESSAGE_USAGE), pe);
}
- argMultimap.verifyNoDuplicatePrefixesFor(PREFIX_NAME, PREFIX_PHONE, PREFIX_EMAIL, PREFIX_ADDRESS);
+ argMultimap.verifyNoDuplicatePrefixesFor(PREFIX_NAME, PREFIX_PHONE, PREFIX_EMAIL,
+ PREFIX_MATRICULATIONNUMBER);
EditPersonDescriptor editPersonDescriptor = new EditPersonDescriptor();
@@ -55,8 +60,9 @@ public EditCommand parse(String args) throws ParseException {
if (argMultimap.getValue(PREFIX_EMAIL).isPresent()) {
editPersonDescriptor.setEmail(ParserUtil.parseEmail(argMultimap.getValue(PREFIX_EMAIL).get()));
}
- if (argMultimap.getValue(PREFIX_ADDRESS).isPresent()) {
- editPersonDescriptor.setAddress(ParserUtil.parseAddress(argMultimap.getValue(PREFIX_ADDRESS).get()));
+ if (argMultimap.getValue(PREFIX_MATRICULATIONNUMBER).isPresent()) {
+ editPersonDescriptor.setMatriculationNumber(
+ ParserUtil.parseAddress(argMultimap.getValue(PREFIX_MATRICULATIONNUMBER).get()));
}
parseTagsForEdit(argMultimap.getAllValues(PREFIX_TAG)).ifPresent(editPersonDescriptor::setTags);
diff --git a/src/main/java/seedu/address/logic/parser/EditPaymentCommandParser.java b/src/main/java/seedu/address/logic/parser/EditPaymentCommandParser.java
new file mode 100644
index 00000000000..1de0d409167
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/EditPaymentCommandParser.java
@@ -0,0 +1,119 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_AMOUNT;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_DATE;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_INDEX;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_REMARKS;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+import java.util.Optional;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.EditPaymentCommand;
+import seedu.address.logic.commands.EditPaymentCommand.EditPaymentDescriptor;
+import seedu.address.logic.parser.exceptions.ParseException;
+import seedu.address.model.payment.Amount;
+
+/**
+ * Parses input arguments and creates a new {@code EditPaymentCommand} object.
+ */
+public class EditPaymentCommandParser implements Parser {
+
+ public static final String MESSAGE_INVALID_DATE =
+ "Invalid date. Please use the strict format YYYY-MM-DD (e.g., 2025-01-01) and ensure it is not in the future.";
+
+ @Override
+ public EditPaymentCommand parse(String args) throws ParseException {
+ ArgumentMultimap map = ArgumentTokenizer.tokenize(
+ args, PREFIX_PAYMENT_INDEX, PREFIX_PAYMENT_AMOUNT, PREFIX_PAYMENT_DATE, PREFIX_PAYMENT_REMARKS);
+
+ // Reject multiple of the same prefix
+ map.verifyNoDuplicatePrefixesFor(PREFIX_PAYMENT_INDEX, PREFIX_PAYMENT_AMOUNT,
+ PREFIX_PAYMENT_DATE, PREFIX_PAYMENT_REMARKS);
+
+ // Check for unknown prefixes
+ checkForUnknownPrefixes(args);
+
+ Index personIndex;
+ try {
+ personIndex = ParserUtil.parseIndex(map.getPreamble());
+ } catch (ParseException pe) {
+ throw new ParseException("Invalid command format!\n" + EditPaymentCommand.MESSAGE_USAGE, pe);
+ }
+
+ int paymentOneBased = parseRequiredPaymentIndex(map);
+ EditPaymentDescriptor d = new EditPaymentDescriptor();
+
+ // Optional fields
+ Optional amtOpt = map.getValue(PREFIX_PAYMENT_AMOUNT);
+ if (amtOpt.isPresent()) {
+ d.setAmount(parseAmount(amtOpt.get()));
+ }
+
+ Optional dateOpt = map.getValue(PREFIX_PAYMENT_DATE);
+ if (dateOpt.isPresent()) {
+ d.setDate(parseDate(dateOpt.get())); // strict date parsing
+ }
+
+ map.getValue(PREFIX_PAYMENT_REMARKS).ifPresent(d::setRemarks);
+
+ if (!d.isAnyFieldEdited()) {
+ throw new ParseException(EditPaymentCommand.MESSAGE_NO_FIELDS);
+ }
+
+ return new EditPaymentCommand(personIndex, paymentOneBased, d);
+ }
+
+ private static int parseRequiredPaymentIndex(ArgumentMultimap map) throws ParseException {
+ String raw = map.getValue(PREFIX_PAYMENT_INDEX)
+ .orElseThrow(() -> new ParseException("Missing required prefix p/ for payment index"));
+ try {
+ int oneBased = Integer.parseInt(raw.trim());
+ if (oneBased <= 0) {
+ throw new NumberFormatException();
+ }
+ return oneBased;
+ } catch (NumberFormatException e) {
+ throw new ParseException("Payment index after p/ must be a positive integer (e.g. p/1)");
+ }
+ }
+
+ private static Amount parseAmount(String s) throws ParseException {
+ try {
+ return Amount.parse(s.trim());
+ } catch (IllegalArgumentException ex) {
+ throw new ParseException("Invalid amount: must be positive, ≤ 2 decimal places, and at most 1 million.");
+ }
+ }
+
+ /**
+ * ✅ Parses dates strictly in YYYY-MM-DD format and disallows future dates.
+ */
+ private static LocalDate parseDate(String s) throws ParseException {
+ try {
+ return seedu.address.model.payment.Payment.parseStrictDate(s.trim());
+ } catch (DateTimeParseException | IllegalArgumentException ex) {
+ throw new ParseException(MESSAGE_INVALID_DATE);
+ }
+ }
+
+ /**
+ * ✅ Detects any prefixes that are not recognized and throws a ParseException.
+ */
+ private static void checkForUnknownPrefixes(String args) throws ParseException {
+ // All valid prefixes for this command
+ String[] validPrefixes = {"p/", "a/", "d/", "r/"};
+
+ // Regex to find all potential prefixes like x/
+ java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\b([a-zA-Z])/").matcher(args);
+
+ while (matcher.find()) {
+ String prefix = matcher.group(1) + "/";
+ boolean isKnown = java.util.Arrays.stream(validPrefixes).anyMatch(prefix::equals);
+ if (!isKnown) {
+ throw new ParseException("Unknown parameter: " + prefix);
+ }
+ }
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/ExitCommandParser.java b/src/main/java/seedu/address/logic/parser/ExitCommandParser.java
new file mode 100644
index 00000000000..9dc3db6d9f3
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/ExitCommandParser.java
@@ -0,0 +1,21 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+
+import seedu.address.logic.commands.ExitCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Parses any input and creates a new {@code ExitCommand} object.
+ */
+public class ExitCommandParser implements Parser {
+
+ @Override
+ public ExitCommand parse(String args) throws ParseException {
+ // exit must have no trailing args (allow whitespace)
+ if (args != null && !args.trim().isEmpty()) {
+ throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT, ExitCommand.MESSAGE_USAGE));
+ }
+ return new ExitCommand();
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/FindCommandParser.java b/src/main/java/seedu/address/logic/parser/FindCommandParser.java
index 2867bde857b..e240bc97375 100644
--- a/src/main/java/seedu/address/logic/parser/FindCommandParser.java
+++ b/src/main/java/seedu/address/logic/parser/FindCommandParser.java
@@ -3,21 +3,22 @@
import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
import java.util.Arrays;
+import java.util.List;
+import java.util.function.Predicate;
import seedu.address.logic.commands.FindCommand;
import seedu.address.logic.parser.exceptions.ParseException;
import seedu.address.model.person.NameContainsKeywordsPredicate;
+import seedu.address.model.person.Person;
+import seedu.address.model.person.TagContainsKeywordsPredicate;
/**
- * Parses input arguments and creates a new FindCommand object
+ * Parses input arguments and creates a new FindCommand object.
+ * Now searches by tag instead of name.
*/
public class FindCommandParser implements Parser {
- /**
- * Parses the given {@code String} of arguments in the context of the FindCommand
- * and returns a FindCommand object for execution.
- * @throws ParseException if the user input does not conform the expected format
- */
+ @Override
public FindCommand parse(String args) throws ParseException {
String trimmedArgs = args.trim();
if (trimmedArgs.isEmpty()) {
@@ -25,9 +26,12 @@ public FindCommand parse(String args) throws ParseException {
String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE));
}
- String[] nameKeywords = trimmedArgs.split("\\s+");
+ List keywords = Arrays.asList(trimmedArgs.split("\\s+"));
- return new FindCommand(new NameContainsKeywordsPredicate(Arrays.asList(nameKeywords)));
- }
+ Predicate combinedPredicate = person ->
+ new NameContainsKeywordsPredicate(keywords).test(person)
+ || new TagContainsKeywordsPredicate(keywords).test(person);
+ return new FindCommand(combinedPredicate);
+ }
}
diff --git a/src/main/java/seedu/address/logic/parser/FindPaymentCommandParser.java b/src/main/java/seedu/address/logic/parser/FindPaymentCommandParser.java
new file mode 100644
index 00000000000..24c0ee0327e
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/FindPaymentCommandParser.java
@@ -0,0 +1,188 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_AMOUNT;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_DATE;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_PAYMENT_REMARKS;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+import java.util.Optional;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.FindPaymentCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+import seedu.address.model.payment.Amount;
+
+/**
+ * Parses input arguments and creates a new {@link FindPaymentCommand} object.
+ *
+ *
+ */
+public class FindPaymentCommandParser implements Parser {
+
+ private static final String MESSAGE_MISSING_FILTER =
+ "Please provide one filter: a/AMOUNT, d/DATE or r/REMARK";
+ private static final String MESSAGE_TOO_MANY_FILTERS =
+ "Please specify only one filter at a time.";
+ private static final String MESSAGE_INVALID_AMOUNT =
+ "Invalid amount: must be positive, ≤ 2 decimal places, and at most 1 million.";
+ private static final String MESSAGE_INVALID_DATE =
+ "Invalid date. Please use the strict format YYYY-MM-DD and ensure it is not in the future.";
+ private static final String MESSAGE_EMPTY_REMARK = "Remark cannot be empty.";
+ private static final String MESSAGE_EMPTY_AMOUNT = "Amount cannot be empty.";
+ private static final String MESSAGE_EMPTY_DATE = "Date cannot be empty.";
+ private static final String MESSAGE_UNKNOWN_PREFIX =
+ "Unknown filter: %s (valid filters are a/AMOUNT, d/DATE and r/REMARK)";
+
+ private static final String[] VALID_PREFIXES = { "a/", "r/", "d/" };
+
+ /**
+ * Parses the given {@code String} of arguments and returns a {@link FindPaymentCommand} object.
+ *
+ * @param args full user input string.
+ * @return a {@link FindPaymentCommand} representing the parsed filter and member index.
+ * @throws ParseException if user input does not conform to the expected format or contains invalid data.
+ */
+ @Override
+ public FindPaymentCommand parse(String args) throws ParseException {
+ ArgumentMultimap argMap = ArgumentTokenizer.tokenize(
+ args, PREFIX_PAYMENT_AMOUNT, PREFIX_PAYMENT_REMARKS, PREFIX_PAYMENT_DATE);
+
+ Index targetIndex = parseIndex(argMap);
+ validatePrefixUsage(argMap);
+
+ return buildCommand(argMap, targetIndex);
+ }
+
+ // ----------------------------------------------------
+ // Helpers for parsing and validation
+ // ----------------------------------------------------
+
+ /**
+ * Parses and validates the member index from the argument preamble.
+ *
+ * @param map the {@link ArgumentMultimap} containing user arguments.
+ * @return the parsed {@link Index} of the member.
+ * @throws ParseException if the index is missing, not numeric, or invalid.
+ */
+ private Index parseIndex(ArgumentMultimap map) throws ParseException {
+ String preamble = map.getPreamble().trim();
+ if (preamble.isBlank()) {
+ throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindPaymentCommand.MESSAGE_USAGE));
+ }
+
+ String[] tokens = preamble.split("\\s+");
+ if (tokens.length != 1) {
+ throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindPaymentCommand.MESSAGE_USAGE));
+ }
+
+ try {
+ return ParserUtil.parseIndex(tokens[0]);
+ } catch (Exception e) {
+ throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindPaymentCommand.MESSAGE_USAGE));
+ }
+ }
+
+ /**
+ * Ensures that exactly one filter prefix (amount, date, or remark) is used,
+ * and that no duplicates exist.
+ *
+ * @param map the {@link ArgumentMultimap} containing parsed prefixes.
+ * @throws ParseException if no filter or multiple filters are provided.
+ */
+ private void validatePrefixUsage(ArgumentMultimap map) throws ParseException {
+ map.verifyNoDuplicatePrefixesFor(PREFIX_PAYMENT_AMOUNT, PREFIX_PAYMENT_REMARKS, PREFIX_PAYMENT_DATE);
+
+ int filtersUsed = countFilters(map);
+ if (filtersUsed == 0) {
+ throw new ParseException(MESSAGE_MISSING_FILTER);
+ }
+ if (filtersUsed > 1) {
+ throw new ParseException(MESSAGE_TOO_MANY_FILTERS);
+ }
+ }
+
+ /**
+ * Builds a {@link FindPaymentCommand} using the appropriate filter type.
+ *
+ * @param map the parsed argument mappings.
+ * @param index the target member index.
+ * @return a fully constructed {@link FindPaymentCommand}.
+ * @throws ParseException if filter data is invalid.
+ */
+ private FindPaymentCommand buildCommand(ArgumentMultimap map, Index index) throws ParseException {
+ Optional amountVal = map.getValue(PREFIX_PAYMENT_AMOUNT);
+ Optional remarkVal = map.getValue(PREFIX_PAYMENT_REMARKS);
+ Optional dateVal = map.getValue(PREFIX_PAYMENT_DATE);
+
+ if (amountVal.isPresent()) {
+ Amount amount = parseAmount(amountVal.get());
+ return new FindPaymentCommand(index, amount, null, null);
+ }
+
+ if (remarkVal.isPresent()) {
+ String remark = parseRemark(remarkVal.get());
+ return new FindPaymentCommand(index, null, remark, null);
+ }
+
+ LocalDate date = parseDate(dateVal.get());
+ return new FindPaymentCommand(index, null, null, date);
+ }
+
+ /**
+ * Counts the number of filters provided by the user.
+ *
+ * @param map the {@link ArgumentMultimap} containing all parsed arguments.
+ * @return the number of filters (0–3).
+ */
+ private int countFilters(ArgumentMultimap map) {
+ return (map.getValue(PREFIX_PAYMENT_AMOUNT).isPresent() ? 1 : 0)
+ + (map.getValue(PREFIX_PAYMENT_REMARKS).isPresent() ? 1 : 0)
+ + (map.getValue(PREFIX_PAYMENT_DATE).isPresent() ? 1 : 0);
+ }
+
+ // ----------------------------------------------------
+ // Filter parsers for amount, remark, and date
+ // ----------------------------------------------------
+
+ private Amount parseAmount(String amountStr) throws ParseException {
+ if (amountStr.trim().isEmpty()) {
+ throw new ParseException(MESSAGE_EMPTY_AMOUNT);
+ }
+ try {
+ return Amount.parse(amountStr);
+ } catch (IllegalArgumentException ex) {
+ throw new ParseException(MESSAGE_INVALID_AMOUNT, ex);
+ }
+ }
+
+ private String parseRemark(String value) throws ParseException {
+ String remark = value.trim();
+ if (remark.trim().isEmpty()) {
+ throw new ParseException(MESSAGE_EMPTY_REMARK);
+ }
+ return remark;
+ }
+
+ private LocalDate parseDate(String dateStr) throws ParseException {
+ if (dateStr.trim().isEmpty()) {
+ throw new ParseException(MESSAGE_EMPTY_DATE);
+ }
+ try {
+ return seedu.address.model.payment.Payment.parseStrictDate(dateStr);
+ } catch (DateTimeParseException | IllegalArgumentException ex) {
+ throw new ParseException(MESSAGE_INVALID_DATE, ex);
+ }
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/ListArchivedCommandParser.java b/src/main/java/seedu/address/logic/parser/ListArchivedCommandParser.java
new file mode 100644
index 00000000000..5aba528d19f
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/ListArchivedCommandParser.java
@@ -0,0 +1,20 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+
+import seedu.address.logic.commands.ListArchivedCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Parses input arguments and creates a new ListArchivedCommand object
+ */
+public class ListArchivedCommandParser implements Parser {
+ @Override
+ public ListArchivedCommand parse(String args) throws ParseException {
+ if (args == null || args.trim().isEmpty()) {
+ return new ListArchivedCommand();
+ }
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, ListArchivedCommand.MESSAGE_USAGE));
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/ListCommandParser.java b/src/main/java/seedu/address/logic/parser/ListCommandParser.java
new file mode 100644
index 00000000000..d4863c87fdc
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/ListCommandParser.java
@@ -0,0 +1,20 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+
+import seedu.address.logic.commands.ListCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Parses input arguments and creates a new ListCommand object
+ */
+public class ListCommandParser implements Parser {
+ @Override
+ public ListCommand parse(String args) throws ParseException {
+ if (args == null || args.trim().isEmpty()) {
+ return new ListCommand();
+ }
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, ListCommand.MESSAGE_USAGE));
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/ParserUtil.java b/src/main/java/seedu/address/logic/parser/ParserUtil.java
index b117acb9c55..ac439eaa645 100644
--- a/src/main/java/seedu/address/logic/parser/ParserUtil.java
+++ b/src/main/java/seedu/address/logic/parser/ParserUtil.java
@@ -2,15 +2,17 @@
import static java.util.Objects.requireNonNull;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
+import java.util.List;
import java.util.Set;
import seedu.address.commons.core.index.Index;
import seedu.address.commons.util.StringUtil;
import seedu.address.logic.parser.exceptions.ParseException;
-import seedu.address.model.person.Address;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Phone;
import seedu.address.model.tag.Tag;
@@ -20,11 +22,14 @@
*/
public class ParserUtil {
- public static final String MESSAGE_INVALID_INDEX = "Index is not a non-zero unsigned integer.";
+ public static final String MESSAGE_INVALID_INDEX =
+ "Invalid index: must be a positive integer with no letters or spaces.";
/**
- * Parses {@code oneBasedIndex} into an {@code Index} and returns it. Leading and trailing whitespaces will be
+ * Parses {@code oneBasedIndex} into an {@code Index} and returns it. Leading and trailing whitespaces
+ * will be
* trimmed.
+ *
* @throws ParseException if the specified index is invalid (not non-zero unsigned integer).
*/
public static Index parseIndex(String oneBasedIndex) throws ParseException {
@@ -71,13 +76,13 @@ public static Phone parsePhone(String phone) throws ParseException {
*
* @throws ParseException if the given {@code address} is invalid.
*/
- public static Address parseAddress(String address) throws ParseException {
+ public static MatriculationNumber parseAddress(String address) throws ParseException {
requireNonNull(address);
String trimmedAddress = address.trim();
- if (!Address.isValidAddress(trimmedAddress)) {
- throw new ParseException(Address.MESSAGE_CONSTRAINTS);
+ if (!MatriculationNumber.isValidMatriculationNumber(trimmedAddress)) {
+ throw new ParseException(MatriculationNumber.MESSAGE_CONSTRAINTS);
}
- return new Address(trimmedAddress);
+ return new MatriculationNumber(trimmedAddress);
}
/**
@@ -121,4 +126,29 @@ public static Set parseTags(Collection tags) throws ParseException
}
return tagSet;
}
+
+ /**
+ * Parses a comma-separated list of one-based indexes (e.g. "1", "1,2, 5").
+ * Trims whitespace around each index. Throws ParseException if any index is invalid
+ * or if the input is empty/blank.
+ */
+ public static List parseIndexes(String indexes) throws ParseException {
+ requireNonNull(indexes);
+ String trimmed = indexes.trim();
+ if (trimmed.isEmpty()) {
+ throw new ParseException("No indexes provided.");
+ }
+
+ String[] parts = trimmed.split(",");
+ List parsed = new ArrayList<>();
+ for (String part : parts) {
+ String p = part.trim();
+ if (p.isEmpty()) {
+ throw new ParseException("Empty index in list.");
+ }
+ parsed.add(parseIndex(p)); // reuse the existing parseIndex(String)
+ }
+ return parsed;
+ }
+
}
diff --git a/src/main/java/seedu/address/logic/parser/RedoCommandParser.java b/src/main/java/seedu/address/logic/parser/RedoCommandParser.java
new file mode 100644
index 00000000000..23dad92bf78
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/RedoCommandParser.java
@@ -0,0 +1,20 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+
+import seedu.address.logic.commands.RedoCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Parses input arguments and creates a new RedoCommand object
+ */
+public class RedoCommandParser implements Parser {
+ @Override
+ public RedoCommand parse(String args) throws ParseException {
+ if (args == null || args.trim().isEmpty()) {
+ return new RedoCommand();
+ }
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, RedoCommand.MESSAGE_USAGE));
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/UnarchiveCommandParser.java b/src/main/java/seedu/address/logic/parser/UnarchiveCommandParser.java
new file mode 100644
index 00000000000..ac6b0db41fa
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/UnarchiveCommandParser.java
@@ -0,0 +1,85 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.UnarchiveCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Parses input arguments and creates a new {@code UnarchiveCommand} object.
+ *
+ * Supports multiple comma-separated indexes (e.g., {@code "1,2,3"}).
+ * Spaces around commas and tokens are allowed; empty tokens are ignored.
+ * If no valid indexes remain after parsing, a usage error is raised.
+ */
+public class UnarchiveCommandParser implements Parser {
+
+ @Override
+ public UnarchiveCommand parse(String args) throws ParseException {
+ try {
+ String trimmedArgs = args.trim();
+ validateNonEmpty(trimmedArgs);
+
+ String[] tokens = splitByComma(trimmedArgs);
+ List indexes = parseIndexes(tokens);
+
+ ensureNonEmptyIndexes(indexes);
+
+ return new UnarchiveCommand(indexes);
+ } catch (ParseException pe) {
+ throw usageError(pe);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Helper methods
+ // ---------------------------------------------------------------------
+
+ /** Ensures the raw argument string is not empty. */
+ private void validateNonEmpty(String trimmedArgs) throws ParseException {
+ if (trimmedArgs.isEmpty()) {
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, UnarchiveCommand.MESSAGE_USAGE));
+ }
+ }
+
+ private String[] splitByComma(String input) {
+ return input.split(",");
+ }
+
+ /**
+ * Parses each non-blank token into an {@link Index} using {@link ParserUtil#parseIndex(String)}.
+ * Blank tokens (e.g., from consecutive commas) are skipped.
+ */
+ private List parseIndexes(String[] tokens) throws ParseException {
+ List indexes = new ArrayList<>();
+ for (String token : tokens) {
+ if (token.isBlank()) {
+ // avoid accepting "1,,,,2"
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, UnarchiveCommand.MESSAGE_USAGE));
+ }
+ indexes.add(ParserUtil.parseIndex(token.trim()));
+ }
+ return indexes;
+ }
+
+
+ /** Ensures at least one valid index was provided */
+ private void ensureNonEmptyIndexes(List indexes) throws ParseException {
+ if (indexes.isEmpty()) {
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, UnarchiveCommand.MESSAGE_USAGE));
+ }
+ }
+
+ /** Wrapper method for usage errors (to incerase SLAP) */
+ private ParseException usageError(ParseException cause) {
+ return new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, UnarchiveCommand.MESSAGE_USAGE), cause);
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/UndoCommandParser.java b/src/main/java/seedu/address/logic/parser/UndoCommandParser.java
new file mode 100644
index 00000000000..8be645706b2
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/UndoCommandParser.java
@@ -0,0 +1,20 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+
+import seedu.address.logic.commands.UndoCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Parses input arguments and creates a new UndoCommand object
+ */
+public class UndoCommandParser implements Parser {
+ @Override
+ public UndoCommand parse(String args) throws ParseException {
+ if (args == null || args.trim().isEmpty()) {
+ return new UndoCommand();
+ }
+ throw new ParseException(String.format(
+ MESSAGE_INVALID_COMMAND_FORMAT, UndoCommand.MESSAGE_USAGE));
+ }
+}
diff --git a/src/main/java/seedu/address/logic/parser/DeleteCommandParser.java b/src/main/java/seedu/address/logic/parser/ViewCommandParser.java
similarity index 50%
rename from src/main/java/seedu/address/logic/parser/DeleteCommandParser.java
rename to src/main/java/seedu/address/logic/parser/ViewCommandParser.java
index 3527fe76a3e..b80d6190b6d 100644
--- a/src/main/java/seedu/address/logic/parser/DeleteCommandParser.java
+++ b/src/main/java/seedu/address/logic/parser/ViewCommandParser.java
@@ -1,29 +1,29 @@
package seedu.address.logic.parser;
-import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
import seedu.address.commons.core.index.Index;
-import seedu.address.logic.commands.DeleteCommand;
+import seedu.address.logic.commands.ViewCommand;
import seedu.address.logic.parser.exceptions.ParseException;
/**
- * Parses input arguments and creates a new DeleteCommand object
+ * Parses input arguments and creates a new ViewCommand object.
*/
-public class DeleteCommandParser implements Parser {
+public class ViewCommandParser implements Parser {
/**
- * Parses the given {@code String} of arguments in the context of the DeleteCommand
- * and returns a DeleteCommand object for execution.
- * @throws ParseException if the user input does not conform the expected format
+ * Parses the given {@code String} of arguments in the context of the ViewCommand
+ * and returns a ViewCommand object for execution.
+ * @throws ParseException if the user input does not conform to the expected format
*/
- public DeleteCommand parse(String args) throws ParseException {
+ @Override
+ public ViewCommand parse(String args) throws ParseException {
try {
Index index = ParserUtil.parseIndex(args);
- return new DeleteCommand(index);
+ return new ViewCommand(index);
} catch (ParseException pe) {
throw new ParseException(
- String.format(MESSAGE_INVALID_COMMAND_FORMAT, DeleteCommand.MESSAGE_USAGE), pe);
+ String.format(MESSAGE_INVALID_COMMAND_FORMAT, ViewCommand.MESSAGE_USAGE), pe);
}
}
-
}
diff --git a/src/main/java/seedu/address/logic/parser/ViewPaymentCommandParser.java b/src/main/java/seedu/address/logic/parser/ViewPaymentCommandParser.java
new file mode 100644
index 00000000000..c6d70670da9
--- /dev/null
+++ b/src/main/java/seedu/address/logic/parser/ViewPaymentCommandParser.java
@@ -0,0 +1,27 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.ViewPaymentCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Parses input arguments and creates a new {@code ViewPaymentsCommand} object.
+ */
+public class ViewPaymentCommandParser implements Parser {
+ @Override
+ public ViewPaymentCommand parse(String args) throws ParseException {
+ String s = args.trim();
+ if (s.equalsIgnoreCase("all")) {
+ return new ViewPaymentCommand(null);
+ }
+ try {
+ Index index = ParserUtil.parseIndex(s);
+ return new ViewPaymentCommand(index);
+ } catch (ParseException pe) {
+ throw new ParseException(
+ String.format(MESSAGE_INVALID_COMMAND_FORMAT, ViewPaymentCommand.MESSAGE_USAGE), pe);
+ }
+ }
+}
diff --git a/src/main/java/seedu/address/model/Model.java b/src/main/java/seedu/address/model/Model.java
index d54df471c1f..c79dc4b1b84 100644
--- a/src/main/java/seedu/address/model/Model.java
+++ b/src/main/java/seedu/address/model/Model.java
@@ -11,8 +11,13 @@
* The API of the Model component.
*/
public interface Model {
- /** {@code Predicate} that always evaluate to true */
+ /**
+ * {@code Predicate} that always evaluate to true
+ */
Predicate PREDICATE_SHOW_ALL_PERSONS = unused -> true;
+ Predicate PREDICATE_SHOW_ACTIVE_PERSONS = person -> !person.isArchived();
+ Predicate PREDICATE_SHOW_ARCHIVED_PERSONS = Person::isArchived;
+
/**
* Replaces user prefs data with the data in {@code userPrefs}.
@@ -49,7 +54,9 @@ public interface Model {
*/
void setAddressBook(ReadOnlyAddressBook addressBook);
- /** Returns the AddressBook */
+ /**
+ * Returns the AddressBook
+ */
ReadOnlyAddressBook getAddressBook();
/**
@@ -57,12 +64,6 @@ public interface Model {
*/
boolean hasPerson(Person person);
- /**
- * Deletes the given person.
- * The person must exist in the address book.
- */
- void deletePerson(Person target);
-
/**
* Adds the given person.
* {@code person} must not already exist in the address book.
@@ -72,16 +73,72 @@ public interface Model {
/**
* Replaces the given person {@code target} with {@code editedPerson}.
* {@code target} must exist in the address book.
- * The person identity of {@code editedPerson} must not be the same as another existing person in the address book.
+ * The person identity of {@code editedPerson} must not be the same as another existing person in the
+ * address book.
*/
void setPerson(Person target, Person editedPerson);
- /** Returns an unmodifiable view of the filtered person list */
+ /**
+ * Returns an unmodifiable view of the filtered person list
+ */
ObservableList getFilteredPersonList();
/**
* Updates the filter of the filtered person list to filter by the given {@code predicate}.
+ *
* @throws NullPointerException if {@code predicate} is null.
*/
void updateFilteredPersonList(Predicate predicate);
+
+ /**
+ * Returns true if there is at least one previous state available
+ * in the undo history.
+ *
+ * @return true if undo can be performed, false otherwise.
+ */
+ boolean canUndo();
+
+ /**
+ * Saves the current state of the AddressBook into the undo history.
+ * This should be called before executing a mutating command so that
+ * the previous state can be restored if needed.
+ */
+ void saveSnapshot();
+
+ /**
+ * Stores the given address book state in the undo history.
+ * Called before a mutating command is executed, so that the user can undo it later.
+ */
+ void pushUndoSnapshot(ReadOnlyAddressBook snapshot);
+
+ /**
+ * Restores the AddressBook to its previous state.
+ *
+ * @throws IllegalStateException if there are no states available to undo.
+ */
+ void undo();
+
+ /**
+ * Clears the redo history.
+ * This should be called whenever a new command that changes the state
+ * is executed, to maintain consistency of the undo/redo stacks.
+ */
+ void clearRedo();
+
+ /**
+ * Returns true if there is at least one future state available
+ * in the redo history.
+ *
+ * @return true if redo can be performed, false otherwise.
+ */
+ boolean canRedo();
+
+ /**
+ * Reapplies the most recently undone change to restore the AddressBook
+ * to its state before the last undo operation.
+ *
+ * @throws IllegalStateException if there are no states available to redo.
+ */
+ void redo();
+
}
diff --git a/src/main/java/seedu/address/model/ModelManager.java b/src/main/java/seedu/address/model/ModelManager.java
index 57bc563fde6..9eec6d202fc 100644
--- a/src/main/java/seedu/address/model/ModelManager.java
+++ b/src/main/java/seedu/address/model/ModelManager.java
@@ -4,6 +4,8 @@
import static seedu.address.commons.util.CollectionUtil.requireAllNonNull;
import java.nio.file.Path;
+import java.util.ArrayDeque;
+import java.util.Deque;
import java.util.function.Predicate;
import java.util.logging.Logger;
@@ -22,6 +24,8 @@ public class ModelManager implements Model {
private final AddressBook addressBook;
private final UserPrefs userPrefs;
private final FilteredList filteredPersons;
+ private final Deque undoStack = new ArrayDeque<>();
+ private final Deque redoStack = new ArrayDeque<>(); // optional
/**
* Initializes a ModelManager with the given addressBook and userPrefs.
@@ -34,6 +38,7 @@ public ModelManager(ReadOnlyAddressBook addressBook, ReadOnlyUserPrefs userPrefs
this.addressBook = new AddressBook(addressBook);
this.userPrefs = new UserPrefs(userPrefs);
filteredPersons = new FilteredList<>(this.addressBook.getPersonList());
+ updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
}
public ModelManager() {
@@ -93,21 +98,15 @@ public boolean hasPerson(Person person) {
return addressBook.hasPerson(person);
}
- @Override
- public void deletePerson(Person target) {
- addressBook.removePerson(target);
- }
-
@Override
public void addPerson(Person person) {
addressBook.addPerson(person);
- updateFilteredPersonList(PREDICATE_SHOW_ALL_PERSONS);
+ updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
}
@Override
public void setPerson(Person target, Person editedPerson) {
requireAllNonNull(target, editedPerson);
-
addressBook.setPerson(target, editedPerson);
}
@@ -128,6 +127,57 @@ public void updateFilteredPersonList(Predicate predicate) {
filteredPersons.setPredicate(predicate);
}
+ @Override
+ public void saveSnapshot() {
+ undoStack.push(new AddressBook(this.addressBook)); // deep copy
+ }
+
+ @Override
+ public boolean canUndo() {
+ return !undoStack.isEmpty();
+ }
+
+ @Override
+ public void undo() {
+ if (!canUndo()) {
+ throw new IllegalStateException("Nothing to undo");
+ }
+ // push current to redoStack
+ redoStack.push(new AddressBook(this.addressBook));
+ AddressBook prev = new AddressBook(undoStack.pop());
+ this.addressBook.resetData(prev);
+ // show active list after undo
+ updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
+ }
+
+ @Override
+ public void clearRedo() {
+ redoStack.clear();
+ }
+ @Override
+ public void pushUndoSnapshot(ReadOnlyAddressBook snapshot) {
+ requireNonNull(snapshot);
+ undoStack.push(new AddressBook(snapshot)); // deep copy
+ }
+
+ @Override
+ public boolean canRedo() {
+ return !redoStack.isEmpty();
+ }
+
+ @Override
+ public void redo() {
+ if (!canRedo()) {
+ throw new IllegalStateException("Nothing to redo");
+ }
+ // push current to undoStack for safety
+ undoStack.push(new AddressBook(this.addressBook));
+
+ AddressBook next = new AddressBook(redoStack.pop());
+ this.addressBook.resetData(next);
+ updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
+ }
+
@Override
public boolean equals(Object other) {
if (other == this) {
diff --git a/src/main/java/seedu/address/model/payment/Amount.java b/src/main/java/seedu/address/model/payment/Amount.java
new file mode 100644
index 00000000000..8a2c3f0d409
--- /dev/null
+++ b/src/main/java/seedu/address/model/payment/Amount.java
@@ -0,0 +1,90 @@
+package seedu.address.model.payment;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Objects;
+
+/**
+ * Immutable value object representing a monetary amount.
+ * Rules:
+ * - strictly positive
+ * - at most 2 decimal places
+ * - stored at fixed scale of 2 without rounding
+ */
+public final class Amount implements Comparable {
+ public static final String MESSAGE_CONSTRAINTS =
+ "Invalid amount (must be a positive number, up to 2 decimal places, and at most 1 million).";
+ public static final int SCALE = 2;
+
+ private final BigDecimal value;
+
+ public Amount(BigDecimal value) {
+ this.value = normalize(value);
+ }
+
+ /**
+ * Parse from a string such as "12.34".
+ */
+ public static Amount parse(String raw) {
+ if (raw == null) {
+ throw new NullPointerException("raw");
+ }
+ String s = raw.trim();
+ try {
+ if ((new BigDecimal(s)).compareTo(new BigDecimal("1000000.01")) > 0) {
+ throw new NumberFormatException();
+ }
+ return new Amount(new BigDecimal(s));
+ } catch (NumberFormatException nfe) {
+ throw new IllegalArgumentException(MESSAGE_CONSTRAINTS, nfe);
+ }
+ }
+
+ /**
+ * Internal BigDecimal at scale 2.
+ */
+ public BigDecimal asBigDecimal() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return value.toPlainString();
+ }
+
+ @Override
+ public int compareTo(Amount other) {
+ return this.value.compareTo(other.value);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) {
+ return true;
+ }
+ if (!(o instanceof Amount)) {
+ return false;
+ }
+ Amount other = (Amount) o;
+ return value.equals(other.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return value.hashCode();
+ }
+
+ // ---------- helpers ----------
+
+ private static BigDecimal normalize(BigDecimal input) {
+ Objects.requireNonNull(input, "value");
+ if (input.signum() <= 0) {
+ throw new IllegalArgumentException(MESSAGE_CONSTRAINTS);
+ }
+ if (input.scale() > SCALE) {
+ // do not round silently
+ throw new IllegalArgumentException(MESSAGE_CONSTRAINTS);
+ }
+ return input.setScale(SCALE, RoundingMode.UNNECESSARY);
+ }
+}
diff --git a/src/main/java/seedu/address/model/payment/Payment.java b/src/main/java/seedu/address/model/payment/Payment.java
new file mode 100644
index 00000000000..3ad4f170669
--- /dev/null
+++ b/src/main/java/seedu/address/model/payment/Payment.java
@@ -0,0 +1,140 @@
+package seedu.address.model.payment;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * Immutable record of a single payment.
+ * Contains the Amount, the payment date, optional remarks, and the recordedAt timestamp.
+ */
+public final class Payment {
+
+ /**
+ * A single source of truth for how payments are shown in the UI.
+ * Current policy: most recent date first, then most recent recordedAt as tie-breaker.
+ * This guarantees a stable, deterministic order even when dates are equal.
+ */
+ public static final Comparator DISPLAY_ORDER = Comparator
+ .comparing(Payment::getDate).reversed()
+ .thenComparing(Payment::getRecordedAt, Comparator.reverseOrder());
+
+ private final Amount amount;
+ private final LocalDate date;
+ private final String remarks;
+ private final LocalDateTime recordedAt;
+
+ /**
+ * Create a payment with no remarks. recordedAt defaults to now.
+ */
+ public Payment(Amount amount, LocalDate date) {
+ this(amount, date, null, LocalDateTime.now());
+ }
+
+ /**
+ * Create a payment with remarks. recordedAt defaults to now.
+ */
+ public Payment(Amount amount, LocalDate date, String remarks) {
+ this(amount, date, remarks, LocalDateTime.now());
+ }
+
+ /**
+ * Full constructor with explicit recordedAt.
+ */
+ public Payment(Amount amount, LocalDate date, String remarks, LocalDateTime recordedAt) {
+ this.amount = Objects.requireNonNull(amount, "amount");
+ this.date = Objects.requireNonNull(date, "date");
+ this.remarks = tidy(remarks);
+ this.recordedAt = Objects.requireNonNull(recordedAt, "recordedAt");
+ }
+
+ public Amount getAmount() {
+ return amount;
+ }
+
+ public LocalDate getDate() {
+ return date;
+ }
+
+ public String getRemarks() {
+ return remarks;
+ }
+
+ public LocalDateTime getRecordedAt() {
+ return recordedAt;
+ }
+
+ @Override
+ public String toString() {
+ String r = (remarks == null || remarks.isEmpty()) ? "" : (" | " + remarks);
+ return date + " | $" + amount.toString() + r;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) {
+ return true;
+ }
+ if (!(o instanceof Payment)) {
+ return false;
+ }
+ Payment p = (Payment) o;
+ return Objects.equals(this.amount, p.amount)
+ && Objects.equals(this.date, p.date)
+ && Objects.equals(this.remarks, p.remarks)
+ && Objects.equals(this.recordedAt, p.recordedAt);
+ }
+
+ @Override
+ public int hashCode() {
+ int h = 17;
+ h = 31 * h + amount.hashCode();
+ h = 31 * h + date.hashCode();
+ h = 31 * h + (remarks == null ? 0 : remarks.hashCode());
+ h = 31 * h + recordedAt.hashCode();
+ return h;
+ }
+
+ /**
+ * Convenience: return a new list sorted in display order.
+ */
+ public static List inDisplayOrder(List src) {
+ return src.stream().sorted(DISPLAY_ORDER).collect(Collectors.toList());
+ }
+
+ // ---------- helpers ----------
+
+ private static String tidy(String s) {
+ if (s == null) {
+ return null;
+ }
+ String t = s.trim();
+ return t.isEmpty() ? null : t;
+ }
+
+ /**
+ * Strict date parser that only accepts YYYY-MM-DD format.
+ *
+ * @throws IllegalArgumentException if the format is invalid or the date is in the future.
+ */
+ public static LocalDate parseStrictDate(String dateStr) {
+ Objects.requireNonNull(dateStr, "dateStr");
+ String trimmed = dateStr.trim();
+
+ try {
+ LocalDate parsedDate = LocalDate.parse(trimmed, DateTimeFormatter.ISO_LOCAL_DATE);
+ if (parsedDate.isAfter(LocalDate.now())) {
+ throw new IllegalArgumentException("Date cannot be in the future.");
+ }
+ return parsedDate;
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException(
+ "Invalid date format. Please use strict YYYY-MM-DD (e.g., 2025-01-01).", e);
+ }
+ }
+}
diff --git a/src/main/java/seedu/address/model/person/Address.java b/src/main/java/seedu/address/model/person/Address.java
deleted file mode 100644
index 469a2cc9a1e..00000000000
--- a/src/main/java/seedu/address/model/person/Address.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package seedu.address.model.person;
-
-import static java.util.Objects.requireNonNull;
-import static seedu.address.commons.util.AppUtil.checkArgument;
-
-/**
- * Represents a Person's address in the address book.
- * Guarantees: immutable; is valid as declared in {@link #isValidAddress(String)}
- */
-public class Address {
-
- public static final String MESSAGE_CONSTRAINTS = "Addresses can take any values, and it should not be blank";
-
- /*
- * The first character of the address must not be a whitespace,
- * otherwise " " (a blank string) becomes a valid input.
- */
- public static final String VALIDATION_REGEX = "[^\\s].*";
-
- public final String value;
-
- /**
- * Constructs an {@code Address}.
- *
- * @param address A valid address.
- */
- public Address(String address) {
- requireNonNull(address);
- checkArgument(isValidAddress(address), MESSAGE_CONSTRAINTS);
- value = address;
- }
-
- /**
- * Returns true if a given string is a valid email.
- */
- public static boolean isValidAddress(String test) {
- return test.matches(VALIDATION_REGEX);
- }
-
- @Override
- public String toString() {
- return value;
- }
-
- @Override
- public boolean equals(Object other) {
- if (other == this) {
- return true;
- }
-
- // instanceof handles nulls
- if (!(other instanceof Address)) {
- return false;
- }
-
- Address otherAddress = (Address) other;
- return value.equals(otherAddress.value);
- }
-
- @Override
- public int hashCode() {
- return value.hashCode();
- }
-
-}
diff --git a/src/main/java/seedu/address/model/person/MatriculationNumber.java b/src/main/java/seedu/address/model/person/MatriculationNumber.java
new file mode 100644
index 00000000000..64de592512f
--- /dev/null
+++ b/src/main/java/seedu/address/model/person/MatriculationNumber.java
@@ -0,0 +1,76 @@
+package seedu.address.model.person;
+
+import static java.util.Objects.requireNonNull;
+
+import seedu.address.model.person.exceptions.InvalidMatriculationNumberException;
+
+/**
+ * Represents a Person's matriculation number in the address book.
+ * Guarantees: immutable; always valid as declared in {@link #isValidMatriculationNumber(String)}.
+ */
+public class MatriculationNumber {
+
+ public static final String MESSAGE_CONSTRAINTS =
+ "Matriculation numbers must be exactly 9 characters long, "
+ + "start with 'A', end with an alphabet, "
+ + "and contain digits in between (e.g. A1234567X).";
+
+ /**
+ * Format:
+ * - First char: 'A'
+ * - Next 8 chars: digits 0–9
+ * - Last char: alphabet (A–Z)
+ * Total length: 9.
+ */
+ public static final String VALIDATION_REGEX = "A\\d{7}[A-Z]";
+
+ public final String value;
+
+ /**
+ * Constructs a {@code MatriculationNumber}.
+ *
+ * @param input A valid matriculation number.
+ */
+ public MatriculationNumber(String input) {
+ requireNonNull(input);
+
+ // Convert to uppercase automatically
+ String normalized = input.toUpperCase();
+
+ if (!isValidMatriculationNumber(normalized)) {
+ throw new InvalidMatriculationNumberException(normalized);
+ }
+
+ value = normalized;
+ }
+
+ /**
+ * Returns true if the given string is a valid matriculation number.
+ */
+ public static boolean isValidMatriculationNumber(String test) {
+ return test != null && test.toUpperCase().matches(VALIDATION_REGEX);
+ }
+
+ @Override
+ public String toString() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) {
+ return true;
+ }
+
+ if (!(other instanceof MatriculationNumber otherMatriculationNumber)) {
+ return false;
+ }
+
+ return value.equals(otherMatriculationNumber.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return value.hashCode();
+ }
+}
diff --git a/src/main/java/seedu/address/model/person/Name.java b/src/main/java/seedu/address/model/person/Name.java
index 173f15b9b00..df258dfcaa5 100644
--- a/src/main/java/seedu/address/model/person/Name.java
+++ b/src/main/java/seedu/address/model/person/Name.java
@@ -5,18 +5,54 @@
/**
* Represents a Person's name in the address book.
- * Guarantees: immutable; is valid as declared in {@link #isValidName(String)}
+ * Guarantees: immutable; is valid as declared in {@link #isValidName(String)}.
+ *
+ *
+ * This class enforces a realistic and inclusive naming convention:
+ *
+ *
Names must contain only alphabetic characters, spaces, hyphens (-), apostrophes ('), and periods (.)
+ *
Names must start with a letter (no leading whitespace or symbols)
+ *
Names cannot contain digits or other special symbols (e.g., @, #, $, %, etc.)
+ *
Names must not be blank
+ *
Supports Unicode letters (e.g., accented or non-Latin alphabets)
*/
public class Name {
+ /**
+ * Error message shown when a given name does not meet the required format.
+ */
public static final String MESSAGE_CONSTRAINTS =
- "Names should only contain alphanumeric characters and spaces, and it should not be blank";
+ "Names should only contain alphabetic characters, spaces, hyphens (-), apostrophes ('), "
+ + "and periods (.), should not be blank and should be at most 100 characters long.";
/*
- * The first character of the address must not be a whitespace,
- * otherwise " " (a blank string) becomes a valid input.
+ * The name must:
+ * 1. Start with an alphabetic character (Unicode supported).
+ * 2. Contain only letters, spaces, hyphens, apostrophes, or periods.
+ *
+ * The pattern "[\\p{L}][\\p{L} .'-]*" ensures:
+ * - \\p{L} : any Unicode letter (A–Z, a–z, or letters from other languages)
+ * - [\\p{L} .'-]* : subsequent characters can be letters, spaces, '.', '\'', or '-'
*/
- public static final String VALIDATION_REGEX = "[\\p{Alnum}][\\p{Alnum} ]*";
+ public static final String VALIDATION_REGEX = "[\\p{L}][\\p{L} .'-]*";
public final String fullName;
@@ -24,6 +60,8 @@ public class Name {
* Constructs a {@code Name}.
*
* @param name A valid name.
+ * @throws NullPointerException if {@code name} is null.
+ * @throws IllegalArgumentException if {@code name} does not satisfy {@link #isValidName(String)}.
*/
public Name(String name) {
requireNonNull(name);
@@ -32,13 +70,12 @@ public Name(String name) {
}
/**
- * Returns true if a given string is a valid name.
+ * Returns true if a given string is a valid name according to {@link #VALIDATION_REGEX}.
*/
public static boolean isValidName(String test) {
- return test.matches(VALIDATION_REGEX);
+ return test.length() <= 100 && test.matches(VALIDATION_REGEX);
}
-
@Override
public String toString() {
return fullName;
diff --git a/src/main/java/seedu/address/model/person/Person.java b/src/main/java/seedu/address/model/person/Person.java
index abe8c46b535..93ea60a1c22 100644
--- a/src/main/java/seedu/address/model/person/Person.java
+++ b/src/main/java/seedu/address/model/person/Person.java
@@ -2,13 +2,19 @@
import static seedu.address.commons.util.CollectionUtil.requireAllNonNull;
+import java.util.ArrayList;
import java.util.Collections;
+import java.util.Comparator;
import java.util.HashSet;
+import java.util.List;
import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import seedu.address.commons.util.ToStringBuilder;
+import seedu.address.model.payment.Payment;
import seedu.address.model.tag.Tag;
+import seedu.address.model.util.PersonFormatter;
/**
* Represents a Person in the address book.
@@ -22,19 +28,39 @@ public class Person {
private final Email email;
// Data fields
- private final Address address;
+ private final MatriculationNumber matriculationNumber;
private final Set tags = new HashSet<>();
+ private final List payments;
+ private final boolean archived;
/**
- * Every field must be present and not null.
+ * Minimal constructor (AB3 default fields). Starts with no payments.
*/
- public Person(Name name, Phone phone, Email email, Address address, Set tags) {
- requireAllNonNull(name, phone, email, address, tags);
+ public Person(Name name, Phone phone, Email email, MatriculationNumber matriculationNumber,
+ Set tags) {
+ requireAllNonNull(name, phone, email, matriculationNumber, tags);
this.name = name;
this.phone = phone;
this.email = email;
- this.address = address;
+ this.matriculationNumber = matriculationNumber;
this.tags.addAll(tags);
+ this.archived = false;
+ this.payments = Collections.unmodifiableList(new ArrayList<>()); // empty immutable list
+ }
+
+ /**
+ * Full constructor including payments.
+ */
+ public Person(Name name, Phone phone, Email email, MatriculationNumber matriculationNumber,
+ Set tags, boolean archived, List payments) {
+ requireAllNonNull(name, phone, email, matriculationNumber, tags, payments);
+ this.name = name;
+ this.phone = phone;
+ this.email = email;
+ this.matriculationNumber = matriculationNumber;
+ this.tags.addAll(tags);
+ this.archived = archived;
+ this.payments = Collections.unmodifiableList(new ArrayList<>(payments));
}
public Name getName() {
@@ -49,10 +75,6 @@ public Email getEmail() {
return email;
}
- public Address getAddress() {
- return address;
- }
-
/**
* Returns an immutable tag set, which throws {@code UnsupportedOperationException}
* if modification is attempted.
@@ -61,8 +83,59 @@ public Set getTags() {
return Collections.unmodifiableSet(tags);
}
+ public boolean isArchived() {
+ return archived;
+ }
+
/**
- * Returns true if both persons have the same name.
+ * NEW: copy-with for archived flag
+ */
+ public Person withArchived(boolean newArchived) {
+ return new Person(name, phone, email, matriculationNumber, tags, newArchived, payments);
+ }
+
+ /**
+ * Returns an immutable view of the payments list.
+ */
+ public List getPayments() {
+ return payments;
+ }
+
+ public MatriculationNumber getMatriculationNumber() {
+ return matriculationNumber;
+ }
+
+ /**
+ * Returns a new Person that is identical to this person but with one extra payment appended.
+ * This preserves immutability.
+ */
+ public Person withAddedPayment(Payment payment) {
+ List updated = new ArrayList<>(this.payments);
+ updated.add(payment);
+ return new Person(name, phone, email, matriculationNumber, tags, archived, updated);
+ }
+
+ /**
+ * Returns a new Person that is identical to this person but with the given payment removed.
+ * If the payment does not exist, this person is returned unchanged.
+ */
+ public Person withRemovedPayment(Payment paymentToRemove) {
+ List updated = new ArrayList<>(this.payments);
+ updated.remove(paymentToRemove);
+ return new Person(name, phone, email, matriculationNumber, tags, archived, updated);
+ }
+
+ /**
+ * Returns a new Person with the payment at {@code zeroBasedPaymentIndex} replaced by {@code edited}.
+ */
+ public Person withEditedPayment(int zeroBasedPaymentIndex, Payment edited) {
+ List updated = new ArrayList<>(this.payments);
+ updated.set(zeroBasedPaymentIndex, edited);
+ return new Person(name, phone, email, matriculationNumber, tags, archived, updated);
+ }
+
+ /**
+ * Returns true if both persons have the same matriculation number.
* This defines a weaker notion of equality between two persons.
*/
public boolean isSamePerson(Person otherPerson) {
@@ -70,48 +143,66 @@ public boolean isSamePerson(Person otherPerson) {
return true;
}
- return otherPerson != null
- && otherPerson.getName().equals(getName());
+ if (otherPerson == null) {
+ return false;
+ }
+
+ return otherPerson.getMatriculationNumber().equals(getMatriculationNumber());
+ }
+ /**
+ * Returns latest payment if the person has made any pauyment
+ */
+ public Optional getLatestPayment() {
+ return getPayments().stream()
+ .max(Comparator
+ .comparing(Payment::getDate)
+ .thenComparing(Payment::getRecordedAt)); // if recordedAt exists
}
/**
* Returns true if both persons have the same identity and data fields.
- * This defines a stronger notion of equality between two persons.
+ * (Note: payments are intentionally not part of equality to preserve AB3 semantics.)
*/
@Override
public boolean equals(Object other) {
if (other == this) {
return true;
}
-
- // instanceof handles nulls
if (!(other instanceof Person)) {
return false;
}
-
- Person otherPerson = (Person) other;
- return name.equals(otherPerson.name)
- && phone.equals(otherPerson.phone)
- && email.equals(otherPerson.email)
- && address.equals(otherPerson.address)
- && tags.equals(otherPerson.tags);
+ Person o = (Person) other;
+ return name.equals(o.name)
+ && phone.equals(o.phone)
+ && email.equals(o.email)
+ && matriculationNumber.equals(o.matriculationNumber)
+ && tags.equals(o.tags)
+ && archived == o.archived;
}
@Override
public int hashCode() {
- // use this method for custom fields hashing instead of implementing your own
- return Objects.hash(name, phone, email, address, tags);
+ return Objects.hash(name, phone, email, matriculationNumber, tags);
}
@Override
public String toString() {
return new ToStringBuilder(this)
- .add("name", name)
- .add("phone", phone)
- .add("email", email)
- .add("address", address)
- .add("tags", tags)
- .toString();
+ .add("name", name)
+ .add("phone", phone)
+ .add("email", email)
+ .add("matriculationNumber", matriculationNumber)
+ .add("tags", tags)
+ .add("archived", archived)
+ .add("paymentsCount", payments.size())
+ .toString();
}
+ /**
+ * Returns a human-readable summary of this person's information,
+ * suitable for display in a detailed view.
+ */
+ public String getFormattedProfile() {
+ return PersonFormatter.formatProfile(this);
+ }
}
diff --git a/src/main/java/seedu/address/model/person/Phone.java b/src/main/java/seedu/address/model/person/Phone.java
index d733f63d739..cedf9b23989 100644
--- a/src/main/java/seedu/address/model/person/Phone.java
+++ b/src/main/java/seedu/address/model/person/Phone.java
@@ -11,8 +11,8 @@ public class Phone {
public static final String MESSAGE_CONSTRAINTS =
- "Phone numbers should only contain numbers, and it should be at least 3 digits long";
- public static final String VALIDATION_REGEX = "\\d{3,}";
+ "Phone numbers should only contain numbers, and it should be 8 digits long";
+ public static final String VALIDATION_REGEX = "\\d{8}";
public final String value;
/**
diff --git a/src/main/java/seedu/address/model/person/Role.java b/src/main/java/seedu/address/model/person/Role.java
new file mode 100644
index 00000000000..271b993b809
--- /dev/null
+++ b/src/main/java/seedu/address/model/person/Role.java
@@ -0,0 +1,9 @@
+package seedu.address.model.person;
+
+/**
+ * Represents the role of a CCA member in the address book.
+ */
+public enum Role {
+ TREASURER,
+ MEMBER
+}
diff --git a/src/main/java/seedu/address/model/person/TagContainsKeywordsPredicate.java b/src/main/java/seedu/address/model/person/TagContainsKeywordsPredicate.java
new file mode 100644
index 00000000000..00de3ccf787
--- /dev/null
+++ b/src/main/java/seedu/address/model/person/TagContainsKeywordsPredicate.java
@@ -0,0 +1,40 @@
+package seedu.address.model.person;
+
+import java.util.List;
+import java.util.Set;
+import java.util.function.Predicate;
+
+import seedu.address.model.tag.Tag;
+
+/**
+ * Tests that a {@code Person}'s {@code Tag} matches any of the keywords given.
+ */
+
+public class TagContainsKeywordsPredicate implements Predicate {
+ private final List keywords;
+
+ public TagContainsKeywordsPredicate(List keywords) {
+ this.keywords = keywords;
+ }
+
+ @Override
+ public boolean test(Person person) {
+ Set tags = person.getTags();
+ for (Tag tag : tags) {
+ String tagName = tag.tagName.toLowerCase();
+ for (String keyword : keywords) {
+ if (tagName.equalsIgnoreCase(keyword)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other == this // short circuit if same object
+ || (other instanceof TagContainsKeywordsPredicate
+ && keywords.equals(((TagContainsKeywordsPredicate) other).keywords)); // state check
+ }
+}
diff --git a/src/main/java/seedu/address/model/person/exceptions/DuplicateMatriculationNumberException.java b/src/main/java/seedu/address/model/person/exceptions/DuplicateMatriculationNumberException.java
new file mode 100644
index 00000000000..7ed6088b1d2
--- /dev/null
+++ b/src/main/java/seedu/address/model/person/exceptions/DuplicateMatriculationNumberException.java
@@ -0,0 +1,10 @@
+package seedu.address.model.person.exceptions;
+
+/**
+ * Signals that the operation would result in duplicate matriculation numbers.
+ */
+public class DuplicateMatriculationNumberException extends RuntimeException {
+ public DuplicateMatriculationNumberException() {
+ super("Operation would result in duplicate matriculation numbers");
+ }
+}
diff --git a/src/main/java/seedu/address/model/person/exceptions/InvalidMatriculationNumberException.java b/src/main/java/seedu/address/model/person/exceptions/InvalidMatriculationNumberException.java
new file mode 100644
index 00000000000..7c4c0292d7b
--- /dev/null
+++ b/src/main/java/seedu/address/model/person/exceptions/InvalidMatriculationNumberException.java
@@ -0,0 +1,19 @@
+package seedu.address.model.person.exceptions;
+
+/**
+ * Signals that the provided matriculation number is invalid.
+ */
+public class InvalidMatriculationNumberException extends RuntimeException {
+
+ /**
+ * Constructs a new {@code InvalidMatriculationNumberException} with a detailed error message
+ * that includes the invalid matriculation number provided by the user.
+ *
+ * @param input the invalid matriculation number that caused this exception
+ */
+ public InvalidMatriculationNumberException(String input) {
+ super("Invalid matriculation number: " + input
+ + ". Expected format: A########X (e.g. A01234567X).");
+ }
+
+}
diff --git a/src/main/java/seedu/address/model/util/PersonFormatter.java b/src/main/java/seedu/address/model/util/PersonFormatter.java
new file mode 100644
index 00000000000..728fb0c1e40
--- /dev/null
+++ b/src/main/java/seedu/address/model/util/PersonFormatter.java
@@ -0,0 +1,39 @@
+package seedu.address.model.util;
+
+import seedu.address.model.person.Person;
+
+/**
+ * Contains utility method(s) for formatting a person's information for viewing purposes
+ */
+public class PersonFormatter {
+ /**
+ * Returns a human-readable formatted profile string for the given person for viewing purposes
+ */
+ public static String formatProfile(Person person) {
+ StringBuilder sb = new StringBuilder();
+
+ sb.append("Name: ").append(person.getName()).append("\n");
+ sb.append("Phone: ").append(person.getPhone()).append("\n");
+ sb.append("Email: ").append(person.getEmail()).append("\n");
+ sb.append("Matriculation No.: ").append(person.getMatriculationNumber()).append("\n");
+
+ if (person.getTags().isEmpty()) {
+ sb.append("Tags: (none)\n");
+ } else {
+ sb.append("Tags: ").append(person.getTags()).append("\n");
+ }
+
+ sb.append("Status: ").append(person.isArchived() ? "Archived" : "Active").append("\n");
+
+ sb.append("Payments: ").append(person.getPayments().size()).append(" total").append("\n");
+
+ // include latest payment info if available
+ person.getLatestPayment().ifPresent(latest ->
+ sb.append(" (Latest on ").append(latest.getDate()).append(")").append("\n")
+ );
+
+ sb.append("To view the full list of payments, use the \"viewpayments\" command.");
+
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/seedu/address/model/util/SampleDataUtil.java b/src/main/java/seedu/address/model/util/SampleDataUtil.java
index 1806da4facf..72c2a0a721b 100644
--- a/src/main/java/seedu/address/model/util/SampleDataUtil.java
+++ b/src/main/java/seedu/address/model/util/SampleDataUtil.java
@@ -6,8 +6,8 @@
import seedu.address.model.AddressBook;
import seedu.address.model.ReadOnlyAddressBook;
-import seedu.address.model.person.Address;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Person;
import seedu.address.model.person.Phone;
@@ -18,24 +18,25 @@
*/
public class SampleDataUtil {
public static Person[] getSamplePersons() {
- return new Person[] {
+ return new Person[]{
new Person(new Name("Alex Yeoh"), new Phone("87438807"), new Email("alexyeoh@example.com"),
- new Address("Blk 30 Geylang Street 29, #06-40"),
+ new MatriculationNumber("A1234567X"),
getTagSet("friends")),
new Person(new Name("Bernice Yu"), new Phone("99272758"), new Email("berniceyu@example.com"),
- new Address("Blk 30 Lorong 3 Serangoon Gardens, #07-18"),
+ new MatriculationNumber("A2234567X"),
getTagSet("colleagues", "friends")),
- new Person(new Name("Charlotte Oliveiro"), new Phone("93210283"), new Email("charlotte@example.com"),
- new Address("Blk 11 Ang Mo Kio Street 74, #11-04"),
+ new Person(new Name("Charlotte Oliveiro"), new Phone("93210283"), new Email("charlotte@example"
+ + ".com"),
+ new MatriculationNumber("A3234567X"),
getTagSet("neighbours")),
new Person(new Name("David Li"), new Phone("91031282"), new Email("lidavid@example.com"),
- new Address("Blk 436 Serangoon Gardens Street 26, #16-43"),
+ new MatriculationNumber("A4234567X"),
getTagSet("family")),
new Person(new Name("Irfan Ibrahim"), new Phone("92492021"), new Email("irfan@example.com"),
- new Address("Blk 47 Tampines Street 20, #17-35"),
+ new MatriculationNumber("A5234567X"),
getTagSet("classmates")),
new Person(new Name("Roy Balakrishnan"), new Phone("92624417"), new Email("royb@example.com"),
- new Address("Blk 45 Aljunied Street 85, #11-31"),
+ new MatriculationNumber("A6234567X"),
getTagSet("colleagues"))
};
}
@@ -53,8 +54,8 @@ public static ReadOnlyAddressBook getSampleAddressBook() {
*/
public static Set getTagSet(String... strings) {
return Arrays.stream(strings)
- .map(Tag::new)
- .collect(Collectors.toSet());
+ .map(Tag::new)
+ .collect(Collectors.toSet());
}
}
diff --git a/src/main/java/seedu/address/storage/JsonAdaptedPayment.java b/src/main/java/seedu/address/storage/JsonAdaptedPayment.java
new file mode 100644
index 00000000000..cace28e9071
--- /dev/null
+++ b/src/main/java/seedu/address/storage/JsonAdaptedPayment.java
@@ -0,0 +1,87 @@
+package seedu.address.storage;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import seedu.address.commons.exceptions.IllegalValueException;
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+
+/**
+ * Jackson-friendly version of {@code Payment}.
+ */
+public class JsonAdaptedPayment {
+
+ public static final String MISSING_FIELD_MESSAGE_FORMAT = "Payment's %s field is missing";
+
+ private final String amount; // e.g. "12.34"
+ private final String date; // yyyy-MM-dd
+ private final String remarks; // optional, may be null
+ private final String recordedAt; // ISO-8601, e.g. 2025-10-15T14:23:05.123
+
+ /**
+ * Constructs a {@code JsonAdaptedPayment} with the given JSON properties.
+ */
+ @JsonCreator
+ public JsonAdaptedPayment(@JsonProperty("amount") String amount,
+ @JsonProperty("date") String date,
+ @JsonProperty("remarks") String remarks,
+ @JsonProperty("recordedAt") String recordedAt) {
+ this.amount = amount;
+ this.date = date;
+ this.remarks = remarks;
+ this.recordedAt = recordedAt; // may be null for older save files
+ }
+
+ /**
+ * Converts a given {@code Payment} into this class for Jackson use.
+ */
+ public JsonAdaptedPayment(Payment source) {
+ this.amount = source.getAmount().toString();
+ this.date = source.getDate().toString();
+ this.remarks = source.getRemarks(); // may be null
+ this.recordedAt = source.getRecordedAt().toString();
+ }
+
+ /**
+ * Converts this Jackson-friendly adapted payment object into the model's {@code Payment} object.
+ *
+ * @throws IllegalValueException if any field data constraints are violated.
+ */
+ public Payment toModelType() throws IllegalValueException {
+ if (amount == null || amount.isBlank()) {
+ throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT, "amount"));
+ }
+ if (date == null || date.isBlank()) {
+ throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT, "date"));
+ }
+
+ final Amount modelAmount;
+ try {
+ modelAmount = Amount.parse(amount.trim());
+ } catch (IllegalArgumentException e) {
+ throw new IllegalValueException("Invalid amount: " + amount);
+ }
+
+ final LocalDate modelDate;
+ try {
+ modelDate = LocalDate.parse(date.trim());
+ } catch (Exception e) {
+ throw new IllegalValueException("Invalid payment date: " + date + " (expected yyyy-MM-dd)");
+ }
+
+ final LocalDateTime modelRecordedAt;
+ try {
+ modelRecordedAt = (recordedAt == null || recordedAt.isBlank())
+ ? LocalDateTime.now()
+ : LocalDateTime.parse(recordedAt.trim());
+ } catch (Exception e) {
+ throw new IllegalValueException("Invalid recordedAt: " + recordedAt + " (expected ISO-8601)");
+ }
+
+ return new Payment(modelAmount, modelDate, remarks, modelRecordedAt);
+ }
+}
diff --git a/src/main/java/seedu/address/storage/JsonAdaptedPerson.java b/src/main/java/seedu/address/storage/JsonAdaptedPerson.java
index bd1ca0f56c8..2348222ecb4 100644
--- a/src/main/java/seedu/address/storage/JsonAdaptedPerson.java
+++ b/src/main/java/seedu/address/storage/JsonAdaptedPerson.java
@@ -10,8 +10,9 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import seedu.address.commons.exceptions.IllegalValueException;
-import seedu.address.model.person.Address;
+import seedu.address.model.payment.Payment;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Person;
import seedu.address.model.person.Phone;
@@ -27,25 +28,36 @@ class JsonAdaptedPerson {
private final String name;
private final String phone;
private final String email;
- private final String address;
+ private final String matriculationNumber;
private final List tags = new ArrayList<>();
+ private final Boolean archived;
+ private final List payments = new ArrayList<>();
/**
* Constructs a {@code JsonAdaptedPerson} with the given person details.
*/
@JsonCreator
- public JsonAdaptedPerson(@JsonProperty("name") String name, @JsonProperty("phone") String phone,
- @JsonProperty("email") String email, @JsonProperty("address") String address,
- @JsonProperty("tags") List tags) {
+ public JsonAdaptedPerson(@JsonProperty("name") String name,
+ @JsonProperty("phone") String phone,
+ @JsonProperty("email") String email,
+ @JsonProperty("matriculationNumber") String matriculationNumber,
+ @JsonProperty("tags") List tags,
+ @JsonProperty("archived") Boolean archived,
+ @JsonProperty("payments") List payments) {
this.name = name;
this.phone = phone;
this.email = email;
- this.address = address;
+ this.matriculationNumber = matriculationNumber;
if (tags != null) {
this.tags.addAll(tags);
}
+ this.archived = (archived == null) ? Boolean.FALSE : archived;
+ if (payments != null) {
+ this.payments.addAll(payments);
+ }
}
+
/**
* Converts a given {@code Person} into this class for Jackson use.
*/
@@ -53,10 +65,15 @@ public JsonAdaptedPerson(Person source) {
name = source.getName().fullName;
phone = source.getPhone().value;
email = source.getEmail().value;
- address = source.getAddress().value;
+ matriculationNumber = source.getMatriculationNumber().value;
tags.addAll(source.getTags().stream()
- .map(JsonAdaptedTag::new)
- .collect(Collectors.toList()));
+ .map(JsonAdaptedTag::new)
+ .collect(Collectors.toList()));
+ this.archived = source.isArchived();
+ source.getPayments().stream()
+ .map(JsonAdaptedPayment::new)
+ .forEach(this.payments::add);
+
}
/**
@@ -71,7 +88,8 @@ public Person toModelType() throws IllegalValueException {
}
if (name == null) {
- throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT, Name.class.getSimpleName()));
+ throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT,
+ Name.class.getSimpleName()));
}
if (!Name.isValidName(name)) {
throw new IllegalValueException(Name.MESSAGE_CONSTRAINTS);
@@ -79,7 +97,8 @@ public Person toModelType() throws IllegalValueException {
final Name modelName = new Name(name);
if (phone == null) {
- throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT, Phone.class.getSimpleName()));
+ throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT,
+ Phone.class.getSimpleName()));
}
if (!Phone.isValidPhone(phone)) {
throw new IllegalValueException(Phone.MESSAGE_CONSTRAINTS);
@@ -87,23 +106,37 @@ public Person toModelType() throws IllegalValueException {
final Phone modelPhone = new Phone(phone);
if (email == null) {
- throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT, Email.class.getSimpleName()));
+ throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT,
+ Email.class.getSimpleName()));
}
if (!Email.isValidEmail(email)) {
throw new IllegalValueException(Email.MESSAGE_CONSTRAINTS);
}
final Email modelEmail = new Email(email);
- if (address == null) {
- throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT, Address.class.getSimpleName()));
+ if (matriculationNumber == null) {
+ throw new IllegalValueException(String.format(MISSING_FIELD_MESSAGE_FORMAT,
+ MatriculationNumber.class.getSimpleName()));
}
- if (!Address.isValidAddress(address)) {
- throw new IllegalValueException(Address.MESSAGE_CONSTRAINTS);
+ if (!MatriculationNumber.isValidMatriculationNumber(matriculationNumber)) {
+ throw new IllegalValueException(MatriculationNumber.MESSAGE_CONSTRAINTS);
}
- final Address modelAddress = new Address(address);
+ final MatriculationNumber modelmatriculationNumber = new MatriculationNumber(matriculationNumber);
final Set modelTags = new HashSet<>(personTags);
- return new Person(modelName, modelPhone, modelEmail, modelAddress, modelTags);
+
+ final boolean modelArchived = this.archived != null && this.archived;
+
+ // Build payments list
+ final List modelPayments = new ArrayList<>();
+ for (JsonAdaptedPayment jap : payments) {
+ modelPayments.add(jap.toModelType());
+ }
+
+ // Use the constructor that accepts payments
+ return new Person(modelName, modelPhone, modelEmail, modelmatriculationNumber, modelTags,
+ modelArchived, modelPayments);
+
}
}
diff --git a/src/main/java/seedu/address/ui/HelpWindow.java b/src/main/java/seedu/address/ui/HelpWindow.java
index 3f16b2fcf26..f237647df18 100644
--- a/src/main/java/seedu/address/ui/HelpWindow.java
+++ b/src/main/java/seedu/address/ui/HelpWindow.java
@@ -4,66 +4,112 @@
import javafx.fxml.FXML;
import javafx.scene.control.Button;
-import javafx.scene.control.Label;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
+import javafx.scene.text.Text;
+import javafx.scene.text.TextFlow;
import javafx.stage.Stage;
import seedu.address.commons.core.LogsCenter;
/**
- * Controller for a help page
+ * Controller for the Help window.
+ * Displays a structured quick command reference and a link to the full User Guide.
*/
public class HelpWindow extends UiPart {
- public static final String USERGUIDE_URL = "https://se-education.org/addressbook-level3/UserGuide.html";
- public static final String HELP_MESSAGE = "Refer to the user guide: " + USERGUIDE_URL;
+ public static final String USERGUIDE_URL =
+ "https://ay2526s1-cs2103t-w11-2.github.io/tp/UserGuide.html";
private static final Logger logger = LogsCenter.getLogger(HelpWindow.class);
private static final String FXML = "HelpWindow.fxml";
@FXML
- private Button copyButton;
+ private TextFlow helpTextFlow;
@FXML
- private Label helpMessage;
+ private Button copyButton;
/**
- * Creates a new HelpWindow.
+ * Creates a new HelpWindow using the given stage.
*
* @param root Stage to use as the root of the HelpWindow.
*/
public HelpWindow(Stage root) {
super(FXML, root);
- helpMessage.setText(HELP_MESSAGE);
}
/**
- * Creates a new HelpWindow.
+ * Creates a new HelpWindow with a fresh stage.
*/
public HelpWindow() {
this(new Stage());
}
/**
- * Shows the help window.
- * @throws IllegalStateException
- *
- *
- * if this method is called on a thread other than the JavaFX Application Thread.
- *
- *
- * if this method is called during animation or layout processing.
- *
- *
- * if this method is called on the primary stage.
- *
- *
- * if {@code dialogStage} is already showing.
- *
- *
+ * Initializes the HelpWindow after FXML injection.
+ * Populates the TextFlow with the help content.
+ */
+ @FXML
+ private void initialize() {
+ populateHelpText();
+ }
+
+ /**
+ * Populates the help text with formatted content.
+ */
+ private void populateHelpText() {
+ helpTextFlow.getChildren().clear();
+
+ addSection("Below is a quick user guide. For detailed explanations, visit:\n", false);
+ addSection(USERGUIDE_URL + "\n\n", true);
+
+ addHeader("Member Management");
+ addBullet("add n/NAME p/PHONE e/EMAIL m/MATRIC [t/TAG]... — add a new member");
+ addBullet("edit INDEX [n/NAME] [p/PHONE] [e/EMAIL] [m/MATRIC] [t/TAG]... — edit member details");
+ addBullet("list — show all active members");
+ addBullet("archive/unarchive INDEX — move members between active and archived lists");
+ addBullet("find KEYWORD — search members by name or tag");
+ addBullet("view INDEX — view details of a member\n");
+
+ addHeader("Payment Management");
+ addBullet("addpayment INDEX[,INDEX]... a/AMOUNT d/DATE [r/REMARKS] — add payment(s)");
+ addBullet("editpayment PERSON_INDEX p/PAYMENT_INDEX [a/AMOUNT] [d/DATE] [r/REMARKS] — edit payment");
+ addBullet("deletepayment PERSON_INDEX[,PERSON_INDEX]... p/PAYMENT_INDEX — delete payment(s)");
+ addBullet("viewpayment INDEX — view payments");
+ addBullet("findpayment INDEX [a/AMOUNT] [r/REMARK] [d/DATE] — search a member’s payments\n");
+
+ addHeader("System Commands");
+ addBullet("undo — revert the last change");
+ addBullet("redo — reapply the last undone change");
+ addBullet("help — show this help window");
+ addBullet("exit — close the application");
+ }
+
+ private void addSection(String text, boolean isLink) {
+ Text t = new Text(text);
+ t.setStyle(isLink
+ ? "-fx-fill: #61afef; -fx-underline: true;"
+ : "-fx-fill: white;");
+ helpTextFlow.getChildren().add(t);
+ }
+
+ private void addHeader(String title) {
+ Text header = new Text("\n" + title + "\n");
+ header.setStyle("-fx-font-weight: bold; -fx-fill: #ffd700; -fx-font-size: 14px;");
+ helpTextFlow.getChildren().add(header);
+ }
+
+ private void addBullet(String content) {
+ Text bullet = new Text(" • " + content + "\n");
+ bullet.setStyle("-fx-fill: white;");
+ helpTextFlow.getChildren().add(bullet);
+ }
+
+ /**
+ * Shows the help window and centers it on screen.
*/
public void show() {
- logger.fine("Showing help page about the application.");
+ logger.fine("Showing help window.");
getRoot().show();
getRoot().centerOnScreen();
}
@@ -83,20 +129,20 @@ public void hide() {
}
/**
- * Focuses on the help window.
+ * Brings the help window to focus.
*/
public void focus() {
getRoot().requestFocus();
}
/**
- * Copies the URL to the user guide to the clipboard.
+ * Copies the User Guide URL to the clipboard.
*/
@FXML
private void copyUrl() {
- final Clipboard clipboard = Clipboard.getSystemClipboard();
- final ClipboardContent url = new ClipboardContent();
- url.putString(USERGUIDE_URL);
- clipboard.setContent(url);
+ Clipboard clipboard = Clipboard.getSystemClipboard();
+ ClipboardContent content = new ClipboardContent();
+ content.putString(USERGUIDE_URL);
+ clipboard.setContent(content);
}
}
diff --git a/src/main/java/seedu/address/ui/PersonCard.java b/src/main/java/seedu/address/ui/PersonCard.java
index 094c42cda82..8c67d5a7e5d 100644
--- a/src/main/java/seedu/address/ui/PersonCard.java
+++ b/src/main/java/seedu/address/ui/PersonCard.java
@@ -10,20 +10,12 @@
import seedu.address.model.person.Person;
/**
- * An UI component that displays information of a {@code Person}.
+ * A UI component that displays information of a {@code Person}.
*/
public class PersonCard extends UiPart {
private static final String FXML = "PersonListCard.fxml";
- /**
- * Note: Certain keywords such as "location" and "resources" are reserved keywords in JavaFX.
- * As a consequence, UI elements' variable names cannot be set to such keywords
- * or an exception will be thrown by JavaFX during runtime.
- *
- * @see The issue on AddressBook level 4
- */
-
public final Person person;
@FXML
@@ -35,14 +27,21 @@ public class PersonCard extends UiPart {
@FXML
private Label phone;
@FXML
- private Label address;
+ private Label matriculationNum;
@FXML
private Label email;
@FXML
private FlowPane tags;
+ @FXML
+ private Label archivedLabel;
+ @FXML
+ private Label latestPayment;
/**
- * Creates a {@code PersonCode} with the given {@code Person} and index to display.
+ * Creates a {@code PersonCard} with the given {@code Person} and index to display.
+ *
+ * @param person the person whose details to show
+ * @param displayedIndex the 1-based index to display on the card
*/
public PersonCard(Person person, int displayedIndex) {
super(FXML);
@@ -50,10 +49,30 @@ public PersonCard(Person person, int displayedIndex) {
id.setText(displayedIndex + ". ");
name.setText(person.getName().fullName);
phone.setText(person.getPhone().value);
- address.setText(person.getAddress().value);
+ matriculationNum.setText(person.getMatriculationNumber().value);
email.setText(person.getEmail().value);
+
person.getTags().stream()
.sorted(Comparator.comparing(tag -> tag.tagName))
.forEach(tag -> tags.getChildren().add(new Label(tag.tagName)));
+
+ if (person.isArchived()) {
+ archivedLabel.setVisible(true);
+ archivedLabel.setText("Archived");
+ } else {
+ archivedLabel.setVisible(false);
+ }
+
+ // Latest payment line (by date)
+ String latest = person.getPayments().stream()
+ .max(Comparator.comparing(p -> p.getDate()))
+ .map(p -> {
+ String remark = (p.getRemarks() == null || p.getRemarks().isBlank())
+ ? "" : " for " + p.getRemarks();
+ return "Latest Payment: $" + p.getAmount().toString()
+ + " on " + p.getDate() + remark;
+ })
+ .orElse("Latest Payment: No payments yet");
+ latestPayment.setText(latest);
}
}
diff --git a/src/main/java/seedu/address/ui/UiManager.java b/src/main/java/seedu/address/ui/UiManager.java
index fdf024138bc..4c9e05e5c01 100644
--- a/src/main/java/seedu/address/ui/UiManager.java
+++ b/src/main/java/seedu/address/ui/UiManager.java
@@ -20,7 +20,7 @@ public class UiManager implements Ui {
public static final String ALERT_DIALOG_PANE_FIELD_ID = "alertDialogPane";
private static final Logger logger = LogsCenter.getLogger(UiManager.class);
- private static final String ICON_APPLICATION = "/images/address_book_32.png";
+ private static final String ICON_APPLICATION = "/images/Treasura.png";
private Logic logic;
private MainWindow mainWindow;
diff --git a/src/main/java/seedu/address/ui/UiPart.java b/src/main/java/seedu/address/ui/UiPart.java
index fc820e01a9c..03889c8cd01 100644
--- a/src/main/java/seedu/address/ui/UiPart.java
+++ b/src/main/java/seedu/address/ui/UiPart.java
@@ -1,3 +1,4 @@
+
package seedu.address.ui;
import static java.util.Objects.requireNonNull;
diff --git a/src/main/resources/images/Treasura.png b/src/main/resources/images/Treasura.png
new file mode 100644
index 00000000000..32ee881bae2
Binary files /dev/null and b/src/main/resources/images/Treasura.png differ
diff --git a/src/main/resources/view/CommandBox.fxml b/src/main/resources/view/CommandBox.fxml
index 124283a392e..582f18c92fd 100644
--- a/src/main/resources/view/CommandBox.fxml
+++ b/src/main/resources/view/CommandBox.fxml
@@ -4,6 +4,5 @@
-
+
-
diff --git a/src/main/resources/view/DarkTheme.css b/src/main/resources/view/DarkTheme.css
index 36e6b001cd8..660b0dbcb1d 100644
--- a/src/main/resources/view/DarkTheme.css
+++ b/src/main/resources/view/DarkTheme.css
@@ -1,3 +1,4 @@
+
.background {
-fx-background-color: derive(#1d1d1d, 20%);
background-color: #383838; /* Used in the default.html file */
diff --git a/src/main/resources/view/Extensions.css b/src/main/resources/view/Extensions.css
index bfe82a85964..4d6c9f982b9 100644
--- a/src/main/resources/view/Extensions.css
+++ b/src/main/resources/view/Extensions.css
@@ -1,20 +1,57 @@
+/* --- Starbucks green interiors --- */
-.error {
- -fx-text-fill: #d06651 !important; /* The error class should always override the default text-fill style */
+/* Command TextField inside commandBoxPlaceholder */
+#commandBoxPlaceholder .text-field {
+ -fx-background-color: #2E3033;
+ -fx-control-inner-background: #2E3033;
+ -fx-text-fill: #e8f2ee;
+ -fx-prompt-text-fill: rgba(232,242,238,0.55);
+ -fx-background-insets: 0;
+ -fx-background-radius: 8;
+ -fx-border-color: rgba(212,175,55,0.65);
+ -fx-border-width: 1;
+ -fx-border-radius: 8;
}
-.list-cell:empty {
- /* Empty cells will not have alternating colours */
- -fx-background: #383838;
+/* Result TextArea inside resultDisplayPlaceholder */
+#resultDisplayPlaceholder .text-area,
+#resultDisplayPlaceholder .text-area .content {
+ -fx-background-color: #2E3033;
+ -fx-control-inner-background: #2E3033;
+ -fx-text-fill: #e8f2ee;
+ -fx-background-insets: 0;
+ -fx-background-radius: 10;
+}
+#resultDisplayPlaceholder .text-area {
+ -fx-border-color: rgba(212,175,55,0.65);
+ -fx-border-width: 1;
+ -fx-border-radius: 10;
+}
+
+/* Person list (ListView) area */
+#personList .list-view,
+#personList .scroll-pane,
+#personList .scroll-pane .viewport {
+ -fx-background-color: #2E3033;
+ -fx-background-insets: 0;
+ -fx-background-radius: 12;
+ -fx-border-color: rgba(212,175,55,0.65);
+ -fx-border-width: 1;
+ -fx-border-radius: 12;
}
-.tag-selector {
- -fx-border-width: 1;
- -fx-border-color: white;
- -fx-border-radius: 3;
- -fx-background-radius: 3;
+/* List cells */
+#personList .list-cell {
+ -fx-background-color: transparent;
+ -fx-text-fill: #e8f2ee;
+}
+#personList .list-cell:filled:selected,
+#personList .list-cell:filled:selected:hover {
+ -fx-background-color: #1e3932;
+ -fx-text-fill: white;
}
-.tooltip-text {
- -fx-text-fill: white;
+/* Status bar label text (if any) */
+#statusbarPlaceholder, #statusbarPlaceholder .label {
+ -fx-text-fill: #e8f2ee;
}
diff --git a/src/main/resources/view/HelpWindow.fxml b/src/main/resources/view/HelpWindow.fxml
index e01f330de33..68f2652535d 100644
--- a/src/main/resources/view/HelpWindow.fxml
+++ b/src/main/resources/view/HelpWindow.fxml
@@ -1,44 +1,49 @@
-
-
-
+
+
-
+
-
-
-
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/src/main/resources/view/MainWindow.fxml b/src/main/resources/view/MainWindow.fxml
index 7778f666a0a..d58e86ecf65 100644
--- a/src/main/resources/view/MainWindow.fxml
+++ b/src/main/resources/view/MainWindow.fxml
@@ -6,55 +6,115 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/view/PersonListCard.fxml b/src/main/resources/view/PersonListCard.fxml
index 84e09833a87..5c65a6a7561 100644
--- a/src/main/resources/view/PersonListCard.fxml
+++ b/src/main/resources/view/PersonListCard.fxml
@@ -29,8 +29,15 @@
-
-
+
+
+
+
+
diff --git a/src/main/resources/view/ResultDisplay.fxml b/src/main/resources/view/ResultDisplay.fxml
index 01b691792a9..c5dc227eda8 100644
--- a/src/main/resources/view/ResultDisplay.fxml
+++ b/src/main/resources/view/ResultDisplay.fxml
@@ -3,7 +3,15 @@
-
-
+
+
diff --git a/src/test/data/JsonAddressBookStorageTest/invalidAndValidPersonAddressBook.json b/src/test/data/JsonAddressBookStorageTest/invalidAndValidPersonAddressBook.json
index 6a4d2b7181c..bf305e888e2 100644
--- a/src/test/data/JsonAddressBookStorageTest/invalidAndValidPersonAddressBook.json
+++ b/src/test/data/JsonAddressBookStorageTest/invalidAndValidPersonAddressBook.json
@@ -1,13 +1,16 @@
{
- "persons": [ {
- "name": "Valid Person",
- "phone": "9482424",
- "email": "hans@example.com",
- "address": "4th street"
- }, {
- "name": "Person With Invalid Phone Field",
- "phone": "948asdf2424",
- "email": "hans@example.com",
- "address": "4th street"
- } ]
+ "persons": [
+ {
+ "name": "Valid Person",
+ "phone": "9482424",
+ "email": "hans@example.com",
+ "matriculationNumber": "A00000000A"
+ },
+ {
+ "name": "Person With Invalid Phone Field",
+ "phone": "948asdf2424",
+ "email": "hans@example.com",
+ "matriculationNumber": "A00000000B"
+ }
+ ]
}
diff --git a/src/test/data/JsonAddressBookStorageTest/invalidPersonAddressBook.json b/src/test/data/JsonAddressBookStorageTest/invalidPersonAddressBook.json
index ccd21f7d1a9..1637b261748 100644
--- a/src/test/data/JsonAddressBookStorageTest/invalidPersonAddressBook.json
+++ b/src/test/data/JsonAddressBookStorageTest/invalidPersonAddressBook.json
@@ -1,8 +1,10 @@
{
- "persons": [ {
- "name": "Person with invalid name field: Ha!ns Mu@ster",
- "phone": "9482424",
- "email": "hans@example.com",
- "address": "4th street"
- } ]
+ "persons": [
+ {
+ "name": "Person with invalid name field: Ha!ns Mu@ster",
+ "phone": "9482424",
+ "email": "hans@example.com",
+ "MatriculationNumber": "A00000000C"
+ }
+ ]
}
diff --git a/src/test/data/JsonSerializableAddressBookTest/duplicatePersonAddressBook.json b/src/test/data/JsonSerializableAddressBookTest/duplicatePersonAddressBook.json
index a7427fe7aa2..a8aedf53a1a 100644
--- a/src/test/data/JsonSerializableAddressBookTest/duplicatePersonAddressBook.json
+++ b/src/test/data/JsonSerializableAddressBookTest/duplicatePersonAddressBook.json
@@ -1,14 +1,19 @@
{
- "persons": [ {
- "name": "Alice Pauline",
- "phone": "94351253",
- "email": "alice@example.com",
- "address": "123, Jurong West Ave 6, #08-111",
- "tags": [ "friends" ]
- }, {
- "name": "Alice Pauline",
- "phone": "94351253",
- "email": "pauline@example.com",
- "address": "4th street"
- } ]
+ "persons": [
+ {
+ "name": "Alice Pauline",
+ "phone": "94351253",
+ "email": "alice@example.com",
+ "matriculationNumber": "A4444444A",
+ "tags": [
+ "friends"
+ ]
+ },
+ {
+ "name": "Alice George",
+ "phone": "94351253",
+ "email": "pauline@example.com",
+ "matriculationNumber": "A4444444A"
+ }
+ ]
}
diff --git a/src/test/data/JsonSerializableAddressBookTest/invalidPersonAddressBook.json b/src/test/data/JsonSerializableAddressBookTest/invalidPersonAddressBook.json
index ad3f135ae42..8b3a6954752 100644
--- a/src/test/data/JsonSerializableAddressBookTest/invalidPersonAddressBook.json
+++ b/src/test/data/JsonSerializableAddressBookTest/invalidPersonAddressBook.json
@@ -1,8 +1,10 @@
{
- "persons": [ {
- "name": "Hans Muster",
- "phone": "9482424",
- "email": "invalid@email!3e",
- "address": "4th street"
- } ]
+ "persons": [
+ {
+ "name": "Hans Muster",
+ "phone": "12345678",
+ "email": "invalid@email!3e",
+ "matriculationNumber": "A00000000K"
+ }
+ ]
}
diff --git a/src/test/data/JsonSerializableAddressBookTest/typicalPersonsAddressBook.json b/src/test/data/JsonSerializableAddressBookTest/typicalPersonsAddressBook.json
index 72262099d35..830058d10f6 100644
--- a/src/test/data/JsonSerializableAddressBookTest/typicalPersonsAddressBook.json
+++ b/src/test/data/JsonSerializableAddressBookTest/typicalPersonsAddressBook.json
@@ -1,46 +1,61 @@
{
"_comment": "AddressBook save file which contains the same Person values as in TypicalPersons#getTypicalAddressBook()",
- "persons" : [ {
- "name" : "Alice Pauline",
- "phone" : "94351253",
- "email" : "alice@example.com",
- "address" : "123, Jurong West Ave 6, #08-111",
- "tags" : [ "friends" ]
- }, {
- "name" : "Benson Meier",
- "phone" : "98765432",
- "email" : "johnd@example.com",
- "address" : "311, Clementi Ave 2, #02-25",
- "tags" : [ "owesMoney", "friends" ]
- }, {
- "name" : "Carl Kurz",
- "phone" : "95352563",
- "email" : "heinz@example.com",
- "address" : "wall street",
- "tags" : [ ]
- }, {
- "name" : "Daniel Meier",
- "phone" : "87652533",
- "email" : "cornelia@example.com",
- "address" : "10th street",
- "tags" : [ "friends" ]
- }, {
- "name" : "Elle Meyer",
- "phone" : "9482224",
- "email" : "werner@example.com",
- "address" : "michegan ave",
- "tags" : [ ]
- }, {
- "name" : "Fiona Kunz",
- "phone" : "9482427",
- "email" : "lydia@example.com",
- "address" : "little tokyo",
- "tags" : [ ]
- }, {
- "name" : "George Best",
- "phone" : "9482442",
- "email" : "anna@example.com",
- "address" : "4th street",
- "tags" : [ ]
- } ]
+ "persons": [
+ {
+ "name": "Alice Pauline",
+ "phone": "13658964",
+ "email": "alice@example.com",
+ "matriculationNumber": "A0000000D",
+ "tags": [
+ "friends"
+ ]
+ },
+ {
+ "name": "Benson Meier",
+ "phone": "69834754",
+ "email": "johnd@example.com",
+ "matriculationNumber": "A0000000E",
+ "tags": [
+ "owesMoney",
+ "friends"
+ ]
+ },
+ {
+ "name": "Carl Kurz",
+ "phone": "99997765",
+ "email": "heinz@example.com",
+ "matriculationNumber": "A0000000F",
+ "tags": []
+ },
+ {
+ "name": "Daniel Meier",
+ "phone": "27547468",
+ "email": "cornelia@example.com",
+ "matriculationNumber": "A0000000G",
+ "tags": [
+ "friends"
+ ]
+ },
+ {
+ "name": "Elle Meyer",
+ "phone": "27527468",
+ "email": "werner@example.com",
+ "matriculationNumber": "A0000000H",
+ "tags": []
+ },
+ {
+ "name": "Fiona Kunz",
+ "phone": "27547468",
+ "email": "lydia@example.com",
+ "matriculationNumber": "A0000000I",
+ "tags": []
+ },
+ {
+ "name": "George Best",
+ "phone": "27547468",
+ "email": "anna@example.com",
+ "matriculationNumber": "A0000000J",
+ "tags": []
+ }
+ ]
}
diff --git a/src/test/java/seedu/address/commons/util/AppUtilTest.java b/src/test/java/seedu/address/commons/util/AppUtilTest.java
index 594de1e6365..940aa712cd1 100644
--- a/src/test/java/seedu/address/commons/util/AppUtilTest.java
+++ b/src/test/java/seedu/address/commons/util/AppUtilTest.java
@@ -9,7 +9,7 @@ public class AppUtilTest {
@Test
public void getImage_exitingImage() {
- assertNotNull(AppUtil.getImage("/images/address_book_32.png"));
+ assertNotNull(AppUtil.getImage("/images/Treasura.png"));
}
@Test
diff --git a/src/test/java/seedu/address/logic/LogicManagerTest.java b/src/test/java/seedu/address/logic/LogicManagerTest.java
index baf8ce336a2..7867310b557 100644
--- a/src/test/java/seedu/address/logic/LogicManagerTest.java
+++ b/src/test/java/seedu/address/logic/LogicManagerTest.java
@@ -1,10 +1,9 @@
package seedu.address.logic;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static seedu.address.logic.Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX;
import static seedu.address.logic.Messages.MESSAGE_UNKNOWN_COMMAND;
-import static seedu.address.logic.commands.CommandTestUtil.ADDRESS_DESC_AMY;
import static seedu.address.logic.commands.CommandTestUtil.EMAIL_DESC_AMY;
+import static seedu.address.logic.commands.CommandTestUtil.MATRICULATIONNUM_DESC_AMY;
import static seedu.address.logic.commands.CommandTestUtil.NAME_DESC_AMY;
import static seedu.address.logic.commands.CommandTestUtil.PHONE_DESC_AMY;
import static seedu.address.testutil.Assert.assertThrows;
@@ -18,7 +17,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
-import seedu.address.logic.commands.AddCommand;
+import seedu.address.logic.commands.AddMemberCommand;
import seedu.address.logic.commands.CommandResult;
import seedu.address.logic.commands.ListCommand;
import seedu.address.logic.commands.exceptions.CommandException;
@@ -35,7 +34,8 @@
public class LogicManagerTest {
private static final IOException DUMMY_IO_EXCEPTION = new IOException("dummy IO exception");
- private static final IOException DUMMY_AD_EXCEPTION = new AccessDeniedException("dummy access denied exception");
+ private static final IOException DUMMY_AD_EXCEPTION = new AccessDeniedException("dummy access denied "
+ + "exception");
@TempDir
public Path temporaryFolder;
@@ -47,7 +47,8 @@ public class LogicManagerTest {
public void setUp() {
JsonAddressBookStorage addressBookStorage =
new JsonAddressBookStorage(temporaryFolder.resolve("addressBook.json"));
- JsonUserPrefsStorage userPrefsStorage = new JsonUserPrefsStorage(temporaryFolder.resolve("userPrefs.json"));
+ JsonUserPrefsStorage userPrefsStorage = new JsonUserPrefsStorage(temporaryFolder.resolve("userPrefs"
+ + ".json"));
StorageManager storage = new StorageManager(addressBookStorage, userPrefsStorage);
logic = new LogicManager(model, storage);
}
@@ -60,8 +61,13 @@ public void execute_invalidCommandFormat_throwsParseException() {
@Test
public void execute_commandExecutionError_throwsCommandException() {
- String deleteCommand = "delete 9";
- assertCommandException(deleteCommand, MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ // Use a valid command word but an invalid (out-of-bounds) index to force a CommandException
+ int outOfBoundsIndex = model.getFilteredPersonList().size() + 1; // 1-based index beyond list
+ String command = "archive " + outOfBoundsIndex;
+
+ String expectedMessage = seedu.address.commons.core.Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX;
+
+ assertCommandException(command, expectedMessage);
}
@Test
@@ -92,10 +98,11 @@ public void getFilteredPersonList_modifyList_throwsUnsupportedOperationException
* - no exceptions are thrown
* - the feedback message is equal to {@code expectedMessage}
* - the internal model manager state is the same as that in {@code expectedModel}
+ *
* @see #assertCommandFailure(String, Class, String, Model)
*/
private void assertCommandSuccess(String inputCommand, String expectedMessage,
- Model expectedModel) throws CommandException, ParseException {
+ Model expectedModel) throws CommandException, ParseException {
CommandResult result = logic.execute(inputCommand);
assertEquals(expectedMessage, result.getFeedbackToUser());
assertEquals(expectedModel, model);
@@ -103,6 +110,7 @@ private void assertCommandSuccess(String inputCommand, String expectedMessage,
/**
* Executes the command, confirms that a ParseException is thrown and that the result message is correct.
+ *
* @see #assertCommandFailure(String, Class, String, Model)
*/
private void assertParseException(String inputCommand, String expectedMessage) {
@@ -110,7 +118,9 @@ private void assertParseException(String inputCommand, String expectedMessage) {
}
/**
- * Executes the command, confirms that a CommandException is thrown and that the result message is correct.
+ * Executes the command, confirms that a CommandException is thrown and that the result message is
+ * correct.
+ *
* @see #assertCommandFailure(String, Class, String, Model)
*/
private void assertCommandException(String inputCommand, String expectedMessage) {
@@ -119,10 +129,11 @@ private void assertCommandException(String inputCommand, String expectedMessage)
/**
* Executes the command, confirms that the exception is thrown and that the result message is correct.
+ *
* @see #assertCommandFailure(String, Class, String, Model)
*/
private void assertCommandFailure(String inputCommand, Class extends Throwable> expectedException,
- String expectedMessage) {
+ String expectedMessage) {
Model expectedModel = new ModelManager(model.getAddressBook(), new UserPrefs());
assertCommandFailure(inputCommand, expectedException, expectedMessage, expectedModel);
}
@@ -132,10 +143,11 @@ private void assertCommandFailure(String inputCommand, Class extends Throwable
* - the {@code expectedException} is thrown
* - the resulting error message is equal to {@code expectedMessage}
* - the internal model manager state is the same as that in {@code expectedModel}
+ *
* @see #assertCommandSuccess(String, String, Model)
*/
private void assertCommandFailure(String inputCommand, Class extends Throwable> expectedException,
- String expectedMessage, Model expectedModel) {
+ String expectedMessage, Model expectedModel) {
assertThrows(expectedException, expectedMessage, () -> logic.execute(inputCommand));
assertEquals(expectedModel, model);
}
@@ -143,7 +155,7 @@ private void assertCommandFailure(String inputCommand, Class extends Throwable
/**
* Tests the Logic component's handling of an {@code IOException} thrown by the Storage component.
*
- * @param e the exception to be thrown by the Storage component
+ * @param e the exception to be thrown by the Storage component
* @param expectedMessage the message expected inside exception thrown by the Logic component
*/
private void assertCommandFailureForExceptionFromStorage(IOException e, String expectedMessage) {
@@ -158,6 +170,8 @@ public void saveAddressBook(ReadOnlyAddressBook addressBook, Path filePath)
}
};
+
+
JsonUserPrefsStorage userPrefsStorage =
new JsonUserPrefsStorage(temporaryFolder.resolve("ExceptionUserPrefs.json"));
StorageManager storage = new StorageManager(addressBookStorage, userPrefsStorage);
@@ -165,8 +179,8 @@ public void saveAddressBook(ReadOnlyAddressBook addressBook, Path filePath)
logic = new LogicManager(model, storage);
// Triggers the saveAddressBook method by executing an add command
- String addCommand = AddCommand.COMMAND_WORD + NAME_DESC_AMY + PHONE_DESC_AMY
- + EMAIL_DESC_AMY + ADDRESS_DESC_AMY;
+ String addCommand = AddMemberCommand.COMMAND_WORD + NAME_DESC_AMY + PHONE_DESC_AMY
+ + EMAIL_DESC_AMY + MATRICULATIONNUM_DESC_AMY;
Person expectedPerson = new PersonBuilder(AMY).withTags().build();
ModelManager expectedModel = new ModelManager();
expectedModel.addPerson(expectedPerson);
diff --git a/src/test/java/seedu/address/logic/commands/AddCommandIntegrationTest.java b/src/test/java/seedu/address/logic/commands/AddCommandIntegrationTest.java
index 162a0c86031..850bb876b3a 100644
--- a/src/test/java/seedu/address/logic/commands/AddCommandIntegrationTest.java
+++ b/src/test/java/seedu/address/logic/commands/AddCommandIntegrationTest.java
@@ -33,16 +33,16 @@ public void execute_newPerson_success() {
Model expectedModel = new ModelManager(model.getAddressBook(), new UserPrefs());
expectedModel.addPerson(validPerson);
- assertCommandSuccess(new AddCommand(validPerson), model,
- String.format(AddCommand.MESSAGE_SUCCESS, Messages.format(validPerson)),
- expectedModel);
+ assertCommandSuccess(new AddMemberCommand(validPerson), model,
+ String.format(AddMemberCommand.MESSAGE_SUCCESS, Messages.format(validPerson)),
+ expectedModel);
}
@Test
public void execute_duplicatePerson_throwsCommandException() {
Person personInList = model.getAddressBook().getPersonList().get(0);
- assertCommandFailure(new AddCommand(personInList), model,
- AddCommand.MESSAGE_DUPLICATE_PERSON);
+ assertCommandFailure(new AddMemberCommand(personInList), model,
+ AddMemberCommand.MESSAGE_DUPLICATE_PERSON);
}
}
diff --git a/src/test/java/seedu/address/logic/commands/AddEditViewPaymentFlowIntegrationTest.java b/src/test/java/seedu/address/logic/commands/AddEditViewPaymentFlowIntegrationTest.java
new file mode 100644
index 00000000000..da1b4462593
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/AddEditViewPaymentFlowIntegrationTest.java
@@ -0,0 +1,70 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.EditPaymentCommand.EditPaymentDescriptor;
+import seedu.address.model.AddressBook;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.UserPrefs;
+import seedu.address.model.person.Person;
+import seedu.address.testutil.PaymentBuilder;
+import seedu.address.testutil.PersonBuilder;
+import seedu.address.testutil.TypicalPersons;
+
+public class AddEditViewPaymentFlowIntegrationTest {
+
+ @Test
+ public void addEditView_endToEnd_success() throws Exception {
+ Model model = new ModelManager(TypicalPersons.getTypicalAddressBook(), new UserPrefs());
+
+ Person personToAdd = new PersonBuilder()
+ .withName("Zed Flow")
+ .withPayments(new PaymentBuilder()
+ .withAmount("12.34")
+ .withDate("2025-01-01")
+ .withRemarks("initial")
+ .build())
+ .build();
+
+ CommandResult addResult = new AddMemberCommand(personToAdd).execute(model);
+ assertTrue(addResult.getFeedbackToUser().toLowerCase().contains("added"));
+
+ Index personIndex = Index.fromOneBased(model.getFilteredPersonList().size());
+
+ EditPaymentDescriptor descriptor = new EditPaymentDescriptor();
+ descriptor.setRemarks("updated-remarks");
+ CommandResult editResult = new EditPaymentCommand(personIndex, 1, descriptor).execute(model);
+ assertTrue(editResult.getFeedbackToUser().toLowerCase().contains("edited"));
+
+ CommandResult viewResult = new ViewPaymentCommand(personIndex).execute(model);
+ assertTrue(viewResult.getFeedbackToUser().toLowerCase().contains("updated-remarks"));
+ }
+
+ @Test
+ public void addEditView_onFreshModel_success() throws Exception {
+ Model model = new ModelManager(new AddressBook(), new UserPrefs());
+
+ Person personToAdd = new PersonBuilder()
+ .withName("Flow Fresh")
+ .withPayments(new PaymentBuilder()
+ .withAmount("10.00")
+ .withDate("2025-02-02")
+ .withRemarks("orig")
+ .build())
+ .build();
+
+ new AddMemberCommand(personToAdd).execute(model);
+ Index idx = Index.fromOneBased(model.getFilteredPersonList().size());
+
+ EditPaymentDescriptor descriptor = new EditPaymentDescriptor();
+ descriptor.setRemarks("changed");
+ new EditPaymentCommand(idx, 1, descriptor).execute(model);
+
+ CommandResult view = new ViewPaymentCommand(idx).execute(model);
+ assertTrue(view.getFeedbackToUser().toLowerCase().contains("changed"));
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/AddCommandTest.java b/src/test/java/seedu/address/logic/commands/AddMemberCommandTest.java
similarity index 75%
rename from src/test/java/seedu/address/logic/commands/AddCommandTest.java
rename to src/test/java/seedu/address/logic/commands/AddMemberCommandTest.java
index 90e8253f48e..5f85ee61bee 100644
--- a/src/test/java/seedu/address/logic/commands/AddCommandTest.java
+++ b/src/test/java/seedu/address/logic/commands/AddMemberCommandTest.java
@@ -25,11 +25,11 @@
import seedu.address.model.person.Person;
import seedu.address.testutil.PersonBuilder;
-public class AddCommandTest {
+public class AddMemberCommandTest {
@Test
public void constructor_nullPerson_throwsNullPointerException() {
- assertThrows(NullPointerException.class, () -> new AddCommand(null));
+ assertThrows(NullPointerException.class, () -> new AddMemberCommand(null));
}
@Test
@@ -37,34 +37,36 @@ public void execute_personAcceptedByModel_addSuccessful() throws Exception {
ModelStubAcceptingPersonAdded modelStub = new ModelStubAcceptingPersonAdded();
Person validPerson = new PersonBuilder().build();
- CommandResult commandResult = new AddCommand(validPerson).execute(modelStub);
+ CommandResult commandResult = new AddMemberCommand(validPerson).execute(modelStub);
- assertEquals(String.format(AddCommand.MESSAGE_SUCCESS, Messages.format(validPerson)),
- commandResult.getFeedbackToUser());
+ assertEquals(String.format(AddMemberCommand.MESSAGE_SUCCESS, Messages.format(validPerson)),
+ commandResult.getFeedbackToUser());
assertEquals(Arrays.asList(validPerson), modelStub.personsAdded);
}
@Test
public void execute_duplicatePerson_throwsCommandException() {
Person validPerson = new PersonBuilder().build();
- AddCommand addCommand = new AddCommand(validPerson);
+ AddMemberCommand addMemberCommand = new AddMemberCommand(validPerson);
ModelStub modelStub = new ModelStubWithPerson(validPerson);
- assertThrows(CommandException.class, AddCommand.MESSAGE_DUPLICATE_PERSON, () -> addCommand.execute(modelStub));
+ assertThrows(CommandException.class, AddMemberCommand.MESSAGE_DUPLICATE_PERSON, (
+
+ ) -> addMemberCommand.execute(modelStub));
}
@Test
public void equals() {
Person alice = new PersonBuilder().withName("Alice").build();
Person bob = new PersonBuilder().withName("Bob").build();
- AddCommand addAliceCommand = new AddCommand(alice);
- AddCommand addBobCommand = new AddCommand(bob);
+ AddMemberCommand addAliceCommand = new AddMemberCommand(alice);
+ AddMemberCommand addBobCommand = new AddMemberCommand(bob);
// same object -> returns true
assertTrue(addAliceCommand.equals(addAliceCommand));
// same values -> returns true
- AddCommand addAliceCommandCopy = new AddCommand(alice);
+ AddMemberCommand addAliceCommandCopy = new AddMemberCommand(alice);
assertTrue(addAliceCommand.equals(addAliceCommandCopy));
// different types -> returns false
@@ -79,9 +81,9 @@ public void equals() {
@Test
public void toStringMethod() {
- AddCommand addCommand = new AddCommand(ALICE);
- String expected = AddCommand.class.getCanonicalName() + "{toAdd=" + ALICE + "}";
- assertEquals(expected, addCommand.toString());
+ AddMemberCommand addMemberCommand = new AddMemberCommand(ALICE);
+ String expected = AddMemberCommand.class.getCanonicalName() + "{toAdd=" + ALICE + "}";
+ assertEquals(expected, addMemberCommand.toString());
}
/**
@@ -139,24 +141,54 @@ public boolean hasPerson(Person person) {
}
@Override
- public void deletePerson(Person target) {
+ public void setPerson(Person target, Person editedPerson) {
throw new AssertionError("This method should not be called.");
}
@Override
- public void setPerson(Person target, Person editedPerson) {
+ public ObservableList getFilteredPersonList() {
throw new AssertionError("This method should not be called.");
}
@Override
- public ObservableList getFilteredPersonList() {
+ public void updateFilteredPersonList(Predicate predicate) {
throw new AssertionError("This method should not be called.");
}
@Override
- public void updateFilteredPersonList(Predicate predicate) {
+ public boolean canUndo() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public void saveSnapshot() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public void undo() {
throw new AssertionError("This method should not be called.");
}
+
+ @Override
+ public void clearRedo() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public boolean canRedo() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public void redo() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public void pushUndoSnapshot(ReadOnlyAddressBook snapshot) {
+ throw new AssertionError();
+ }
}
/**
@@ -200,5 +232,4 @@ public ReadOnlyAddressBook getAddressBook() {
return new AddressBook();
}
}
-
}
diff --git a/src/test/java/seedu/address/logic/commands/AddPaymentCommandTest.java b/src/test/java/seedu/address/logic/commands/AddPaymentCommandTest.java
new file mode 100644
index 00000000000..99db96cf186
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/AddPaymentCommandTest.java
@@ -0,0 +1,380 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static seedu.address.testutil.Assert.assertThrows;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.ReadOnlyAddressBook;
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.Person;
+import seedu.address.testutil.PersonBuilder;
+
+// Test scheme:
+// Assuming the model stub with person is correctly created:
+// 1. add payment to single person successfully without remarks
+// 2. add payment to single person successfully with remarks
+// 3. add payment to multiple persons successfully without remarks
+// 4. add payment to multiple persons successfully with remarks
+// 5. add payment to multiple persons with invalid index(es)
+// 6. add payment to single person with missing date
+// 7. add payment to single person with missing amount
+// 8. add payment to single person with incorrect date
+// 9. add payment to single person with incorrect amount
+// 10. add payment when the list is empty
+
+/**
+ * Unit tests for AddPaymentCommand.
+ */
+public class AddPaymentCommandTest {
+
+ // 1. Single person, no remarks
+ @Test
+ public void execute_singlePersonNoRemarks_success() throws Exception {
+ Person bob = new PersonBuilder().withName("Bob").build();
+ Model model = new ModelStubWithPerson(bob);
+
+ Amount amount = new Amount(new BigDecimal("66.66"));
+ LocalDate date = LocalDate.of(2025, 10, 10);
+
+ AddPaymentCommand command = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)), amount, date, null);
+
+ CommandResult result = command.execute(model);
+
+ Payment expectedPayment = new Payment(amount, date, null);
+ String expectedMessage = String.format(AddPaymentCommand.MESSAGE_SUCCESS_TEMPLATE, expectedPayment, "Bob");
+
+ assertEquals(expectedMessage, result.getFeedbackToUser());
+ }
+
+ // 2. Single person, with remarks
+ @Test
+ public void execute_singlePersonWithRemarks_success() throws Exception {
+ Person alice = new PersonBuilder().withName("Alice").build();
+ Model model = new ModelStubWithPerson(alice);
+
+ Amount amount = new Amount(new BigDecimal("23.50"));
+ LocalDate date = LocalDate.of(2025, 10, 9);
+ String remarks = "taxi home";
+
+ AddPaymentCommand command = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)), amount, date, remarks);
+
+ CommandResult result = command.execute(model);
+
+ Payment expectedPayment = new Payment(amount, date, remarks);
+ String expectedMessage = String.format(AddPaymentCommand.MESSAGE_SUCCESS_TEMPLATE, expectedPayment, "Alice");
+
+ assertEquals(expectedMessage, result.getFeedbackToUser());
+ }
+
+ // 3. Multiple persons, no remarks
+ @Test
+ public void execute_multiplePersonsNoRemarks_success() throws Exception {
+ Person charlie = new PersonBuilder().withName("Charlie").build();
+ Person dana = new PersonBuilder().withName("Dana").build();
+ Model model = new ModelStubWithMultiplePersons(List.of(charlie, dana));
+
+ Amount amount = new Amount(new BigDecimal("10.00"));
+ LocalDate date = LocalDate.of(2025, 10, 9);
+
+ AddPaymentCommand command = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1),
+ Index.fromOneBased(2)), amount, date, null);
+
+ CommandResult result = command.execute(model);
+
+ Payment expectedPayment = new Payment(amount, date, null);
+ String expectedMessage = String.format(AddPaymentCommand.MESSAGE_SUCCESS_TEMPLATE,
+ expectedPayment, "Charlie, Dana");
+
+ assertEquals(expectedMessage, result.getFeedbackToUser());
+ }
+
+ // 4. Multiple persons, with remarks
+ @Test
+ public void execute_multiplePersonsWithRemarks_success() throws Exception {
+ Person ethan = new PersonBuilder().withName("Ethan").build();
+ Person danton = new PersonBuilder().withName("Danton").build();
+ Model model = new ModelStubWithMultiplePersons(List.of(ethan, danton));
+
+ Amount amount = new Amount(new BigDecimal("12.75"));
+ LocalDate date = LocalDate.of(2025, 10, 9);
+ String remarks = "grab to hawker center";
+
+ AddPaymentCommand command = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1), Index.fromOneBased(2)), amount, date, remarks);
+
+ CommandResult result = command.execute(model);
+
+ Payment expectedPayment = new Payment(amount, date, remarks);
+ String expectedMessage = String.format(AddPaymentCommand.MESSAGE_SUCCESS_TEMPLATE,
+ expectedPayment, "Ethan, Danton");
+
+ assertEquals(expectedMessage, result.getFeedbackToUser());
+ }
+
+ // 5. Multiple persons, invalid index
+ @Test
+ public void execute_invalidIndex_throwsCommandException() {
+ Person ian = new PersonBuilder().withName("Ian").build();
+ Model model = new ModelStubWithPerson(ian);
+
+ Amount amount = new Amount(new BigDecimal("20.00"));
+ LocalDate date = LocalDate.of(2025, 10, 9);
+
+ AddPaymentCommand command = new AddPaymentCommand(
+ List.of(Index.fromOneBased(2)), amount, date, null);
+
+ assertThrows(CommandException.class, () -> command.execute(model));
+ }
+
+ // 6. Missing date (null)
+ @Test
+ public void constructor_nullDate_throwsNullPointerException() {
+ List indexes = List.of(Index.fromOneBased(1));
+ Amount amount = new Amount(new BigDecimal("15.00"));
+
+ assertThrows(NullPointerException.class, () -> new AddPaymentCommand(indexes, amount, null, null));
+ }
+
+ // 7. Missing amount (null)
+ @Test
+ public void constructor_nullAmount_throwsNullPointerException() {
+ List indexes = List.of(Index.fromOneBased(1));
+
+ assertThrows(NullPointerException.class, () -> new AddPaymentCommand(indexes, null,
+ LocalDate.of(2025, 10, 9), null));
+ }
+
+ // 8. Incorrect date format
+ @Test
+ public void execute_invalidDate_throwsCommandException() {
+ Person john = new PersonBuilder().withName("John").build();
+ Model model = new ModelStubWithPerson(john);
+
+ Amount amount = new Amount(new BigDecimal("10.00"));
+ AddPaymentCommand command = new AddPaymentCommand(
+ List.of(Index.fromOneBased(2)), amount,
+ LocalDate.of(2025, 10, 9), null);
+
+ assertThrows(CommandException.class, () -> command.execute(model));
+ }
+
+ // 9. Incorrect amount (negative)
+ @Test
+ public void constructor_invalidAmount_throwsIllegalArgumentException() {
+ List indexes = List.of(Index.fromOneBased(1));
+
+ assertThrows(IllegalArgumentException.class, () -> new Amount(new BigDecimal("-5.00")));
+ }
+
+ // 10. Empty model (no persons)
+ @Test
+ public void execute_emptyModel_throwsCommandException() {
+ Model model = new EmptyModelStub();
+
+ Amount amount = new Amount(new BigDecimal("10.00"));
+ LocalDate date = LocalDate.of(2025, 10, 9);
+
+ AddPaymentCommand command = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)), amount, date, null);
+
+ assertThrows(CommandException.class, () -> command.execute(model));
+ }
+
+ // 11. equals() method tests
+ @Test
+ public void equals_sameValues_returnsTrue() {
+ List indexes = List.of(Index.fromOneBased(1));
+ Amount amount = new Amount(new BigDecimal("10.00"));
+ LocalDate date = LocalDate.of(2025, 10, 10);
+
+ AddPaymentCommand command1 = new AddPaymentCommand(indexes, amount, date, "remark");
+ AddPaymentCommand command2 = new AddPaymentCommand(indexes, amount, date, "remark");
+
+ assertEquals(true, command1.equals(command2));
+ }
+
+ @Test
+ public void equals_differentIndexes_returnsFalse() {
+ AddPaymentCommand cmd1 = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)),
+ new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2025, 10, 10),
+ null);
+ AddPaymentCommand cmd2 = new AddPaymentCommand(
+ List.of(Index.fromOneBased(2)),
+ new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2025, 10, 10),
+ null);
+ assertEquals(false, cmd1.equals(cmd2));
+ }
+
+ @Test
+ public void equals_differentAmount_returnsFalse() {
+ AddPaymentCommand cmd1 = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)),
+ new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2025, 10, 10),
+ null);
+ AddPaymentCommand cmd2 = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)),
+ new Amount(new BigDecimal("20.00")),
+ LocalDate.of(2025, 10, 10),
+ null);
+ assertEquals(false, cmd1.equals(cmd2));
+ }
+
+ @Test
+ public void equals_differentDate_returnsFalse() {
+ AddPaymentCommand cmd1 = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)),
+ new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2025, 10, 10),
+ null);
+ AddPaymentCommand cmd2 = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)),
+ new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2026, 10, 10),
+ null);
+ assertEquals(false, cmd1.equals(cmd2));
+ }
+
+ @Test
+ public void equals_differentRemarks_returnsFalse() {
+ AddPaymentCommand cmd1 = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)),
+ new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2025, 10, 10),
+ "A");
+ AddPaymentCommand cmd2 = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)),
+ new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2025, 10, 10),
+ "B");
+ assertEquals(false, cmd1.equals(cmd2));
+ }
+
+ // Model Stubs
+ private static class ModelStub implements Model {
+ @Override public ObservableList getFilteredPersonList() {
+ throw new AssertionError();
+ }
+ @Override public void setPerson(Person target, Person editedPerson) {
+ throw new AssertionError();
+ }
+ @Override public void addPerson(Person person) {
+ throw new AssertionError();
+ }
+ @Override public boolean hasPerson(Person person) {
+ throw new AssertionError();
+ }
+ @Override public void updateFilteredPersonList(java.util.function.Predicate predicate) {
+ throw new AssertionError();
+ }
+ @Override public void setUserPrefs(seedu.address.model.ReadOnlyUserPrefs userPrefs) {
+ throw new AssertionError();
+ }
+ @Override public seedu.address.model.ReadOnlyUserPrefs getUserPrefs() {
+ throw new AssertionError();
+ }
+ @Override public void setGuiSettings(seedu.address.commons.core.GuiSettings guiSettings) {
+ throw new AssertionError();
+ }
+ @Override public seedu.address.commons.core.GuiSettings getGuiSettings() {
+ throw new AssertionError();
+ }
+ @Override public void setAddressBookFilePath(java.nio.file.Path addressBookFilePath) {
+ throw new AssertionError();
+ }
+ @Override public java.nio.file.Path getAddressBookFilePath() {
+ throw new AssertionError();
+ }
+ @Override public void setAddressBook(seedu.address.model.ReadOnlyAddressBook newData) {
+ throw new AssertionError();
+ }
+ @Override public seedu.address.model.ReadOnlyAddressBook getAddressBook() {
+ throw new AssertionError();
+ }
+ @Override
+ public boolean canUndo() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public void saveSnapshot() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public void undo() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public void clearRedo() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public boolean canRedo() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public void redo() {
+ throw new AssertionError("This method should not be called.");
+ }
+
+ @Override
+ public void pushUndoSnapshot(ReadOnlyAddressBook snapshot) {
+ throw new AssertionError();
+ }
+ }
+
+ private static class ModelStubWithPerson extends ModelStub {
+ private Person person;
+ ModelStubWithPerson(Person person) {
+ this.person = person;
+ }
+ @Override public ObservableList getFilteredPersonList() {
+ return FXCollections.observableArrayList(person);
+ }
+ @Override public void setPerson(Person target, Person editedPerson) {
+ this.person = editedPerson;
+ }
+ }
+
+ private static class ModelStubWithMultiplePersons extends ModelStub {
+ private final List persons;
+ ModelStubWithMultiplePersons(List persons) {
+ this.persons = new ArrayList<>(persons);
+ }
+ @Override public ObservableList getFilteredPersonList() {
+ return FXCollections.observableArrayList(persons);
+ }
+ @Override public void setPerson(Person target, Person editedPerson) {
+ int index = persons.indexOf(target);
+ persons.set(index, editedPerson);
+ }
+ }
+
+ private static class EmptyModelStub extends ModelStub {
+ @Override public ObservableList getFilteredPersonList() {
+ return FXCollections.observableArrayList(); // empty list
+ }
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/ArchiveCommandIntegrationTest.java b/src/test/java/seedu/address/logic/commands/ArchiveCommandIntegrationTest.java
new file mode 100644
index 00000000000..f6b442f48ac
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/ArchiveCommandIntegrationTest.java
@@ -0,0 +1,104 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import seedu.address.logic.Logic;
+import seedu.address.logic.LogicManager;
+import seedu.address.model.AddressBook;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.ReadOnlyAddressBook;
+import seedu.address.model.UserPrefs;
+import seedu.address.model.person.Person;
+import seedu.address.storage.JsonAddressBookStorage;
+import seedu.address.storage.JsonUserPrefsStorage;
+import seedu.address.storage.StorageManager;
+
+public class ArchiveCommandIntegrationTest {
+
+ @TempDir
+ public Path tempDir;
+
+ private Model model;
+ private ReadOnlyAddressBook originalBook; // snapshot of typical book for name lookups
+ private Logic logic;
+
+ @BeforeEach
+ public void setUp() {
+ // Model with typical persons
+ model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+ originalBook = new AddressBook(model.getAddressBook());
+
+ JsonAddressBookStorage addressBookStorage =
+ new JsonAddressBookStorage(tempDir.resolve("addressBook.json"));
+ JsonUserPrefsStorage userPrefsStorage =
+ new JsonUserPrefsStorage(tempDir.resolve("userPrefs.json"));
+ StorageManager storage = new StorageManager(addressBookStorage, userPrefsStorage);
+
+ logic = new LogicManager(model, storage);
+ }
+
+ @Test
+ public void archive_integration() throws Exception {
+ // Ensure we're in active view to begin with
+ logic.execute("list");
+
+ // Grab the names of the first two persons BEFORE archiving
+ Person first = logic.getFilteredPersonList().get(0);
+ Person second = logic.getFilteredPersonList().get(1);
+ String firstName = first.getName().toString();
+ String secondName = second.getName().toString();
+
+ // 1) archive two people
+ String archiveCmd = "archive 1,2";
+ String archiveMsg = logic.execute(archiveCmd).getFeedbackToUser();
+ // Message: "Archived: NAME1, NAME2"
+ String expectedArchiveMsg = String.format(ArchiveCommand.MESSAGE_SUCCESS, firstName + ", " + secondName);
+ assertEquals(expectedArchiveMsg, archiveMsg);
+
+ // After archiving, the active list should not include those persons
+ List activeNames = logic.getFilteredPersonList().stream()
+ .map(p -> p.getName().toString()).collect(Collectors.toList());
+ assertTrue(!activeNames.contains(firstName) && !activeNames.contains(secondName));
+
+ // 2) listarchived should show exactly those two and both must be archived
+ String listArchivedMsg = logic.execute("listarchived").getFeedbackToUser();
+ assertEquals(ListArchivedCommand.MESSAGE_SUCCESS, listArchivedMsg);
+
+ List archivedView = logic.getFilteredPersonList();
+ assertEquals(2, archivedView.size());
+ assertTrue(archivedView.stream().allMatch(Person::isArchived));
+
+ List archivedNames = archivedView.stream()
+ .map(p -> p.getName().toString()).collect(Collectors.toList());
+ assertTrue(archivedNames.contains(firstName) && archivedNames.contains(secondName));
+
+ // 3) unarchive the first one in the archived view (index 1 refers to first archived)
+ String unarchiveMsg = logic.execute("unarchive 1").getFeedbackToUser();
+ String expectedUnarchiveMsg = String.format(UnarchiveCommand.MESSAGE_SUCCESS, firstName);
+ assertEquals(expectedUnarchiveMsg, unarchiveMsg);
+
+ // Re-open archived view and check only the second remains archived.
+ logic.execute("listarchived");
+ List remainderArchived = logic.getFilteredPersonList();
+ assertEquals(1, remainderArchived.size());
+ assertEquals(secondName, remainderArchived.get(0).getName().toString());
+ assertTrue(remainderArchived.get(0).isArchived());
+
+ // And the unarchived first should now appear in active list
+ logic.execute("list"); // ensure active view
+ List activeNamesAfter = logic.getFilteredPersonList().stream()
+ .map(p -> p.getName().toString()).collect(Collectors.toList());
+ assertTrue(activeNamesAfter.contains(firstName));
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/ArchiveCommandTest.java b/src/test/java/seedu/address/logic/commands/ArchiveCommandTest.java
new file mode 100644
index 00000000000..18efdd2d9cb
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/ArchiveCommandTest.java
@@ -0,0 +1,114 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static seedu.address.logic.commands.CommandTestUtil.assertCommandFailure;
+import static seedu.address.logic.commands.CommandTestUtil.assertCommandSuccess;
+import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON;
+import static seedu.address.testutil.TypicalIndexes.INDEX_SECOND_PERSON;
+import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.model.AddressBook;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.UserPrefs;
+import seedu.address.model.person.Person;
+
+/** Tests for ArchiveCommand (supports multiple indices). */
+public class ArchiveCommandTest {
+
+ private Model model;
+
+ @BeforeEach
+ public void setUp() {
+ model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+ }
+
+ @Test
+ public void execute_singleIndex_success() {
+ Person toArchive = model.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
+ ArchiveCommand command = new ArchiveCommand(List.of(INDEX_FIRST_PERSON));
+
+ Model expectedModel = new ModelManager(new AddressBook(model.getAddressBook()), new UserPrefs());
+ Person archived = toArchive.withArchived(true);
+ expectedModel.setPerson(toArchive, archived);
+ expectedModel.updateFilteredPersonList(Model.PREDICATE_SHOW_ACTIVE_PERSONS);
+
+ // default list should show only active; the command itself sets active predicate
+ String expectedMessage = String.format(ArchiveCommand.MESSAGE_SUCCESS, archived.getName());
+
+ assertCommandSuccess(command, model, expectedMessage, expectedModel);
+ // after success, archived person should not be in active filtered list
+ assertTrue(model.getFilteredPersonList().stream().noneMatch(p -> p.isArchived()));
+ }
+
+ @Test
+ public void execute_multipleIndices_success() {
+ Person p1 = model.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
+ Person p2 = model.getFilteredPersonList().get(INDEX_SECOND_PERSON.getZeroBased());
+
+ ArchiveCommand command = new ArchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+
+ Model expectedModel = new ModelManager(new AddressBook(model.getAddressBook()), new UserPrefs());
+ Person a1 = p1.withArchived(true);
+ Person a2 = p2.withArchived(true);
+ expectedModel.setPerson(p1, a1);
+ expectedModel.setPerson(p2, a2);
+ expectedModel.updateFilteredPersonList(Model.PREDICATE_SHOW_ACTIVE_PERSONS);
+
+ String expectedMessage = String.format(
+ ArchiveCommand.MESSAGE_SUCCESS,
+ a1.getName() + ", " + a2.getName()
+ );
+
+ assertCommandSuccess(command, model, expectedMessage, expectedModel);
+ }
+
+ @Test
+ public void execute_indexOutOfBounds_throwsCommandException() {
+ int outOfBounds = model.getFilteredPersonList().size() + 1;
+ ArchiveCommand command = new ArchiveCommand(List.of(Index.fromOneBased(outOfBounds)));
+
+ assertCommandFailure(command, model,
+ seedu.address.commons.core.Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+
+ @Test
+ public void execute_alreadyArchived_throwsCommandException() {
+ // archive first
+ Person p = model.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
+ model.setPerson(p, p.withArchived(true));
+
+ // show archived so index 1 refers to that same person
+ model.updateFilteredPersonList(seedu.address.model.Model.PREDICATE_SHOW_ARCHIVED_PERSONS);
+
+ ArchiveCommand command = new ArchiveCommand(List.of(INDEX_FIRST_PERSON));
+
+ assertCommandFailure(command, model,
+ String.format(ArchiveCommand.MESSAGE_ALREADY_ARCHIVED, p.getName()));
+ }
+
+ @Test
+ public void equals() {
+ ArchiveCommand c1 = new ArchiveCommand(List.of(INDEX_FIRST_PERSON));
+ ArchiveCommand c2 = new ArchiveCommand(List.of(INDEX_FIRST_PERSON));
+ ArchiveCommand c3 = new ArchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+
+ // same values returns true
+ assertTrue(c1.equals(c2));
+ // different targets -> false
+ assertTrue(!c1.equals(c3));
+ // same object returns true
+ assertTrue(c1.equals(c1));
+ // null / different type returns false
+ assertTrue(!c1.equals(null));
+ assertTrue(!c1.equals(new ListCommand()));
+ }
+}
+
diff --git a/src/test/java/seedu/address/logic/commands/ClearCommandTest.java b/src/test/java/seedu/address/logic/commands/ClearCommandTest.java
deleted file mode 100644
index 80d9110c03a..00000000000
--- a/src/test/java/seedu/address/logic/commands/ClearCommandTest.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package seedu.address.logic.commands;
-
-import static seedu.address.logic.commands.CommandTestUtil.assertCommandSuccess;
-import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
-
-import org.junit.jupiter.api.Test;
-
-import seedu.address.model.AddressBook;
-import seedu.address.model.Model;
-import seedu.address.model.ModelManager;
-import seedu.address.model.UserPrefs;
-
-public class ClearCommandTest {
-
- @Test
- public void execute_emptyAddressBook_success() {
- Model model = new ModelManager();
- Model expectedModel = new ModelManager();
-
- assertCommandSuccess(new ClearCommand(), model, ClearCommand.MESSAGE_SUCCESS, expectedModel);
- }
-
- @Test
- public void execute_nonEmptyAddressBook_success() {
- Model model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
- Model expectedModel = new ModelManager(getTypicalAddressBook(), new UserPrefs());
- expectedModel.setAddressBook(new AddressBook());
-
- assertCommandSuccess(new ClearCommand(), model, ClearCommand.MESSAGE_SUCCESS, expectedModel);
- }
-
-}
diff --git a/src/test/java/seedu/address/logic/commands/CommandTestUtil.java b/src/test/java/seedu/address/logic/commands/CommandTestUtil.java
index 643a1d08069..0bab9c6ba1d 100644
--- a/src/test/java/seedu/address/logic/commands/CommandTestUtil.java
+++ b/src/test/java/seedu/address/logic/commands/CommandTestUtil.java
@@ -2,8 +2,8 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static seedu.address.logic.parser.CliSyntax.PREFIX_ADDRESS;
import static seedu.address.logic.parser.CliSyntax.PREFIX_EMAIL;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_MATRICULATIONNUMBER;
import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME;
import static seedu.address.logic.parser.CliSyntax.PREFIX_PHONE;
import static seedu.address.logic.parser.CliSyntax.PREFIX_TAG;
@@ -32,8 +32,8 @@ public class CommandTestUtil {
public static final String VALID_PHONE_BOB = "22222222";
public static final String VALID_EMAIL_AMY = "amy@example.com";
public static final String VALID_EMAIL_BOB = "bob@example.com";
- public static final String VALID_ADDRESS_AMY = "Block 312, Amy Street 1";
- public static final String VALID_ADDRESS_BOB = "Block 123, Bobby Street 3";
+ public static final String VALID_MATRICULATIONNUM_AMY = "A1111111A";
+ public static final String VALID_MATRICULATIONNUM_BOB = "A1111111B";
public static final String VALID_TAG_HUSBAND = "husband";
public static final String VALID_TAG_FRIEND = "friend";
@@ -43,15 +43,20 @@ public class CommandTestUtil {
public static final String PHONE_DESC_BOB = " " + PREFIX_PHONE + VALID_PHONE_BOB;
public static final String EMAIL_DESC_AMY = " " + PREFIX_EMAIL + VALID_EMAIL_AMY;
public static final String EMAIL_DESC_BOB = " " + PREFIX_EMAIL + VALID_EMAIL_BOB;
- public static final String ADDRESS_DESC_AMY = " " + PREFIX_ADDRESS + VALID_ADDRESS_AMY;
- public static final String ADDRESS_DESC_BOB = " " + PREFIX_ADDRESS + VALID_ADDRESS_BOB;
+ public static final String MATRICULATIONNUM_DESC_AMY = " "
+ + PREFIX_MATRICULATIONNUMBER
+ + VALID_MATRICULATIONNUM_AMY;
+ public static final String MATRICULATIONNUM_DESC_BOB = " "
+ + PREFIX_MATRICULATIONNUMBER
+ + VALID_MATRICULATIONNUM_BOB;
public static final String TAG_DESC_FRIEND = " " + PREFIX_TAG + VALID_TAG_FRIEND;
public static final String TAG_DESC_HUSBAND = " " + PREFIX_TAG + VALID_TAG_HUSBAND;
public static final String INVALID_NAME_DESC = " " + PREFIX_NAME + "James&"; // '&' not allowed in names
public static final String INVALID_PHONE_DESC = " " + PREFIX_PHONE + "911a"; // 'a' not allowed in phones
public static final String INVALID_EMAIL_DESC = " " + PREFIX_EMAIL + "bob!yahoo"; // missing '@' symbol
- public static final String INVALID_ADDRESS_DESC = " " + PREFIX_ADDRESS; // empty string not allowed for addresses
+ public static final String INVALID_MATRICULATIONNUM_DESC = " "
+ + PREFIX_MATRICULATIONNUMBER; // empty string not allowed for addresses
public static final String INVALID_TAG_DESC = " " + PREFIX_TAG + "hubby*"; // '*' not allowed in tags
public static final String PREAMBLE_WHITESPACE = "\t \r \n";
@@ -62,11 +67,11 @@ public class CommandTestUtil {
static {
DESC_AMY = new EditPersonDescriptorBuilder().withName(VALID_NAME_AMY)
- .withPhone(VALID_PHONE_AMY).withEmail(VALID_EMAIL_AMY).withAddress(VALID_ADDRESS_AMY)
- .withTags(VALID_TAG_FRIEND).build();
+ .withPhone(VALID_PHONE_AMY).withEmail(VALID_EMAIL_AMY).withMatriculationNumber(VALID_MATRICULATIONNUM_AMY)
+ .withTags(VALID_TAG_FRIEND).build();
DESC_BOB = new EditPersonDescriptorBuilder().withName(VALID_NAME_BOB)
- .withPhone(VALID_PHONE_BOB).withEmail(VALID_EMAIL_BOB).withAddress(VALID_ADDRESS_BOB)
- .withTags(VALID_TAG_HUSBAND, VALID_TAG_FRIEND).build();
+ .withPhone(VALID_PHONE_BOB).withEmail(VALID_EMAIL_BOB).withMatriculationNumber(VALID_MATRICULATIONNUM_BOB)
+ .withTags(VALID_TAG_HUSBAND, VALID_TAG_FRIEND).build();
}
/**
@@ -74,8 +79,9 @@ public class CommandTestUtil {
* - the returned {@link CommandResult} matches {@code expectedCommandResult}
* - the {@code actualModel} matches {@code expectedModel}
*/
- public static void assertCommandSuccess(Command command, Model actualModel, CommandResult expectedCommandResult,
- Model expectedModel) {
+ public static void assertCommandSuccess(Command command, Model actualModel,
+ CommandResult expectedCommandResult,
+ Model expectedModel) {
try {
CommandResult result = command.execute(actualModel);
assertEquals(expectedCommandResult, result);
@@ -90,7 +96,7 @@ public static void assertCommandSuccess(Command command, Model actualModel, Comm
* that takes a string {@code expectedMessage}.
*/
public static void assertCommandSuccess(Command command, Model actualModel, String expectedMessage,
- Model expectedModel) {
+ Model expectedModel) {
CommandResult expectedCommandResult = new CommandResult(expectedMessage);
assertCommandSuccess(command, actualModel, expectedCommandResult, expectedModel);
}
@@ -111,6 +117,7 @@ public static void assertCommandFailure(Command command, Model actualModel, Stri
assertEquals(expectedAddressBook, actualModel.getAddressBook());
assertEquals(expectedFilteredList, actualModel.getFilteredPersonList());
}
+
/**
* Updates {@code model}'s filtered list to show only the person at the given {@code targetIndex} in the
* {@code model}'s address book.
diff --git a/src/test/java/seedu/address/logic/commands/DeleteCommandTest.java b/src/test/java/seedu/address/logic/commands/DeleteCommandTest.java
deleted file mode 100644
index b6f332eabca..00000000000
--- a/src/test/java/seedu/address/logic/commands/DeleteCommandTest.java
+++ /dev/null
@@ -1,120 +0,0 @@
-package seedu.address.logic.commands;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static seedu.address.logic.commands.CommandTestUtil.assertCommandFailure;
-import static seedu.address.logic.commands.CommandTestUtil.assertCommandSuccess;
-import static seedu.address.logic.commands.CommandTestUtil.showPersonAtIndex;
-import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON;
-import static seedu.address.testutil.TypicalIndexes.INDEX_SECOND_PERSON;
-import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
-
-import org.junit.jupiter.api.Test;
-
-import seedu.address.commons.core.index.Index;
-import seedu.address.logic.Messages;
-import seedu.address.model.Model;
-import seedu.address.model.ModelManager;
-import seedu.address.model.UserPrefs;
-import seedu.address.model.person.Person;
-
-/**
- * Contains integration tests (interaction with the Model) and unit tests for
- * {@code DeleteCommand}.
- */
-public class DeleteCommandTest {
-
- private Model model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
-
- @Test
- public void execute_validIndexUnfilteredList_success() {
- Person personToDelete = model.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
- DeleteCommand deleteCommand = new DeleteCommand(INDEX_FIRST_PERSON);
-
- String expectedMessage = String.format(DeleteCommand.MESSAGE_DELETE_PERSON_SUCCESS,
- Messages.format(personToDelete));
-
- ModelManager expectedModel = new ModelManager(model.getAddressBook(), new UserPrefs());
- expectedModel.deletePerson(personToDelete);
-
- assertCommandSuccess(deleteCommand, model, expectedMessage, expectedModel);
- }
-
- @Test
- public void execute_invalidIndexUnfilteredList_throwsCommandException() {
- Index outOfBoundIndex = Index.fromOneBased(model.getFilteredPersonList().size() + 1);
- DeleteCommand deleteCommand = new DeleteCommand(outOfBoundIndex);
-
- assertCommandFailure(deleteCommand, model, Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
- }
-
- @Test
- public void execute_validIndexFilteredList_success() {
- showPersonAtIndex(model, INDEX_FIRST_PERSON);
-
- Person personToDelete = model.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
- DeleteCommand deleteCommand = new DeleteCommand(INDEX_FIRST_PERSON);
-
- String expectedMessage = String.format(DeleteCommand.MESSAGE_DELETE_PERSON_SUCCESS,
- Messages.format(personToDelete));
-
- Model expectedModel = new ModelManager(model.getAddressBook(), new UserPrefs());
- expectedModel.deletePerson(personToDelete);
- showNoPerson(expectedModel);
-
- assertCommandSuccess(deleteCommand, model, expectedMessage, expectedModel);
- }
-
- @Test
- public void execute_invalidIndexFilteredList_throwsCommandException() {
- showPersonAtIndex(model, INDEX_FIRST_PERSON);
-
- Index outOfBoundIndex = INDEX_SECOND_PERSON;
- // ensures that outOfBoundIndex is still in bounds of address book list
- assertTrue(outOfBoundIndex.getZeroBased() < model.getAddressBook().getPersonList().size());
-
- DeleteCommand deleteCommand = new DeleteCommand(outOfBoundIndex);
-
- assertCommandFailure(deleteCommand, model, Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
- }
-
- @Test
- public void equals() {
- DeleteCommand deleteFirstCommand = new DeleteCommand(INDEX_FIRST_PERSON);
- DeleteCommand deleteSecondCommand = new DeleteCommand(INDEX_SECOND_PERSON);
-
- // same object -> returns true
- assertTrue(deleteFirstCommand.equals(deleteFirstCommand));
-
- // same values -> returns true
- DeleteCommand deleteFirstCommandCopy = new DeleteCommand(INDEX_FIRST_PERSON);
- assertTrue(deleteFirstCommand.equals(deleteFirstCommandCopy));
-
- // different types -> returns false
- assertFalse(deleteFirstCommand.equals(1));
-
- // null -> returns false
- assertFalse(deleteFirstCommand.equals(null));
-
- // different person -> returns false
- assertFalse(deleteFirstCommand.equals(deleteSecondCommand));
- }
-
- @Test
- public void toStringMethod() {
- Index targetIndex = Index.fromOneBased(1);
- DeleteCommand deleteCommand = new DeleteCommand(targetIndex);
- String expected = DeleteCommand.class.getCanonicalName() + "{targetIndex=" + targetIndex + "}";
- assertEquals(expected, deleteCommand.toString());
- }
-
- /**
- * Updates {@code model}'s filtered list to show no one.
- */
- private void showNoPerson(Model model) {
- model.updateFilteredPersonList(p -> false);
-
- assertTrue(model.getFilteredPersonList().isEmpty());
- }
-}
diff --git a/src/test/java/seedu/address/logic/commands/DeletePaymentCommandIntegrationTest.java b/src/test/java/seedu/address/logic/commands/DeletePaymentCommandIntegrationTest.java
new file mode 100644
index 00000000000..90be60b80fe
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/DeletePaymentCommandIntegrationTest.java
@@ -0,0 +1,68 @@
+package seedu.address.logic.commands;
+
+import static seedu.address.logic.commands.CommandTestUtil.assertCommandFailure;
+import static seedu.address.logic.commands.CommandTestUtil.assertCommandSuccess;
+import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.Messages;
+import seedu.address.commons.core.index.Index;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.UserPrefs;
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.Person;
+
+public class DeletePaymentCommandIntegrationTest {
+
+ private Model model;
+
+ @BeforeEach
+ public void setUp() {
+ model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+ }
+
+ @Test
+ public void execute_deleteSinglePayment_success() throws Exception {
+ Person targetPerson = model.getFilteredPersonList().get(0);
+ Payment payment = new Payment(new Amount(new BigDecimal("50.00")), LocalDate.of(2025, 1, 1));
+ Person withPayment = targetPerson.withAddedPayment(payment);
+ model.setPerson(targetPerson, withPayment);
+
+ DeletePaymentCommand command = new DeletePaymentCommand(
+ Index.fromOneBased(1), List.of(Index.fromOneBased(1)));
+
+ Person updated = withPayment.withRemovedPayment(payment);
+ Model expectedModel = new ModelManager(model.getAddressBook(), new UserPrefs());
+ expectedModel.setPerson(withPayment, updated);
+
+ String expectedMessage = String.format(DeletePaymentCommand.MESSAGE_SUCCESS, "1", updated.getName());
+ assertCommandSuccess(command, model, expectedMessage, expectedModel);
+ }
+
+ @Test
+ public void execute_invalidPaymentIndex_throwsCommandException() {
+ Person target = model.getFilteredPersonList().get(0);
+ DeletePaymentCommand command = new DeletePaymentCommand(
+ Index.fromOneBased(1), List.of(Index.fromOneBased(1)));
+
+ assertCommandFailure(command, model,
+ "This person has no payments to delete.");
+ }
+
+ @Test
+ public void execute_invalidPersonIndex_throwsCommandException() {
+ int outOfBounds = model.getFilteredPersonList().size() + 1;
+ DeletePaymentCommand command = new DeletePaymentCommand(
+ Index.fromOneBased(outOfBounds), List.of(Index.fromOneBased(1)));
+
+ assertCommandFailure(command, model, Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/DeletePaymentCommandTest.java b/src/test/java/seedu/address/logic/commands/DeletePaymentCommandTest.java
new file mode 100644
index 00000000000..31dd4e9c108
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/DeletePaymentCommandTest.java
@@ -0,0 +1,210 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static seedu.address.testutil.Assert.assertThrows;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import seedu.address.commons.core.GuiSettings;
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.ReadOnlyAddressBook;
+import seedu.address.model.ReadOnlyUserPrefs;
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.Person;
+import seedu.address.testutil.PersonBuilder;
+
+/**
+ * Unit tests for DeletePaymentCommand.
+ */
+public class DeletePaymentCommandTest {
+
+ @Test
+ public void execute_validIndex_success() throws Exception {
+ Person alice = new PersonBuilder().withName("Alice").build();
+ Payment payment = new Payment(new Amount(new BigDecimal("50.00")), LocalDate.of(2025, 1, 1));
+ alice = alice.withAddedPayment(payment);
+
+ Model model = new ModelStubWithPerson(alice);
+ DeletePaymentCommand command = new DeletePaymentCommand(
+ Index.fromOneBased(1), List.of(Index.fromOneBased(1)));
+
+ CommandResult result = command.execute(model);
+
+ assertEquals(String.format(DeletePaymentCommand.MESSAGE_SUCCESS, "1", "Alice"),
+ result.getFeedbackToUser());
+ }
+
+ @Test
+ public void execute_invalidPaymentIndex_throwsCommandException() {
+ Person bob = new PersonBuilder().withName("Bob").build(); // no payments
+ Model model = new ModelStubWithPerson(bob);
+
+ DeletePaymentCommand command = new DeletePaymentCommand(
+ Index.fromOneBased(1), List.of(Index.fromOneBased(1)));
+
+ assertThrows(CommandException.class, () -> command.execute(model));
+ }
+
+ @Test
+ public void execute_emptyFilteredList_throwsCommandException() {
+ Model model = new ModelStub() {
+ @Override public ObservableList getFilteredPersonList() {
+ return FXCollections.observableArrayList(); // empty
+ }
+ };
+
+ DeletePaymentCommand cmd = new DeletePaymentCommand(Index.fromOneBased(1),
+ List.of(Index.fromOneBased(1)));
+
+ assertThrows(CommandException.class, () -> cmd.execute(model));
+ }
+
+ @Test
+ public void execute_personIndexOutOfBounds_throwsCommandException() {
+ Person p = new PersonBuilder().withName("X").build();
+ Model model = new ModelStub() {
+ @Override public ObservableList getFilteredPersonList() {
+ return FXCollections.observableArrayList(p); // size=1
+ }
+ };
+
+ DeletePaymentCommand cmd = new DeletePaymentCommand(Index.fromOneBased(2),
+ List.of(Index.fromOneBased(1)));
+
+ assertThrows(CommandException.class, () -> cmd.execute(model));
+ }
+
+ @Test
+ public void isMutating_returnsTrue() {
+ DeletePaymentCommand cmd = new DeletePaymentCommand(Index.fromOneBased(1),
+ List.of(Index.fromOneBased(1)));
+ assertTrue(cmd.isMutating());
+ }
+
+ @Test
+ public void equals() {
+ DeletePaymentCommand c1 = new DeletePaymentCommand(
+ Index.fromOneBased(1), List.of(Index.fromOneBased(1)));
+ DeletePaymentCommand c2 = new DeletePaymentCommand(
+ Index.fromOneBased(1), List.of(Index.fromOneBased(1)));
+ DeletePaymentCommand c3 = new DeletePaymentCommand(
+ Index.fromOneBased(2), List.of(Index.fromOneBased(1)));
+
+ assertEquals(c1, c2);
+ assertEquals(false, c1.equals(c3));
+ }
+
+ // ===== Model stubs =====
+
+ private static class ModelStub implements Model {
+ @Override public void setUserPrefs(ReadOnlyUserPrefs userPrefs) {
+ throw new AssertionError();
+ }
+
+ @Override public ReadOnlyUserPrefs getUserPrefs() {
+ throw new AssertionError();
+ }
+
+ @Override public GuiSettings getGuiSettings() {
+ throw new AssertionError();
+ }
+
+ @Override public void setGuiSettings(GuiSettings guiSettings) {
+ throw new AssertionError();
+ }
+
+ @Override public java.nio.file.Path getAddressBookFilePath() {
+ throw new AssertionError();
+ }
+
+ @Override public void setAddressBookFilePath(java.nio.file.Path path) {
+ throw new AssertionError();
+ }
+
+ @Override public void setAddressBook(ReadOnlyAddressBook ab) {
+ throw new AssertionError();
+ }
+
+ @Override public ReadOnlyAddressBook getAddressBook() {
+ throw new AssertionError();
+ }
+
+ @Override public boolean hasPerson(Person person) {
+ throw new AssertionError();
+ }
+
+ @Override public void addPerson(Person person) {
+ throw new AssertionError();
+ }
+
+ @Override public void setPerson(Person target, Person editedPerson) {
+ throw new AssertionError();
+ }
+
+ @Override public ObservableList getFilteredPersonList() {
+ throw new AssertionError();
+ }
+
+ @Override public void updateFilteredPersonList(java.util.function.Predicate predicate) {
+ throw new AssertionError();
+ }
+
+ @Override public boolean canUndo() {
+ throw new AssertionError();
+ }
+
+ @Override public void saveSnapshot() {
+ throw new AssertionError();
+ }
+
+ @Override public void undo() {
+ throw new AssertionError();
+ }
+
+ @Override public void clearRedo() {
+ throw new AssertionError();
+ }
+
+ @Override public boolean canRedo() {
+ throw new AssertionError();
+ }
+
+ @Override public void redo() {
+ throw new AssertionError();
+ }
+
+ @Override
+ public void pushUndoSnapshot(ReadOnlyAddressBook snapshot) {
+ throw new AssertionError();
+ }
+
+ }
+
+ private static class ModelStubWithPerson extends ModelStub {
+ private Person person;
+
+ ModelStubWithPerson(Person person) {
+ this.person = person;
+ }
+
+ @Override
+ public ObservableList getFilteredPersonList() {
+ return FXCollections.observableArrayList(person);
+ }
+
+ @Override
+ public void setPerson(Person target, Person editedPerson) {
+ this.person = editedPerson;
+ }
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/EditCommandTest.java b/src/test/java/seedu/address/logic/commands/EditCommandTest.java
index 469dd97daa7..bb56698d538 100644
--- a/src/test/java/seedu/address/logic/commands/EditCommandTest.java
+++ b/src/test/java/seedu/address/logic/commands/EditCommandTest.java
@@ -11,6 +11,8 @@
import static seedu.address.logic.commands.CommandTestUtil.assertCommandFailure;
import static seedu.address.logic.commands.CommandTestUtil.assertCommandSuccess;
import static seedu.address.logic.commands.CommandTestUtil.showPersonAtIndex;
+import static seedu.address.model.Model.PREDICATE_SHOW_ACTIVE_PERSONS;
+import static seedu.address.model.Model.PREDICATE_SHOW_ARCHIVED_PERSONS;
import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON;
import static seedu.address.testutil.TypicalIndexes.INDEX_SECOND_PERSON;
import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
@@ -93,8 +95,17 @@ public void execute_filteredList_success() {
String expectedMessage = String.format(EditCommand.MESSAGE_EDIT_PERSON_SUCCESS, Messages.format(editedPerson));
- Model expectedModel = new ModelManager(new AddressBook(model.getAddressBook()), new UserPrefs());
- expectedModel.setPerson(model.getFilteredPersonList().get(0), editedPerson);
+ Model expectedModel;
+
+ if (editedPerson.isArchived()) {
+ expectedModel = new ModelManager(new AddressBook(model.getAddressBook()), new UserPrefs());
+ model.updateFilteredPersonList(PREDICATE_SHOW_ARCHIVED_PERSONS);
+ expectedModel.setPerson(model.getFilteredPersonList().get(0), editedPerson);
+ } else {
+ expectedModel = new ModelManager(new AddressBook(model.getAddressBook()), new UserPrefs());
+ model.updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
+ expectedModel.setPerson(model.getFilteredPersonList().get(0), editedPerson);
+ }
assertCommandSuccess(editCommand, model, expectedMessage, expectedModel);
}
@@ -161,8 +172,6 @@ public void equals() {
// null -> returns false
assertFalse(standardCommand.equals(null));
- // different types -> returns false
- assertFalse(standardCommand.equals(new ClearCommand()));
// different index -> returns false
assertFalse(standardCommand.equals(new EditCommand(INDEX_SECOND_PERSON, DESC_AMY)));
diff --git a/src/test/java/seedu/address/logic/commands/EditPaymentCommandTest.java b/src/test/java/seedu/address/logic/commands/EditPaymentCommandTest.java
new file mode 100644
index 00000000000..d4f4d08c329
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/EditPaymentCommandTest.java
@@ -0,0 +1,59 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.EditPaymentCommand.EditPaymentDescriptor;
+
+/**
+ * Unit tests for {@link EditPaymentCommand} (equality of command instances).
+ */
+public class EditPaymentCommandTest {
+
+ @Test
+ public void equals_sameValues_returnsTrue() {
+ EditPaymentDescriptor d1 = new EditPaymentDescriptor();
+ d1.setRemarks("late fee");
+
+ EditPaymentDescriptor d2 = new EditPaymentDescriptor();
+ d2.setRemarks("late fee");
+
+ EditPaymentCommand a = new EditPaymentCommand(Index.fromOneBased(1), 1, d1);
+ EditPaymentCommand b = new EditPaymentCommand(Index.fromOneBased(1), 1, d2);
+ assertEquals(a, b);
+ }
+
+ @Test
+ public void equals_differentPersonIndex_returnsFalse() {
+ EditPaymentDescriptor d = new EditPaymentDescriptor();
+ d.setRemarks("x");
+ EditPaymentCommand a = new EditPaymentCommand(Index.fromOneBased(1), 1, d);
+ EditPaymentCommand b = new EditPaymentCommand(Index.fromOneBased(2), 1, d);
+ assertNotEquals(a, b);
+ }
+
+ @Test
+ public void equals_differentPaymentIndex_returnsFalse() {
+ EditPaymentDescriptor d = new EditPaymentDescriptor();
+ d.setRemarks("x");
+ EditPaymentCommand a = new EditPaymentCommand(Index.fromOneBased(1), 1, d);
+ EditPaymentCommand b = new EditPaymentCommand(Index.fromOneBased(1), 2, d);
+ assertNotEquals(a, b);
+ }
+
+ @Test
+ public void equals_differentDescriptor_returnsFalse() {
+ EditPaymentDescriptor d1 = new EditPaymentDescriptor();
+ d1.setRemarks("x");
+
+ EditPaymentDescriptor d2 = new EditPaymentDescriptor();
+ d2.setRemarks("y");
+
+ EditPaymentCommand a = new EditPaymentCommand(Index.fromOneBased(1), 1, d1);
+ EditPaymentCommand b = new EditPaymentCommand(Index.fromOneBased(1), 1, d2);
+ assertNotEquals(a, b);
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/EditPersonDescriptorTest.java b/src/test/java/seedu/address/logic/commands/EditPersonDescriptorTest.java
index b17c1f3d5c2..17545558466 100644
--- a/src/test/java/seedu/address/logic/commands/EditPersonDescriptorTest.java
+++ b/src/test/java/seedu/address/logic/commands/EditPersonDescriptorTest.java
@@ -5,8 +5,8 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import static seedu.address.logic.commands.CommandTestUtil.DESC_AMY;
import static seedu.address.logic.commands.CommandTestUtil.DESC_BOB;
-import static seedu.address.logic.commands.CommandTestUtil.VALID_ADDRESS_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_EMAIL_BOB;
+import static seedu.address.logic.commands.CommandTestUtil.VALID_MATRICULATIONNUM_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_NAME_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_PHONE_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_TAG_HUSBAND;
@@ -37,7 +37,8 @@ public void equals() {
assertFalse(DESC_AMY.equals(DESC_BOB));
// different name -> returns false
- EditPersonDescriptor editedAmy = new EditPersonDescriptorBuilder(DESC_AMY).withName(VALID_NAME_BOB).build();
+ EditPersonDescriptor editedAmy =
+ new EditPersonDescriptorBuilder(DESC_AMY).withName(VALID_NAME_BOB).build();
assertFalse(DESC_AMY.equals(editedAmy));
// different phone -> returns false
@@ -49,7 +50,8 @@ public void equals() {
assertFalse(DESC_AMY.equals(editedAmy));
// different address -> returns false
- editedAmy = new EditPersonDescriptorBuilder(DESC_AMY).withAddress(VALID_ADDRESS_BOB).build();
+ editedAmy =
+ new EditPersonDescriptorBuilder(DESC_AMY).withMatriculationNumber(VALID_MATRICULATIONNUM_BOB).build();
assertFalse(DESC_AMY.equals(editedAmy));
// different tags -> returns false
@@ -61,11 +63,12 @@ public void equals() {
public void toStringMethod() {
EditPersonDescriptor editPersonDescriptor = new EditPersonDescriptor();
String expected = EditPersonDescriptor.class.getCanonicalName() + "{name="
- + editPersonDescriptor.getName().orElse(null) + ", phone="
- + editPersonDescriptor.getPhone().orElse(null) + ", email="
- + editPersonDescriptor.getEmail().orElse(null) + ", address="
- + editPersonDescriptor.getAddress().orElse(null) + ", tags="
- + editPersonDescriptor.getTags().orElse(null) + "}";
+ + editPersonDescriptor.getName().orElse(null) + ", phone="
+ + editPersonDescriptor.getPhone().orElse(null) + ", email="
+ + editPersonDescriptor.getEmail().orElse(null) + ", matriculation number="
+ + editPersonDescriptor.getMatriculationNumber().orElse(null) + ", tags="
+ + editPersonDescriptor.getTags().orElse(null) + "}";
assertEquals(expected, editPersonDescriptor.toString());
}
+
}
diff --git a/src/test/java/seedu/address/logic/commands/FindCommandIntegrationTest.java b/src/test/java/seedu/address/logic/commands/FindCommandIntegrationTest.java
new file mode 100644
index 00000000000..3dcd8a7f4fc
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/FindCommandIntegrationTest.java
@@ -0,0 +1,169 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.Messages;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.UserPrefs;
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.NameContainsKeywordsPredicate;
+import seedu.address.model.person.Person;
+
+/**
+ * Integration tests for {@code FindCommand} interacting with payments in the model.
+ *
+ * Test scheme
+ * 1. Persons with payments remain discoverable via FindCommand.
+ * 2. Adding or deleting payments does not affect the find filtering logic.
+ * 3. The filtered list reflects the expected persons after both AddPayment and Find commands.
+ * 4. Find person - add payment - view payment workflow
+ */
+public class FindCommandIntegrationTest {
+
+ private Model model;
+ private Model expectedModel;
+
+ @BeforeEach
+ public void setUp() {
+ model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+ expectedModel = new ModelManager(model.getAddressBook(), new UserPrefs());
+ }
+
+ // 1. Persons with payments remain discoverable via find command
+ @Test
+ public void execute_findAfterAddingPayments_success() throws Exception {
+ // get a person from the model and add a payment
+ Person target = model.getFilteredPersonList().get(0);
+ Payment payment = new Payment(new Amount(new BigDecimal("50.00")),
+ LocalDate.of(2025, 10, 20), "grabfood");
+ Person updated = target.withAddedPayment(payment);
+
+ model.setPerson(target, updated);
+ expectedModel.setPerson(target, updated);
+
+ // run find command by name keyword that matches this person
+ String keyword = updated.getName().fullName.split(" ")[0];
+ NameContainsKeywordsPredicate predicate = new NameContainsKeywordsPredicate(List.of(keyword));
+ FindCommand command = new FindCommand(predicate);
+
+ expectedModel.updateFilteredPersonList(
+ Model.PREDICATE_SHOW_ACTIVE_PERSONS.and(predicate));
+
+ CommandResult result = command.execute(model);
+
+ assertEquals(String.format(Messages.MESSAGE_PERSONS_LISTED_OVERVIEW,
+ expectedModel.getFilteredPersonList().size()), result.getFeedbackToUser());
+ assertEquals(expectedModel.getFilteredPersonList(), model.getFilteredPersonList());
+ }
+
+ // 2. Adding or deleting payments does not affect the find filtering logic.
+ @Test
+ public void execute_findAfterDeletingPayments_success() throws Exception {
+ // add and then remove a payment, find still works
+ Person target = model.getFilteredPersonList().get(0);
+ Payment payment = new Payment(new Amount(new BigDecimal("30.00")),
+ LocalDate.of(2025, 10, 10), "shirt");
+ Person withPayment = target.withAddedPayment(payment);
+ model.setPerson(target, withPayment);
+
+ // delete the payment
+ Person noPayment = withPayment.withRemovedPayment(payment);
+ model.setPerson(withPayment, noPayment);
+ expectedModel.setPerson(target, noPayment);
+
+ // find command should still locate the person
+ String keyword = target.getName().fullName.split(" ")[0];
+ NameContainsKeywordsPredicate predicate = new NameContainsKeywordsPredicate(List.of(keyword));
+ FindCommand command = new FindCommand(predicate);
+ expectedModel.updateFilteredPersonList(Model.PREDICATE_SHOW_ACTIVE_PERSONS.and(predicate));
+
+ CommandResult result = command.execute(model);
+
+ assertEquals(String.format(Messages.MESSAGE_PERSONS_LISTED_OVERVIEW,
+ expectedModel.getFilteredPersonList().size()), result.getFeedbackToUser());
+ assertEquals(expectedModel.getFilteredPersonList(), model.getFilteredPersonList());
+ }
+
+ // 3. The filtered list reflects the expected persons after both AddPayment and Find commands.
+ @Test
+ public void execute_findAfterMultiplePersonsHavePayments_success() throws Exception {
+ // add payments to multiple people, ensure find still filters correctly
+ Person p1 = model.getFilteredPersonList().get(0);
+ Person p2 = model.getFilteredPersonList().get(1);
+
+ Payment payment = new Payment(new Amount(new BigDecimal("99.99")),
+ LocalDate.of(2025, 5, 5), "booking fee");
+ model.setPerson(p1, p1.withAddedPayment(payment));
+ model.setPerson(p2, p2.withAddedPayment(payment));
+
+ expectedModel.setPerson(p1, p1.withAddedPayment(payment));
+ expectedModel.setPerson(p2, p2.withAddedPayment(payment));
+
+ // find persons with a shared keyword
+ String keyword = p1.getName().fullName.split(" ")[0];
+ NameContainsKeywordsPredicate predicate = new NameContainsKeywordsPredicate(List.of(keyword));
+ FindCommand command = new FindCommand(predicate);
+
+ expectedModel.updateFilteredPersonList(Model.PREDICATE_SHOW_ACTIVE_PERSONS.and(predicate));
+
+ CommandResult result = command.execute(model);
+ assertEquals(String.format(Messages.MESSAGE_PERSONS_LISTED_OVERVIEW,
+ expectedModel.getFilteredPersonList().size()), result.getFeedbackToUser());
+ assertEquals(expectedModel.getFilteredPersonList(), model.getFilteredPersonList());
+ }
+
+ // 4. Find person - add payment - view payment workflow
+ @Test
+ public void execute_findAddViewPaymentsFlow_success() throws Exception {
+ // Step 1: find person by keyword
+ Person target = model.getFilteredPersonList().get(0);
+ String keyword = target.getName().fullName.split(" ")[0]; // first name
+ NameContainsKeywordsPredicate predicate = new NameContainsKeywordsPredicate(List.of(keyword));
+
+ FindCommand findCommand = new FindCommand(predicate);
+ findCommand.execute(model); // filters list
+ assertEquals(1, model.getFilteredPersonList().size());
+ assertEquals(target, model.getFilteredPersonList().get(0));
+
+ // Step 2: add payment to that person
+ Amount amount = new Amount(new BigDecimal("25.00"));
+ LocalDate date = LocalDate.of(2025, 10, 21);
+ String remarks = "membership fee";
+
+ AddPaymentCommand addPaymentCommand = new AddPaymentCommand(
+ List.of(Index.fromOneBased(1)), amount, date, remarks);
+
+ CommandResult addResult = addPaymentCommand.execute(model);
+ Person updated = model.getFilteredPersonList().get(0);
+
+ Payment expectedPayment = new Payment(amount, date, remarks);
+ String expectedAddMessage = String.format(AddPaymentCommand.MESSAGE_SUCCESS_TEMPLATE,
+ expectedPayment, target.getName());
+ assertEquals(expectedAddMessage, addResult.getFeedbackToUser());
+ assertEquals(1, updated.getPayments().size());
+
+ // Step 3: view payments for that person
+ ViewPaymentCommand viewCommand = ViewPaymentCommand.forIndex(Index.fromOneBased(1));
+ CommandResult viewResult = viewCommand.execute(model);
+
+ String expectedHeader = String.format("Payments for %s (1). Total: $25.00", updated.getName());
+ String expectedBody = String.format("%s | $%s | %s",
+ date, amount, remarks);
+
+ // Verify view output includes all key elements
+ String actualMessage = viewResult.getFeedbackToUser();
+ assertEquals(true, actualMessage.contains(expectedHeader));
+ assertEquals(true, actualMessage.contains(expectedBody));
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/FindPaymentCommandIntegrationTest.java b/src/test/java/seedu/address/logic/commands/FindPaymentCommandIntegrationTest.java
new file mode 100644
index 00000000000..9c5b3ab9e85
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/FindPaymentCommandIntegrationTest.java
@@ -0,0 +1,90 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
+import seedu.address.model.person.Name;
+import seedu.address.model.person.Person;
+import seedu.address.model.person.Phone;
+import seedu.address.model.tag.Tag;
+
+public class FindPaymentCommandIntegrationTest {
+
+ private Model model;
+
+ private Person alice;
+ private Person bob;
+
+ @BeforeEach
+ public void setUp() {
+ model = new ModelManager();
+
+ alice = new Person(
+ new Name("Alice"),
+ new Phone("12345678"),
+ new Email("alice@example.com"),
+ new MatriculationNumber("A1234567Z"),
+ Set.of()
+ );
+
+ bob = new Person(
+ new Name("Bob"),
+ new Phone("87654321"),
+ new Email("bob@example.com"),
+ new MatriculationNumber("A7654321Z"),
+ Set.of(new Tag("friend"))
+ );
+
+ model.addPerson(alice);
+ model.addPerson(bob);
+ }
+
+ @Test
+ public void execute_addPayment_successMessage() throws Exception {
+ Payment payment = new Payment(new Amount(new BigDecimal("12.34")), LocalDate.now(), "Lunch");
+
+ Person updatedAlice = alice.withAddedPayment(payment);
+ model.setPerson(alice, updatedAlice);
+
+ // Corrected message using payment fields instead of Payment.toString()
+ String expectedMessage = String.format("Added payment: $12.34 | Lunch to Alice");
+ String actualMessage = String.format("Added payment: $%s | %s to %s",
+ payment.getAmount(), payment.getRemarks(), updatedAlice.getName());
+ assertEquals(expectedMessage, actualMessage);
+
+ // Confirm payment exists in model
+ assertEquals(1, model.getFilteredPersonList().get(0).getPayments().size());
+ assertEquals(payment, model.getFilteredPersonList().get(0).getPayments().get(0));
+ }
+
+ @Test
+ public void execute_removePayment_successMessage() throws Exception {
+ Payment payment = new Payment(new Amount(new BigDecimal("50.00")), LocalDate.now(), "Dinner");
+ alice = alice.withAddedPayment(payment);
+ model.setPerson(model.getFilteredPersonList().get(0), alice);
+
+ Person updatedAlice = alice.withRemovedPayment(payment);
+ model.setPerson(alice, updatedAlice);
+
+ // Corrected message
+ String expectedMessage = String.format("Removed payment: $50.00 | Dinner from Alice");
+ String actualMessage = String.format("Removed payment: $%s | %s from %s",
+ payment.getAmount(), payment.getRemarks(), updatedAlice.getName());
+ assertEquals(expectedMessage, actualMessage);
+
+ // Payment list should be empty
+ assertEquals(0, updatedAlice.getPayments().size());
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/FindPaymentCommandTest.java b/src/test/java/seedu/address/logic/commands/FindPaymentCommandTest.java
new file mode 100644
index 00000000000..5296a47c473
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/FindPaymentCommandTest.java
@@ -0,0 +1,251 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static seedu.address.testutil.Assert.assertThrows;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+import org.junit.jupiter.api.Test;
+
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.ReadOnlyAddressBook;
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+import seedu.address.model.person.Person;
+import seedu.address.testutil.PersonBuilder;
+
+/**
+ * Unit tests for {@link FindPaymentCommand}.
+ */
+public class FindPaymentCommandTest {
+
+ // 1. Find by amount (success)
+ @Test
+ public void execute_findByAmount_success() throws Exception {
+ Payment p1 = new Payment(new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2025, 10, 10), "food");
+ Payment p2 = new Payment(new Amount(new BigDecimal("20.00")),
+ LocalDate.of(2025, 10, 11), "bus");
+ Person bob = new PersonBuilder().withName("Bob").withPayments(p1, p2).build();
+
+ Model model = new ModelStubWithPerson(bob);
+
+ Amount searchAmount = new Amount(new BigDecimal("10.00"));
+ FindPaymentCommand cmd = new FindPaymentCommand(Index.fromOneBased(1),
+ searchAmount, null, null);
+
+ CommandResult result = cmd.execute(model);
+
+ String expectedList = "- " + p1;
+ String expectedMsg = String.format(FindPaymentCommand.MESSAGE_SUCCESS, 1, bob.getName(), expectedList);
+
+ assertEquals(expectedMsg, result.getFeedbackToUser());
+ }
+
+ // 2. Find by remark (case-insensitive success)
+ @Test
+ public void execute_findByRemark_success() throws Exception {
+ Payment p1 = new Payment(new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2025, 10, 10), "CCA fee");
+ Payment p2 = new Payment(new Amount(new BigDecimal("5.00")),
+ LocalDate.of(2025, 10, 11), "donation");
+ Person danton = new PersonBuilder().withName("Danton").withPayments(p1, p2).build();
+
+ Model model = new ModelStubWithPerson(danton);
+
+ FindPaymentCommand cmd = new FindPaymentCommand(Index.fromOneBased(1),
+ null, "cca", null);
+
+ CommandResult result = cmd.execute(model);
+
+ String expectedList = "- " + p1;
+ String expectedMsg = String.format(FindPaymentCommand.MESSAGE_SUCCESS, 1, danton.getName(), expectedList);
+
+ assertEquals(expectedMsg, result.getFeedbackToUser());
+ }
+
+ // 3. Find by date (success)
+ @Test
+ public void execute_findByDate_success() throws Exception {
+ LocalDate d1 = LocalDate.of(2025, 10, 9);
+ Payment p1 = new Payment(new Amount(new BigDecimal("12.00")), d1, "lunch");
+ Payment p2 = new Payment(new Amount(new BigDecimal("9.00")),
+ LocalDate.of(2025, 10, 10), "coffee");
+ Person charlie = new PersonBuilder().withName("Charlie").withPayments(p1, p2).build();
+
+ Model model = new ModelStubWithPerson(charlie);
+
+ FindPaymentCommand cmd = new FindPaymentCommand(Index.fromOneBased(1),
+ null, null, d1);
+
+ CommandResult result = cmd.execute(model);
+
+ String expectedList = "- " + p1;
+ String expectedMsg = String.format(FindPaymentCommand.MESSAGE_SUCCESS, 1, charlie.getName(), expectedList);
+
+ assertEquals(expectedMsg, result.getFeedbackToUser());
+ }
+
+ // 4. No matching payments
+ @Test
+ public void execute_noMatch_returnsNotFoundMessage() throws Exception {
+ Payment p = new Payment(new Amount(new BigDecimal("10.00")),
+ LocalDate.of(2025, 10, 10), "bus");
+ Person dana = new PersonBuilder().withName("Dana").withPayments(p).build();
+ Model model = new ModelStubWithPerson(dana);
+
+ FindPaymentCommand cmd = new FindPaymentCommand(Index.fromOneBased(1),
+ null, "taxi", null);
+
+ CommandResult result = cmd.execute(model);
+
+ String expectedMsg = String.format(FindPaymentCommand.MESSAGE_NOT_FOUND, dana.getName(), "remark \"taxi\"");
+ assertEquals(expectedMsg, result.getFeedbackToUser());
+ }
+
+ // 5. Invalid index
+ @Test
+ public void execute_invalidIndex_throwsCommandException() {
+ Person person = new PersonBuilder().withName("Foo").build();
+ Model model = new ModelStubWithPerson(person);
+
+ FindPaymentCommand cmd = new FindPaymentCommand(Index.fromOneBased(2),
+ new Amount(new BigDecimal("5.00")), null, null);
+
+ assertThrows(CommandException.class, () -> cmd.execute(model));
+ }
+
+ // positive equality test:
+ @Test
+ public void equals_sameValues_returnsTrue() {
+ Index idx = Index.fromOneBased(1);
+ Amount amt = new Amount(new BigDecimal("5.00"));
+ LocalDate date = LocalDate.of(2025, 10, 10);
+ String remark = "bus";
+
+ FindPaymentCommand c1 = new FindPaymentCommand(idx, amt, remark, date);
+ FindPaymentCommand c2 = new FindPaymentCommand(idx, amt, remark, date);
+
+ assertEquals(c1, c2);
+ }
+
+ // negative equality tests:
+ @Test
+ public void equals_differentAmount_returnsFalse() {
+ Index idx = Index.fromOneBased(1);
+ FindPaymentCommand c1 = new FindPaymentCommand(idx,
+ new Amount(new BigDecimal("5.00")), null, null);
+ FindPaymentCommand c2 = new FindPaymentCommand(idx,
+ new Amount(new BigDecimal("10.00")), null, null);
+ assertEquals(false, c1.equals(c2));
+ }
+
+ @Test
+ public void equals_differentRemark_returnsFalse() {
+ Index idx = Index.fromOneBased(1);
+ FindPaymentCommand c1 = new FindPaymentCommand(idx, null, "a", null);
+ FindPaymentCommand c2 = new FindPaymentCommand(idx, null, "b", null);
+ assertEquals(false, c1.equals(c2));
+ }
+
+ @Test
+ public void equals_differentDate_returnsFalse() {
+ Index idx = Index.fromOneBased(1);
+ FindPaymentCommand c1 = new FindPaymentCommand(idx, null, null,
+ LocalDate.of(2025, 10, 10));
+ FindPaymentCommand c2 = new FindPaymentCommand(idx, null, null,
+ LocalDate.of(2025, 10, 11));
+ assertEquals(false, c1.equals(c2));
+ }
+
+ @Test
+ public void equals_differentIndex_returnsFalse() {
+ FindPaymentCommand c1 = new FindPaymentCommand(Index.fromOneBased(1),
+ null, "a", null);
+ FindPaymentCommand c2 = new FindPaymentCommand(Index.fromOneBased(2),
+ null, "a", null);
+ assertEquals(false, c1.equals(c2));
+ }
+
+ // ==== Model Stubs ====
+
+ private static class ModelStub implements Model {
+ @Override public ObservableList getFilteredPersonList() {
+ throw new AssertionError();
+ }
+ @Override public void setPerson(Person target, Person editedPerson) {
+ throw new AssertionError();
+ }
+ @Override public void addPerson(Person person) {
+ throw new AssertionError();
+ }
+ @Override public boolean hasPerson(Person person) {
+ throw new AssertionError();
+ }
+ @Override public void updateFilteredPersonList(java.util.function.Predicate predicate) {
+ throw new AssertionError();
+ }
+ @Override public void setUserPrefs(seedu.address.model.ReadOnlyUserPrefs userPrefs) {
+ throw new AssertionError();
+ }
+ @Override public seedu.address.model.ReadOnlyUserPrefs getUserPrefs() {
+ throw new AssertionError();
+ }
+ @Override public void setGuiSettings(seedu.address.commons.core.GuiSettings guiSettings) {
+ throw new AssertionError();
+ }
+ @Override public seedu.address.commons.core.GuiSettings getGuiSettings() {
+ throw new AssertionError();
+ }
+ @Override public void setAddressBookFilePath(java.nio.file.Path addressBookFilePath) {
+ throw new AssertionError();
+ }
+ @Override public java.nio.file.Path getAddressBookFilePath() {
+ throw new AssertionError();
+ }
+ @Override public void setAddressBook(seedu.address.model.ReadOnlyAddressBook newData) {
+ throw new AssertionError();
+ }
+ @Override public seedu.address.model.ReadOnlyAddressBook getAddressBook() {
+ throw new AssertionError();
+ }
+ @Override public boolean canUndo() {
+ throw new AssertionError();
+ }
+ @Override public void saveSnapshot() {
+ throw new AssertionError();
+ }
+ @Override public void undo() {
+ throw new AssertionError();
+ }
+ @Override public void clearRedo() {
+ throw new AssertionError();
+ }
+ @Override public boolean canRedo() {
+ throw new AssertionError();
+ }
+ @Override public void redo() {
+ throw new AssertionError();
+ }
+
+ @Override
+ public void pushUndoSnapshot(ReadOnlyAddressBook snapshot) {
+ throw new AssertionError();
+ }
+ }
+
+ private static class ModelStubWithPerson extends ModelStub {
+ private final Person person;
+ ModelStubWithPerson(Person person) {
+ this.person = person;
+ }
+ @Override public ObservableList getFilteredPersonList() {
+ return FXCollections.observableArrayList(person);
+ }
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/ListArchivedCommandTest.java b/src/test/java/seedu/address/logic/commands/ListArchivedCommandTest.java
new file mode 100644
index 00000000000..a457b957ef8
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/ListArchivedCommandTest.java
@@ -0,0 +1,87 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static seedu.address.logic.commands.CommandTestUtil.assertCommandSuccess;
+import static seedu.address.model.Model.PREDICATE_SHOW_ARCHIVED_PERSONS;
+import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import seedu.address.model.AddressBook;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.UserPrefs;
+import seedu.address.model.person.Person;
+
+public class ListArchivedCommandTest {
+
+ private Model model;
+ private Model expectedModel;
+
+ @BeforeEach
+ public void setUp() {
+ model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+ expectedModel = new ModelManager(new AddressBook(model.getAddressBook()), new UserPrefs());
+
+ // Ensure there is at least one archived person (same in both models)
+ Person p = model.getFilteredPersonList().get(0);
+ Person archived = p.withArchived(true);
+ model.setPerson(p, archived);
+ expectedModel.setPerson(p, archived);
+
+ // Prepare expected model's filtered view to match the command's effect
+ expectedModel.updateFilteredPersonList(PREDICATE_SHOW_ARCHIVED_PERSONS);
+ }
+
+ @Test
+ public void execute_showsOnlyArchived() {
+ assertCommandSuccess(
+ new ListArchivedCommand(),
+ model,
+ ListArchivedCommand.MESSAGE_SUCCESS,
+ expectedModel
+ );
+
+ // everything shown is archived
+ assertTrue(model.getFilteredPersonList().stream().allMatch(Person::isArchived));
+ }
+
+ @Test
+ public void execute_noArchivedPersons_showsEmptyMessage() {
+ // Use fresh models with nobody archived
+ Model localModel = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+ Model localExpected = new ModelManager(new AddressBook(localModel.getAddressBook()), new UserPrefs());
+
+ //filter to archived first, then show empty message if none
+ localExpected.updateFilteredPersonList(PREDICATE_SHOW_ARCHIVED_PERSONS);
+
+ assertCommandSuccess(
+ new ListArchivedCommand(),
+ localModel,
+ ListArchivedCommand.MESSAGE_EMPTY,
+ localExpected
+ );
+ // Sanity
+ assertEquals(0, localModel.getFilteredPersonList().size());
+ }
+
+ @Test
+ public void equals() {
+ ListArchivedCommand command1 = new ListArchivedCommand();
+ ListArchivedCommand command2 = new ListArchivedCommand();
+
+ // same object returns true
+ assertTrue(command1.equals(command1));
+
+ // same type and state return true
+ assertTrue(command1.equals(command2));
+
+ // null return false
+ assertTrue(!command1.equals(null));
+
+ // different type return false
+ assertTrue(!command1.equals(new ListCommand()));
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/RedoCommandTest.java b/src/test/java/seedu/address/logic/commands/RedoCommandTest.java
new file mode 100644
index 00000000000..b41a46362b1
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/RedoCommandTest.java
@@ -0,0 +1,84 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static seedu.address.logic.commands.CommandTestUtil.assertCommandSuccess;
+import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import seedu.address.model.AddressBook;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.UserPrefs;
+import seedu.address.model.person.Person;
+
+public class RedoCommandTest {
+
+ private Model model;
+ private Model expectedModel;
+
+ @BeforeEach
+ public void setUp() {
+ model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+ expectedModel = new ModelManager(new AddressBook(model.getAddressBook()), new UserPrefs());
+ }
+
+ @Test
+ public void execute_nothingToRedo_showsMessageAndNoChange() {
+ // No undo performed yet, so redo has nothing to apply
+ assertCommandSuccess(new RedoCommand(), model, RedoCommand.MESSAGE_NOTHING, expectedModel);
+ }
+
+ @Test
+ public void execute_afterUndo_restoresUndoneChange() {
+ Person p0 = model.getFilteredPersonList().get(0);
+
+ // Simulate a mutating command
+ model.saveSnapshot();
+ model.setPerson(p0, p0.withArchived(true));
+
+ // Undo, now redo should be available
+ model.undo();
+
+ // Expected model should represent the mutated state (archived=true) AFTER redo
+ Person exp0 = expectedModel.getFilteredPersonList().get(0);
+ expectedModel.setPerson(exp0, exp0.withArchived(true));
+ expectedModel.updateFilteredPersonList(seedu.address.model.Model.PREDICATE_SHOW_ACTIVE_PERSONS);
+
+ // Now redo should re-apply the archived change and match expectedModel
+ assertCommandSuccess(new RedoCommand(), model, RedoCommand.MESSAGE_SUCCESS, expectedModel);
+ }
+
+ @Test
+ public void execute_afterSingleRedo_secondRedoDoesNothing() {
+ Person p0 = model.getFilteredPersonList().get(0);
+
+ // Mutate with a snapshot, then undo, so one redo available
+ model.saveSnapshot();
+ model.setPerson(p0, p0.withArchived(true));
+ model.undo();
+
+ // Expected state after the FIRST redo: archived = true and active member list
+ Person exp0 = expectedModel.getFilteredPersonList().get(0);
+ expectedModel.setPerson(exp0, exp0.withArchived(true));
+ expectedModel.updateFilteredPersonList(seedu.address.model.Model.PREDICATE_SHOW_ACTIVE_PERSONS);
+
+ // First redo succeeds
+ assertCommandSuccess(new RedoCommand(), model, RedoCommand.MESSAGE_SUCCESS, expectedModel);
+
+ // Second redo has nothing left to apply; model stays identical to expectedModel
+ assertCommandSuccess(new RedoCommand(), model, RedoCommand.MESSAGE_NOTHING, expectedModel);
+ }
+
+ @Test
+ public void equals() {
+ RedoCommand a = new RedoCommand();
+ RedoCommand b = new RedoCommand();
+
+ assertTrue(a.equals(a)); // same object
+ assertTrue(a.equals(b)); // stateless shld be equal
+ assertTrue(!a.equals(null)); // null
+ assertTrue(!a.equals(new UndoCommand())); // different type
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/UnarchiveCommandTest.java b/src/test/java/seedu/address/logic/commands/UnarchiveCommandTest.java
new file mode 100644
index 00000000000..a9171cbd6cf
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/UnarchiveCommandTest.java
@@ -0,0 +1,150 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_PERSON_DISPLAYED_INDEX;
+import static seedu.address.logic.commands.CommandTestUtil.assertCommandFailure;
+import static seedu.address.logic.commands.CommandTestUtil.assertCommandSuccess;
+import static seedu.address.model.Model.PREDICATE_SHOW_ACTIVE_PERSONS;
+import static seedu.address.model.Model.PREDICATE_SHOW_ARCHIVED_PERSONS;
+import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON;
+import static seedu.address.testutil.TypicalIndexes.INDEX_SECOND_PERSON;
+import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.model.AddressBook;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.UserPrefs;
+import seedu.address.model.person.Person;
+
+/** Tests for UnarchiveCommand (supports multiple indices). */
+public class UnarchiveCommandTest {
+
+ private Model model;
+ private Model expectedModel;
+
+ @BeforeEach
+ public void setUp() {
+ model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+ expectedModel = new ModelManager(new AddressBook(model.getAddressBook()), new UserPrefs());
+
+ // Archive two people in BOTH models so they stay in sync.
+ Person p1 = model.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
+ Person p2 = model.getFilteredPersonList().get(INDEX_SECOND_PERSON.getZeroBased());
+ model.setPerson(p1, p1.withArchived(true));
+ model.setPerson(p2, p2.withArchived(true));
+ expectedModel.setPerson(p1, p1.withArchived(true));
+ expectedModel.setPerson(p2, p2.withArchived(true));
+
+ // Mimic listarchived so only archived people are listed
+ model.updateFilteredPersonList(PREDICATE_SHOW_ARCHIVED_PERSONS);
+ expectedModel.updateFilteredPersonList(PREDICATE_SHOW_ARCHIVED_PERSONS);
+ }
+
+ @Test
+ public void execute_singleIndex_success() {
+ Person archived = model.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
+ UnarchiveCommand command = new UnarchiveCommand(List.of(INDEX_FIRST_PERSON));
+
+ Person unarchived = archived.withArchived(false);
+ expectedModel.setPerson(archived, unarchived);
+ expectedModel.updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
+
+ String expectedMessage = String.format(UnarchiveCommand.MESSAGE_SUCCESS, unarchived.getName());
+ assertCommandSuccess(command, model, expectedMessage, expectedModel);
+ }
+
+ @Test
+ public void execute_multipleIndices_success() {
+ Person a1 = model.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
+ Person a2 = model.getFilteredPersonList().get(INDEX_SECOND_PERSON.getZeroBased());
+
+ UnarchiveCommand command =
+ new UnarchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+
+ Person u1 = a1.withArchived(false);
+ Person u2 = a2.withArchived(false);
+ expectedModel.setPerson(a1, u1);
+ expectedModel.setPerson(a2, u2);
+
+ expectedModel.updateFilteredPersonList(PREDICATE_SHOW_ACTIVE_PERSONS);
+
+ String expectedMessage = String.format(
+ UnarchiveCommand.MESSAGE_SUCCESS, u1.getName() + ", " + u2.getName());
+ assertCommandSuccess(command, model, expectedMessage, expectedModel);
+ }
+
+ @Test
+ public void execute_indexOutOfBounds_throwsCommandException() {
+ int outOfBounds = model.getFilteredPersonList().size() + 1;
+ UnarchiveCommand command = new UnarchiveCommand(List.of(Index.fromOneBased(outOfBounds)));
+
+ assertCommandFailure(command, model, MESSAGE_INVALID_PERSON_DISPLAYED_INDEX);
+ }
+
+ /**
+ * Covers:
+ * - fetching each selected person
+ * - the (!person.isArchived()) branch, collecting names
+ * - throwing when notArchivedNames is non-empty
+ */
+ @Test
+ public void execute_selectionContainsNotArchived_throwsWithNames() {
+ Model localModel = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+
+ // Archive the second person only; first remains active
+ Person first = localModel.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
+ Person second = localModel.getFilteredPersonList().get(INDEX_SECOND_PERSON.getZeroBased());
+ localModel.setPerson(second, second.withArchived(true));
+
+ // Show ALL persons so both indices are selectable/visible
+ localModel.updateFilteredPersonList(p -> true);
+
+ UnarchiveCommand cmd =
+ new UnarchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+
+ String expectedMessage = String.format(
+ UnarchiveCommand.MESSAGE_NOT_ARCHIVED, first.getName().toString());
+
+ assertCommandFailure(cmd, localModel, expectedMessage);
+ }
+
+ @Test
+ public void execute_selectionAllNotArchived_throwsWithJoinedNames() {
+ Model localModel = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+
+ Person first = localModel.getFilteredPersonList().get(INDEX_FIRST_PERSON.getZeroBased());
+ Person second = localModel.getFilteredPersonList().get(INDEX_SECOND_PERSON.getZeroBased());
+ // Typical persons are active by default — no changes needed
+
+ localModel.updateFilteredPersonList(p -> true);
+
+ UnarchiveCommand cmd =
+ new UnarchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+
+ String expectedMessage = String.format(
+ UnarchiveCommand.MESSAGE_NOT_ARCHIVED, first.getName() + ", " + second.getName());
+
+ assertCommandFailure(cmd, localModel, expectedMessage);
+ }
+
+ @Test
+ public void equals() {
+ UnarchiveCommand c1 = new UnarchiveCommand(List.of(INDEX_FIRST_PERSON));
+ UnarchiveCommand c2 = new UnarchiveCommand(List.of(INDEX_FIRST_PERSON));
+ UnarchiveCommand c3 = new UnarchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+
+ assertTrue(c1.equals(c2));
+ assertTrue(!c1.equals(c3));
+ assertTrue(c1.equals(c1));
+ assertTrue(!c1.equals(null));
+ assertTrue(!c1.equals(new ListCommand())); // different type
+ }
+}
+
diff --git a/src/test/java/seedu/address/logic/commands/UndoCommandTest.java b/src/test/java/seedu/address/logic/commands/UndoCommandTest.java
new file mode 100644
index 00000000000..b2242511385
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/UndoCommandTest.java
@@ -0,0 +1,56 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static seedu.address.logic.commands.CommandTestUtil.assertCommandSuccess;
+import static seedu.address.testutil.TypicalPersons.getTypicalAddressBook;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import seedu.address.model.AddressBook;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.UserPrefs;
+import seedu.address.model.person.Person;
+
+public class UndoCommandTest {
+
+ private Model model;
+ private Model expectedModel;
+
+ @BeforeEach
+ public void setUp() {
+ model = new ModelManager(getTypicalAddressBook(), new UserPrefs());
+ expectedModel = new ModelManager(new AddressBook(model.getAddressBook()), new UserPrefs());
+ }
+
+ @Test
+ public void execute_nothingToUndo_showsMessageAndNoChange() {
+ // No snapshots saved yet
+ assertCommandSuccess(new UndoCommand(), model, UndoCommand.MESSAGE_NOTHING, expectedModel);
+ }
+
+ @Test
+ public void execute_afterMutatingCommand_restoresPreviousState() {
+ // Baseline expected state is the original (before mutation)
+ Person original = model.getFilteredPersonList().get(0);
+
+ // Simulate a mutating command the way LogicManager would:
+ model.saveSnapshot(); // snapshot "before"
+ model.setPerson(original, original.withArchived(true)); // mutate
+
+ // After undo, model should match expectedModel (original state)
+ assertCommandSuccess(new UndoCommand(), model, UndoCommand.MESSAGE_SUCCESS, expectedModel);
+ }
+
+ @Test
+ public void equals() {
+ UndoCommand a = new UndoCommand();
+ UndoCommand b = new UndoCommand();
+
+ assertTrue(a.equals(a)); // same object
+ assertTrue(a.equals(b)); // stateless shld be equal
+ assertTrue(!a.equals(null)); // null
+ assertTrue(!a.equals(new RedoCommand())); // different type
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/ViewPaymentCommandIntegrationTest.java b/src/test/java/seedu/address/logic/commands/ViewPaymentCommandIntegrationTest.java
new file mode 100644
index 00000000000..a1776e64e8f
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/ViewPaymentCommandIntegrationTest.java
@@ -0,0 +1,42 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.exceptions.CommandException;
+import seedu.address.model.Model;
+import seedu.address.model.ModelManager;
+import seedu.address.model.UserPrefs;
+import seedu.address.testutil.TypicalPersons;
+
+/**
+ * Integration tests for {@link ViewPaymentCommand}.
+ */
+public class ViewPaymentCommandIntegrationTest {
+
+ private Model model;
+
+ @BeforeEach
+ public void setUp() {
+ model = new ModelManager(TypicalPersons.getTypicalAddressBook(), new UserPrefs());
+ }
+
+ @Test
+ public void execute_validIndex_success() throws Exception {
+ CommandResult result = new ViewPaymentCommand(Index.fromOneBased(1)).execute(model);
+ assertNotNull(result);
+ assertTrue(result.getFeedbackToUser() != null && !result.getFeedbackToUser().isEmpty());
+ }
+
+ @Test
+ public void execute_outOfBoundsIndex_throwsCommandException() {
+ int outOfBounds = model.getFilteredPersonList().size() + 1;
+ ViewPaymentCommand cmd = new ViewPaymentCommand(Index.fromOneBased(outOfBounds));
+ assertThrows(CommandException.class, () -> cmd.execute(model));
+ }
+}
diff --git a/src/test/java/seedu/address/logic/commands/ViewPaymentCommandTest.java b/src/test/java/seedu/address/logic/commands/ViewPaymentCommandTest.java
new file mode 100644
index 00000000000..350fc13a306
--- /dev/null
+++ b/src/test/java/seedu/address/logic/commands/ViewPaymentCommandTest.java
@@ -0,0 +1,35 @@
+package seedu.address.logic.commands;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+
+/**
+ * Unit tests for {@link ViewPaymentCommand} (equality and basic semantics).
+ */
+public class ViewPaymentCommandTest {
+
+ @Test
+ public void equals_sameValues_returnsTrue() {
+ ViewPaymentCommand a = new ViewPaymentCommand(Index.fromOneBased(1));
+ ViewPaymentCommand b = new ViewPaymentCommand(Index.fromOneBased(1));
+ assertEquals(a, b);
+ }
+
+ @Test
+ public void equals_differentIndex_returnsFalse() {
+ ViewPaymentCommand a = new ViewPaymentCommand(Index.fromOneBased(1));
+ ViewPaymentCommand b = new ViewPaymentCommand(Index.fromOneBased(2));
+ assertNotEquals(a, b);
+ }
+
+ @Test
+ public void equals_nullOrDifferentType_returnsFalse() {
+ ViewPaymentCommand a = new ViewPaymentCommand(Index.fromOneBased(1));
+ assertNotEquals(a, null);
+ assertNotEquals(a, "not-a-command");
+ }
+}
diff --git a/src/test/java/seedu/address/logic/parser/AddCommandParserTest.java b/src/test/java/seedu/address/logic/parser/AddCommandParserTest.java
index 5bc11d3cdaa..94c2292b80d 100644
--- a/src/test/java/seedu/address/logic/parser/AddCommandParserTest.java
+++ b/src/test/java/seedu/address/logic/parser/AddCommandParserTest.java
@@ -1,15 +1,15 @@
package seedu.address.logic.parser;
import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
-import static seedu.address.logic.commands.CommandTestUtil.ADDRESS_DESC_AMY;
-import static seedu.address.logic.commands.CommandTestUtil.ADDRESS_DESC_BOB;
import static seedu.address.logic.commands.CommandTestUtil.EMAIL_DESC_AMY;
import static seedu.address.logic.commands.CommandTestUtil.EMAIL_DESC_BOB;
-import static seedu.address.logic.commands.CommandTestUtil.INVALID_ADDRESS_DESC;
import static seedu.address.logic.commands.CommandTestUtil.INVALID_EMAIL_DESC;
+import static seedu.address.logic.commands.CommandTestUtil.INVALID_MATRICULATIONNUM_DESC;
import static seedu.address.logic.commands.CommandTestUtil.INVALID_NAME_DESC;
import static seedu.address.logic.commands.CommandTestUtil.INVALID_PHONE_DESC;
import static seedu.address.logic.commands.CommandTestUtil.INVALID_TAG_DESC;
+import static seedu.address.logic.commands.CommandTestUtil.MATRICULATIONNUM_DESC_AMY;
+import static seedu.address.logic.commands.CommandTestUtil.MATRICULATIONNUM_DESC_BOB;
import static seedu.address.logic.commands.CommandTestUtil.NAME_DESC_AMY;
import static seedu.address.logic.commands.CommandTestUtil.NAME_DESC_BOB;
import static seedu.address.logic.commands.CommandTestUtil.PHONE_DESC_AMY;
@@ -18,14 +18,14 @@
import static seedu.address.logic.commands.CommandTestUtil.PREAMBLE_WHITESPACE;
import static seedu.address.logic.commands.CommandTestUtil.TAG_DESC_FRIEND;
import static seedu.address.logic.commands.CommandTestUtil.TAG_DESC_HUSBAND;
-import static seedu.address.logic.commands.CommandTestUtil.VALID_ADDRESS_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_EMAIL_BOB;
+import static seedu.address.logic.commands.CommandTestUtil.VALID_MATRICULATIONNUM_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_NAME_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_PHONE_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_TAG_FRIEND;
import static seedu.address.logic.commands.CommandTestUtil.VALID_TAG_HUSBAND;
-import static seedu.address.logic.parser.CliSyntax.PREFIX_ADDRESS;
import static seedu.address.logic.parser.CliSyntax.PREFIX_EMAIL;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_MATRICULATIONNUMBER;
import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME;
import static seedu.address.logic.parser.CliSyntax.PREFIX_PHONE;
import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
@@ -36,9 +36,9 @@
import org.junit.jupiter.api.Test;
import seedu.address.logic.Messages;
-import seedu.address.logic.commands.AddCommand;
-import seedu.address.model.person.Address;
+import seedu.address.logic.commands.AddMemberCommand;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Person;
import seedu.address.model.person.Phone;
@@ -54,143 +54,192 @@ public void parse_allFieldsPresent_success() {
// whitespace only preamble
assertParseSuccess(parser, PREAMBLE_WHITESPACE + NAME_DESC_BOB + PHONE_DESC_BOB + EMAIL_DESC_BOB
- + ADDRESS_DESC_BOB + TAG_DESC_FRIEND, new AddCommand(expectedPerson));
+ + MATRICULATIONNUM_DESC_BOB + TAG_DESC_FRIEND, new AddMemberCommand(expectedPerson));
// multiple tags - all accepted
- Person expectedPersonMultipleTags = new PersonBuilder(BOB).withTags(VALID_TAG_FRIEND, VALID_TAG_HUSBAND)
- .build();
+ Person expectedPersonMultipleTags = new PersonBuilder(BOB).withTags(VALID_TAG_FRIEND,
+ VALID_TAG_HUSBAND)
+ .build();
assertParseSuccess(parser,
- NAME_DESC_BOB + PHONE_DESC_BOB + EMAIL_DESC_BOB + ADDRESS_DESC_BOB + TAG_DESC_HUSBAND + TAG_DESC_FRIEND,
- new AddCommand(expectedPersonMultipleTags));
+ NAME_DESC_BOB
+ + PHONE_DESC_BOB
+ + EMAIL_DESC_BOB
+ + MATRICULATIONNUM_DESC_BOB
+ + TAG_DESC_HUSBAND
+ + TAG_DESC_FRIEND,
+ new AddMemberCommand(expectedPersonMultipleTags));
}
@Test
public void parse_repeatedNonTagValue_failure() {
String validExpectedPersonString = NAME_DESC_BOB + PHONE_DESC_BOB + EMAIL_DESC_BOB
- + ADDRESS_DESC_BOB + TAG_DESC_FRIEND;
+ + MATRICULATIONNUM_DESC_BOB + TAG_DESC_FRIEND;
// multiple names
assertParseFailure(parser, NAME_DESC_AMY + validExpectedPersonString,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_NAME));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_NAME));
// multiple phones
assertParseFailure(parser, PHONE_DESC_AMY + validExpectedPersonString,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE));
// multiple emails
assertParseFailure(parser, EMAIL_DESC_AMY + validExpectedPersonString,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_EMAIL));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_EMAIL));
// multiple addresses
- assertParseFailure(parser, ADDRESS_DESC_AMY + validExpectedPersonString,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_ADDRESS));
+ assertParseFailure(parser, MATRICULATIONNUM_DESC_AMY + validExpectedPersonString,
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_MATRICULATIONNUMBER));
// multiple fields repeated
assertParseFailure(parser,
- validExpectedPersonString + PHONE_DESC_AMY + EMAIL_DESC_AMY + NAME_DESC_AMY + ADDRESS_DESC_AMY
- + validExpectedPersonString,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_NAME, PREFIX_ADDRESS, PREFIX_EMAIL, PREFIX_PHONE));
+ validExpectedPersonString
+ + PHONE_DESC_AMY
+ + EMAIL_DESC_AMY
+ + NAME_DESC_AMY
+ + MATRICULATIONNUM_DESC_AMY
+ + validExpectedPersonString,
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_NAME, PREFIX_MATRICULATIONNUMBER,
+ PREFIX_EMAIL, PREFIX_PHONE));
// invalid value followed by valid value
// invalid name
assertParseFailure(parser, INVALID_NAME_DESC + validExpectedPersonString,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_NAME));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_NAME));
// invalid email
assertParseFailure(parser, INVALID_EMAIL_DESC + validExpectedPersonString,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_EMAIL));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_EMAIL));
// invalid phone
assertParseFailure(parser, INVALID_PHONE_DESC + validExpectedPersonString,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE));
- // invalid address
- assertParseFailure(parser, INVALID_ADDRESS_DESC + validExpectedPersonString,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_ADDRESS));
+ // invalid MATRICULATIONNUM
+ assertParseFailure(parser, INVALID_MATRICULATIONNUM_DESC + validExpectedPersonString,
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_MATRICULATIONNUMBER));
// valid value followed by invalid value
// invalid name
assertParseFailure(parser, validExpectedPersonString + INVALID_NAME_DESC,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_NAME));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_NAME));
// invalid email
assertParseFailure(parser, validExpectedPersonString + INVALID_EMAIL_DESC,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_EMAIL));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_EMAIL));
// invalid phone
assertParseFailure(parser, validExpectedPersonString + INVALID_PHONE_DESC,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE));
- // invalid address
- assertParseFailure(parser, validExpectedPersonString + INVALID_ADDRESS_DESC,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_ADDRESS));
+ // invalid MATRICULATIONNUM
+ assertParseFailure(parser, validExpectedPersonString + INVALID_MATRICULATIONNUM_DESC,
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_MATRICULATIONNUMBER));
}
@Test
public void parse_optionalFieldsMissing_success() {
// zero tags
Person expectedPerson = new PersonBuilder(AMY).withTags().build();
- assertParseSuccess(parser, NAME_DESC_AMY + PHONE_DESC_AMY + EMAIL_DESC_AMY + ADDRESS_DESC_AMY,
- new AddCommand(expectedPerson));
+ assertParseSuccess(parser, NAME_DESC_AMY
+ + PHONE_DESC_AMY
+ + EMAIL_DESC_AMY
+ + MATRICULATIONNUM_DESC_AMY,
+ new AddMemberCommand(expectedPerson));
}
@Test
public void parse_compulsoryFieldMissing_failure() {
- String expectedMessage = String.format(MESSAGE_INVALID_COMMAND_FORMAT, AddCommand.MESSAGE_USAGE);
+ String expectedMessage = String.format(MESSAGE_INVALID_COMMAND_FORMAT,
+ AddMemberCommand.MESSAGE_USAGE);
// missing name prefix
- assertParseFailure(parser, VALID_NAME_BOB + PHONE_DESC_BOB + EMAIL_DESC_BOB + ADDRESS_DESC_BOB,
- expectedMessage);
+ assertParseFailure(parser, VALID_NAME_BOB
+ + PHONE_DESC_BOB
+ + EMAIL_DESC_BOB
+ + MATRICULATIONNUM_DESC_BOB,
+ expectedMessage);
// missing phone prefix
- assertParseFailure(parser, NAME_DESC_BOB + VALID_PHONE_BOB + EMAIL_DESC_BOB + ADDRESS_DESC_BOB,
- expectedMessage);
+ assertParseFailure(parser, NAME_DESC_BOB
+ + VALID_PHONE_BOB
+ + EMAIL_DESC_BOB
+ + MATRICULATIONNUM_DESC_BOB,
+ expectedMessage);
// missing email prefix
- assertParseFailure(parser, NAME_DESC_BOB + PHONE_DESC_BOB + VALID_EMAIL_BOB + ADDRESS_DESC_BOB,
- expectedMessage);
-
- // missing address prefix
- assertParseFailure(parser, NAME_DESC_BOB + PHONE_DESC_BOB + EMAIL_DESC_BOB + VALID_ADDRESS_BOB,
- expectedMessage);
+ assertParseFailure(parser, NAME_DESC_BOB
+ + PHONE_DESC_BOB
+ + VALID_EMAIL_BOB
+ + MATRICULATIONNUM_DESC_BOB,
+ expectedMessage);
+
+ // missing MATRICULATIONNUM prefix
+ assertParseFailure(parser, NAME_DESC_BOB
+ + PHONE_DESC_BOB
+ + EMAIL_DESC_BOB
+ + VALID_MATRICULATIONNUM_BOB,
+ expectedMessage);
// all prefixes missing
- assertParseFailure(parser, VALID_NAME_BOB + VALID_PHONE_BOB + VALID_EMAIL_BOB + VALID_ADDRESS_BOB,
- expectedMessage);
+ assertParseFailure(parser, VALID_NAME_BOB
+ + VALID_PHONE_BOB
+ + VALID_EMAIL_BOB
+ + VALID_MATRICULATIONNUM_BOB,
+ expectedMessage);
}
@Test
public void parse_invalidValue_failure() {
// invalid name
- assertParseFailure(parser, INVALID_NAME_DESC + PHONE_DESC_BOB + EMAIL_DESC_BOB + ADDRESS_DESC_BOB
- + TAG_DESC_HUSBAND + TAG_DESC_FRIEND, Name.MESSAGE_CONSTRAINTS);
+ assertParseFailure(parser, INVALID_NAME_DESC
+ + PHONE_DESC_BOB
+ + EMAIL_DESC_BOB
+ + MATRICULATIONNUM_DESC_BOB
+ + TAG_DESC_HUSBAND
+ + TAG_DESC_FRIEND, Name.MESSAGE_CONSTRAINTS);
// invalid phone
- assertParseFailure(parser, NAME_DESC_BOB + INVALID_PHONE_DESC + EMAIL_DESC_BOB + ADDRESS_DESC_BOB
- + TAG_DESC_HUSBAND + TAG_DESC_FRIEND, Phone.MESSAGE_CONSTRAINTS);
+ assertParseFailure(parser, NAME_DESC_BOB
+ + INVALID_PHONE_DESC
+ + EMAIL_DESC_BOB
+ + MATRICULATIONNUM_DESC_BOB
+ + TAG_DESC_HUSBAND
+ + TAG_DESC_FRIEND, Phone.MESSAGE_CONSTRAINTS);
// invalid email
- assertParseFailure(parser, NAME_DESC_BOB + PHONE_DESC_BOB + INVALID_EMAIL_DESC + ADDRESS_DESC_BOB
- + TAG_DESC_HUSBAND + TAG_DESC_FRIEND, Email.MESSAGE_CONSTRAINTS);
-
- // invalid address
- assertParseFailure(parser, NAME_DESC_BOB + PHONE_DESC_BOB + EMAIL_DESC_BOB + INVALID_ADDRESS_DESC
- + TAG_DESC_HUSBAND + TAG_DESC_FRIEND, Address.MESSAGE_CONSTRAINTS);
+ assertParseFailure(parser, NAME_DESC_BOB
+ + PHONE_DESC_BOB
+ + INVALID_EMAIL_DESC
+ + MATRICULATIONNUM_DESC_BOB
+ + TAG_DESC_HUSBAND
+ + TAG_DESC_FRIEND, Email.MESSAGE_CONSTRAINTS);
+
+ // invalid MATRICULATIONNUM
+ assertParseFailure(parser, NAME_DESC_BOB
+ + PHONE_DESC_BOB
+ + EMAIL_DESC_BOB
+ + INVALID_MATRICULATIONNUM_DESC
+ + TAG_DESC_HUSBAND
+ + TAG_DESC_FRIEND, MatriculationNumber.MESSAGE_CONSTRAINTS);
// invalid tag
- assertParseFailure(parser, NAME_DESC_BOB + PHONE_DESC_BOB + EMAIL_DESC_BOB + ADDRESS_DESC_BOB
- + INVALID_TAG_DESC + VALID_TAG_FRIEND, Tag.MESSAGE_CONSTRAINTS);
+ assertParseFailure(parser, NAME_DESC_BOB + PHONE_DESC_BOB + EMAIL_DESC_BOB + MATRICULATIONNUM_DESC_BOB
+ + INVALID_TAG_DESC + VALID_TAG_FRIEND, Tag.MESSAGE_CONSTRAINTS);
// two invalid values, only first invalid value reported
- assertParseFailure(parser, INVALID_NAME_DESC + PHONE_DESC_BOB + EMAIL_DESC_BOB + INVALID_ADDRESS_DESC,
- Name.MESSAGE_CONSTRAINTS);
+ assertParseFailure(parser, INVALID_NAME_DESC
+ + PHONE_DESC_BOB
+ + EMAIL_DESC_BOB
+ + INVALID_MATRICULATIONNUM_DESC,
+ Name.MESSAGE_CONSTRAINTS);
// non-empty preamble
assertParseFailure(parser, PREAMBLE_NON_EMPTY + NAME_DESC_BOB + PHONE_DESC_BOB + EMAIL_DESC_BOB
- + ADDRESS_DESC_BOB + TAG_DESC_HUSBAND + TAG_DESC_FRIEND,
- String.format(MESSAGE_INVALID_COMMAND_FORMAT, AddCommand.MESSAGE_USAGE));
+ + MATRICULATIONNUM_DESC_BOB + TAG_DESC_HUSBAND + TAG_DESC_FRIEND,
+ String.format(MESSAGE_INVALID_COMMAND_FORMAT, AddMemberCommand.MESSAGE_USAGE));
}
}
diff --git a/src/test/java/seedu/address/logic/parser/AddPaymentCommandParserTest.java b/src/test/java/seedu/address/logic/parser/AddPaymentCommandParserTest.java
new file mode 100644
index 00000000000..d08c77fff8c
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/AddPaymentCommandParserTest.java
@@ -0,0 +1,160 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+import static seedu.address.logic.parser.AddPaymentCommandParser.MESSAGE_INVALID_AMOUNT;
+import static seedu.address.logic.parser.AddPaymentCommandParser.MESSAGE_INVALID_DATE;
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseSuccess;
+import static seedu.address.logic.parser.ParserUtil.MESSAGE_INVALID_INDEX;
+import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON;
+import static seedu.address.testutil.TypicalIndexes.INDEX_SECOND_PERSON;
+
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.logic.commands.AddPaymentCommand;
+import seedu.address.model.payment.Amount;
+
+/**
+ * Tests for AddPaymentCommandParser.
+ *
+ * - empty / missing args
+ * - missing amount or date prefix
+ * - invalid index tokens
+ * - invalid amount formats
+ * - invalid date formats
+ * - valid single index
+ * - valid multiple indexes
+ * - trailing commas/whitespace handling
+ * - multiple indexes, remarks optional
+ */
+
+public class AddPaymentCommandParserTest {
+
+ private static final String MESSAGE_USAGE =
+ String.format(MESSAGE_INVALID_COMMAND_FORMAT, AddPaymentCommand.MESSAGE_USAGE);
+
+ private final AddPaymentCommandParser parser = new AddPaymentCommandParser();
+
+ // 1. Empty or missing args
+ @Test
+ public void parse_emptyArgs_throwsParseException() {
+ assertParseFailure(parser, "", MESSAGE_USAGE);
+ assertParseFailure(parser, " ", MESSAGE_USAGE);
+ }
+
+ // 2. Missing required prefixes
+ @Test
+ public void parse_missingPrefixes_throwsParseException() {
+ // Missing a/ prefix
+ assertParseFailure(parser, "1 d/2025-10-09", MESSAGE_USAGE);
+
+ // Missing d/ prefix
+ assertParseFailure(parser, "1 a/23.50", MESSAGE_USAGE);
+
+ // Missing both prefixes
+ assertParseFailure(parser, "1", MESSAGE_USAGE);
+ }
+
+ // 3. Invalid indexes
+ @Test
+ public void parse_invalidIndex_throwsParseException() {
+ // Non-numeric
+ assertParseFailure(parser, "a a/23.50 d/2025-10-09", MESSAGE_INVALID_INDEX);
+ assertParseFailure(parser, "1, x a/23.50 d/2025-10-09", MESSAGE_INVALID_INDEX);
+
+ // Zero or negative
+ assertParseFailure(parser, "0 a/23.50 d/2025-10-09", MESSAGE_INVALID_INDEX);
+ assertParseFailure(parser, "-1 a/23.50 d/2025-10-09", MESSAGE_INVALID_INDEX);
+ assertParseFailure(parser, "1, -2 a/23.50 d/2025-10-09", MESSAGE_INVALID_INDEX);
+ }
+
+ // 4. Invalid amount formats
+ @Test
+ public void parse_invalidAmount_throwsParseException() {
+ // Negative
+ assertParseFailure(parser, "1 a/-5.00 d/2025-10-09", MESSAGE_INVALID_AMOUNT);
+
+ // Too many decimal places
+ assertParseFailure(parser, "1 a/23.999 d/2025-10-09", MESSAGE_INVALID_AMOUNT);
+
+ // Non-numeric
+ assertParseFailure(parser, "1 a/abc d/2025-10-09", MESSAGE_INVALID_AMOUNT);
+ }
+
+ // 5. Invalid date formats
+ @Test
+ public void parse_invalidDate_throwsParseException() {
+ // Invalid month
+ assertParseFailure(parser, "1 a/23.50 d/2025-13-09", MESSAGE_INVALID_DATE);
+
+ // Invalid day
+ assertParseFailure(parser, "1 a/23.50 d/2025-10-32", MESSAGE_INVALID_DATE);
+
+ // Non-numeric date
+ assertParseFailure(parser, "1 a/23.50 d/today", MESSAGE_INVALID_DATE);
+ }
+
+ // 6. Single index, valid input
+ @Test
+ public void parse_singleIndexValidArgs_success() {
+ AddPaymentCommand expected = new AddPaymentCommand(
+ List.of(INDEX_FIRST_PERSON),
+ Amount.parse("23.50"),
+ LocalDate.of(2025, 10, 9),
+ null);
+
+ assertParseSuccess(parser, "1 a/23.50 d/2025-10-09", expected);
+ assertParseSuccess(parser, " 1 a/23.50 d/2025-10-09 ", expected);
+ }
+
+ // 7. Single index with remarks
+ @Test
+ public void parse_singleIndexWithRemarks_success() {
+ AddPaymentCommand expected = new AddPaymentCommand(
+ List.of(INDEX_FIRST_PERSON),
+ Amount.parse("45.00"),
+ LocalDate.of(2025, 10, 10),
+ "taxi home");
+
+ assertParseSuccess(parser, "1 a/45.00 d/2025-10-10 r/taxi home", expected);
+ assertParseSuccess(parser, "1 a/45.00 d/2025-10-10 r/ taxi home ", expected);
+ }
+
+ // 8. Multiple indexes
+ @Test
+ public void parse_multipleIndexes_success() {
+ AddPaymentCommand expected = new AddPaymentCommand(
+ Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON),
+ Amount.parse("12.34"),
+ LocalDate.of(2025, 10, 1),
+ null);
+
+ assertParseSuccess(parser, "1,2 a/12.34 d/2025-10-01", expected);
+ assertParseSuccess(parser, " 1 , 2 a/12.34 d/2025-10-01", expected);
+ }
+
+ // 9. Multiple indexes with remarks and trailing comma
+ @Test
+ public void parse_multipleIndexesWithRemarksAndTrailingComma_success() {
+ AddPaymentCommand expected = new AddPaymentCommand(
+ Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON),
+ Amount.parse("50.00"),
+ LocalDate.of(2025, 10, 20),
+ "team lunch");
+
+ // Note: trailing comma ignored
+ assertParseSuccess(parser, "1,2, a/50.00 d/2025-10-20 r/team lunch", expected);
+ assertParseSuccess(parser, " 1 , 2 , a/50.00 d/2025-10-20 r/ team lunch ", expected);
+ }
+
+ // 10. Missing preamble (indexes)
+ @Test
+ public void parse_missingPreamble_throwsParseException() {
+ assertParseFailure(parser, "a/23.50 d/2025-10-09", MESSAGE_USAGE);
+ }
+
+}
diff --git a/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java b/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java
index 5a1ab3dbc0c..00f13381fdd 100644
--- a/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java
+++ b/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java
@@ -6,6 +6,7 @@
import static seedu.address.logic.Messages.MESSAGE_UNKNOWN_COMMAND;
import static seedu.address.testutil.Assert.assertThrows;
import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON;
+import static seedu.address.testutil.TypicalIndexes.INDEX_SECOND_PERSON;
import java.util.Arrays;
import java.util.List;
@@ -13,17 +14,17 @@
import org.junit.jupiter.api.Test;
-import seedu.address.logic.commands.AddCommand;
-import seedu.address.logic.commands.ClearCommand;
-import seedu.address.logic.commands.DeleteCommand;
+import seedu.address.logic.commands.AddMemberCommand;
+import seedu.address.logic.commands.ArchiveCommand;
import seedu.address.logic.commands.EditCommand;
import seedu.address.logic.commands.EditCommand.EditPersonDescriptor;
import seedu.address.logic.commands.ExitCommand;
import seedu.address.logic.commands.FindCommand;
import seedu.address.logic.commands.HelpCommand;
+import seedu.address.logic.commands.ListArchivedCommand;
import seedu.address.logic.commands.ListCommand;
+import seedu.address.logic.commands.UnarchiveCommand;
import seedu.address.logic.parser.exceptions.ParseException;
-import seedu.address.model.person.NameContainsKeywordsPredicate;
import seedu.address.model.person.Person;
import seedu.address.testutil.EditPersonDescriptorBuilder;
import seedu.address.testutil.PersonBuilder;
@@ -36,21 +37,8 @@ public class AddressBookParserTest {
@Test
public void parseCommand_add() throws Exception {
Person person = new PersonBuilder().build();
- AddCommand command = (AddCommand) parser.parseCommand(PersonUtil.getAddCommand(person));
- assertEquals(new AddCommand(person), command);
- }
-
- @Test
- public void parseCommand_clear() throws Exception {
- assertTrue(parser.parseCommand(ClearCommand.COMMAND_WORD) instanceof ClearCommand);
- assertTrue(parser.parseCommand(ClearCommand.COMMAND_WORD + " 3") instanceof ClearCommand);
- }
-
- @Test
- public void parseCommand_delete() throws Exception {
- DeleteCommand command = (DeleteCommand) parser.parseCommand(
- DeleteCommand.COMMAND_WORD + " " + INDEX_FIRST_PERSON.getOneBased());
- assertEquals(new DeleteCommand(INDEX_FIRST_PERSON), command);
+ AddMemberCommand command = (AddMemberCommand) parser.parseCommand(PersonUtil.getAddMemberCommand(person));
+ assertEquals(new AddMemberCommand(person), command);
}
@Test
@@ -58,22 +46,22 @@ public void parseCommand_edit() throws Exception {
Person person = new PersonBuilder().build();
EditPersonDescriptor descriptor = new EditPersonDescriptorBuilder(person).build();
EditCommand command = (EditCommand) parser.parseCommand(EditCommand.COMMAND_WORD + " "
- + INDEX_FIRST_PERSON.getOneBased() + " " + PersonUtil.getEditPersonDescriptorDetails(descriptor));
+ + INDEX_FIRST_PERSON.getOneBased() + " " + PersonUtil.getEditPersonDescriptorDetails(descriptor));
assertEquals(new EditCommand(INDEX_FIRST_PERSON, descriptor), command);
}
@Test
public void parseCommand_exit() throws Exception {
assertTrue(parser.parseCommand(ExitCommand.COMMAND_WORD) instanceof ExitCommand);
- assertTrue(parser.parseCommand(ExitCommand.COMMAND_WORD + " 3") instanceof ExitCommand);
+ assertThrows(ParseException.class, () ->
+ parser.parseCommand(ExitCommand.COMMAND_WORD + " 3"));
}
@Test
public void parseCommand_find() throws Exception {
List keywords = Arrays.asList("foo", "bar", "baz");
- FindCommand command = (FindCommand) parser.parseCommand(
- FindCommand.COMMAND_WORD + " " + keywords.stream().collect(Collectors.joining(" ")));
- assertEquals(new FindCommand(new NameContainsKeywordsPredicate(keywords)), command);
+ String args = FindCommand.COMMAND_WORD + " " + keywords.stream().collect(Collectors.joining(" "));
+ assertTrue(parser.parseCommand(args) instanceof FindCommand);
}
@Test
@@ -85,17 +73,51 @@ public void parseCommand_help() throws Exception {
@Test
public void parseCommand_list() throws Exception {
assertTrue(parser.parseCommand(ListCommand.COMMAND_WORD) instanceof ListCommand);
- assertTrue(parser.parseCommand(ListCommand.COMMAND_WORD + " 3") instanceof ListCommand);
+ assertThrows(ParseException.class, () ->
+ parser.parseCommand(ListCommand.COMMAND_WORD + " 3"));
}
@Test
public void parseCommand_unrecognisedInput_throwsParseException() {
- assertThrows(ParseException.class, String.format(MESSAGE_INVALID_COMMAND_FORMAT, HelpCommand.MESSAGE_USAGE), ()
- -> parser.parseCommand(""));
+ assertThrows(ParseException.class, String.format(MESSAGE_INVALID_COMMAND_FORMAT,
+ HelpCommand.MESSAGE_USAGE), () -> parser.parseCommand(""));
}
@Test
public void parseCommand_unknownCommand_throwsParseException() {
- assertThrows(ParseException.class, MESSAGE_UNKNOWN_COMMAND, () -> parser.parseCommand("unknownCommand"));
+ assertThrows(ParseException.class, MESSAGE_UNKNOWN_COMMAND, () -> parser.parseCommand(
+ "unknownCommand"));
+ }
+
+ @Test
+ public void parseCommand_archive_single() throws Exception {
+ String cmd = ArchiveCommand.COMMAND_WORD + " " + INDEX_FIRST_PERSON.getOneBased();
+ assertTrue(parser.parseCommand(cmd) instanceof ArchiveCommand);
+ }
+
+ @Test
+ public void parseCommand_archive_multiple() throws Exception {
+ String cmd = ArchiveCommand.COMMAND_WORD + " "
+ + INDEX_FIRST_PERSON.getOneBased() + "," + INDEX_SECOND_PERSON.getOneBased();
+ assertTrue(parser.parseCommand(cmd) instanceof ArchiveCommand);
+ }
+
+ @Test
+ public void parseCommand_unarchive_single() throws Exception {
+ String cmd = UnarchiveCommand.COMMAND_WORD + " " + INDEX_FIRST_PERSON.getOneBased();
+ assertTrue(parser.parseCommand(cmd) instanceof UnarchiveCommand);
+ }
+
+ @Test
+ public void parseCommand_unarchive_multiple() throws Exception {
+ String cmd = UnarchiveCommand.COMMAND_WORD + " "
+ + INDEX_FIRST_PERSON.getOneBased() + "," + INDEX_SECOND_PERSON.getOneBased();
+ assertTrue(parser.parseCommand(cmd) instanceof UnarchiveCommand);
+ }
+
+ @Test
+ public void parseCommand_listarchived() throws Exception {
+ assertTrue(parser.parseCommand(ListArchivedCommand.COMMAND_WORD) instanceof ListArchivedCommand);
+ assertTrue(parser.parseCommand(ListArchivedCommand.COMMAND_WORD + " ") instanceof ListArchivedCommand);
}
}
diff --git a/src/test/java/seedu/address/logic/parser/ArchiveCommandParserTest.java b/src/test/java/seedu/address/logic/parser/ArchiveCommandParserTest.java
new file mode 100644
index 00000000000..43dda05afb5
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/ArchiveCommandParserTest.java
@@ -0,0 +1,93 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseSuccess;
+import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON;
+import static seedu.address.testutil.TypicalIndexes.INDEX_SECOND_PERSON;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.logic.commands.ArchiveCommand;
+
+/**
+ * Tests for ArchiveCommandParser.
+ * Targets 100% line/branch coverage:
+ * - empty args (trimmed empty) -> failure (hits catch)
+ * - only commas/whitespace -> failure (indexes.isEmpty path, hits catch)
+ * - non-numeric token -> failure (parseIndex throws, hits catch)
+ * - zero/negative index -> failure (parseIndex throws, hits catch)
+ * - single index success
+ * - multiple indices with spaces success
+ * - duplicates are allowed in input and de-duplicated by ArchiveCommand ctor
+ */
+public class ArchiveCommandParserTest {
+
+ private final ArchiveCommandParser parser = new ArchiveCommandParser();
+
+ @Test
+ public void parse_emptyArgs_throwsParseException() {
+ assertParseFailure(parser, "",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ArchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, " ",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ArchiveCommand.MESSAGE_USAGE));
+ }
+
+ @Test
+ public void parse_onlyCommas_throwsParseException() {
+ assertParseFailure(parser, ",,,",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ArchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, " , , ",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ArchiveCommand.MESSAGE_USAGE));
+ }
+
+ @Test
+ public void parse_nonNumericToken_throwsParseException() {
+ assertParseFailure(parser, "one",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ArchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, "1, x",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ArchiveCommand.MESSAGE_USAGE));
+ }
+
+ @Test
+ public void parse_zeroOrNegativeIndex_throwsParseException() {
+ assertParseFailure(parser, "0",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ArchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, "-1",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ArchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, "1, -2",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ArchiveCommand.MESSAGE_USAGE));
+ }
+
+ @Test
+ public void parse_singleIndex_success() {
+ ArchiveCommand expected = new ArchiveCommand(Arrays.asList(INDEX_FIRST_PERSON));
+ assertParseSuccess(parser, "1", expected);
+ assertParseSuccess(parser, " 1 ", expected);
+ }
+
+ @Test
+ public void parseMultipleIndicesWithSpaces_success() {
+ ArchiveCommand expected = new ArchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+ assertParseSuccess(parser, "1,2", expected);
+ assertParseSuccess(parser, " 1 , 2 ", expected);
+ assertParseSuccess(parser, " 1 , 2 , ", expected); // trailing comma/whitespace ignored
+ }
+
+ @Test
+ public void parseDuplicatesInput_success_dedupByCommand() {
+ // Parser accepts duplicates; ArchiveCommand's constructor removes duplicates while preserving order.
+ ArchiveCommand expected = new ArchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+ assertParseSuccess(parser, "1,1,2,1", expected);
+ }
+}
diff --git a/src/test/java/seedu/address/logic/parser/DeleteCommandParserTest.java b/src/test/java/seedu/address/logic/parser/DeleteCommandParserTest.java
deleted file mode 100644
index 6a40e14a649..00000000000
--- a/src/test/java/seedu/address/logic/parser/DeleteCommandParserTest.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package seedu.address.logic.parser;
-
-import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
-import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
-import static seedu.address.logic.parser.CommandParserTestUtil.assertParseSuccess;
-import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON;
-
-import org.junit.jupiter.api.Test;
-
-import seedu.address.logic.commands.DeleteCommand;
-
-/**
- * As we are only doing white-box testing, our test cases do not cover path variations
- * outside of the DeleteCommand code. For example, inputs "1" and "1 abc" take the
- * same path through the DeleteCommand, and therefore we test only one of them.
- * The path variation for those two cases occur inside the ParserUtil, and
- * therefore should be covered by the ParserUtilTest.
- */
-public class DeleteCommandParserTest {
-
- private DeleteCommandParser parser = new DeleteCommandParser();
-
- @Test
- public void parse_validArgs_returnsDeleteCommand() {
- assertParseSuccess(parser, "1", new DeleteCommand(INDEX_FIRST_PERSON));
- }
-
- @Test
- public void parse_invalidArgs_throwsParseException() {
- assertParseFailure(parser, "a", String.format(MESSAGE_INVALID_COMMAND_FORMAT, DeleteCommand.MESSAGE_USAGE));
- }
-}
diff --git a/src/test/java/seedu/address/logic/parser/DeletePaymentCommandParserTest.java b/src/test/java/seedu/address/logic/parser/DeletePaymentCommandParserTest.java
new file mode 100644
index 00000000000..b3c9c7c0934
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/DeletePaymentCommandParserTest.java
@@ -0,0 +1,82 @@
+package seedu.address.logic.parser;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
+import static seedu.address.logic.commands.DeletePaymentCommand.MESSAGE_USAGE;
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.DeletePaymentCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+public class DeletePaymentCommandParserTest {
+
+ private final DeletePaymentCommandParser parser = new DeletePaymentCommandParser();
+
+ @Test
+ public void parse_validArgs_returnsDeletePaymentCommand() throws Exception {
+ // Example: "1 p/1" → delete payment #1 from person #1
+ DeletePaymentCommand expectedCommand =
+ new DeletePaymentCommand(Index.fromOneBased(1),
+ java.util.List.of(Index.fromOneBased(1)));
+
+ assertEquals(expectedCommand, parser.parse("1 p/1"));
+ }
+
+ @Test
+ public void parse_multiplePaymentIndexes_returnsDeletePaymentCommand() throws Exception {
+ // Example: "1 p/1,2,3" → delete payments #1, #2, #3 from person #1
+ DeletePaymentCommand expectedCommand =
+ new DeletePaymentCommand(Index.fromOneBased(1),
+ java.util.List.of(
+ Index.fromOneBased(1),
+ Index.fromOneBased(2),
+ Index.fromOneBased(3)
+ ));
+
+ assertEquals(expectedCommand, parser.parse("1 p/1,2,3"));
+ }
+
+ @Test
+ public void parse_missingPaymentIndex_throwsParseException() {
+ ParseException e = assertThrows(ParseException.class, () -> parser.parse("1"));
+ assertEquals(
+ "Missing payment index after 'p/'. Example: p/1,2,3",
+ e.getMessage()
+ );
+ }
+
+ @Test
+ public void parse_invalidPersonIndex_throwsParseException() {
+ ParseException e = assertThrows(ParseException.class, () -> parser.parse("a p/1"));
+ assertEquals(
+ String.format(MESSAGE_INVALID_COMMAND_FORMAT, MESSAGE_USAGE),
+ e.getMessage()
+ );
+ }
+
+ @Test
+ public void parse_duplicatePaymentIndexes_failure() {
+ String input = "2 p/1,2,2";
+ assertParseFailure(parser, input,
+ DeletePaymentCommandParser.MESSAGE_DUPLICATE_PAYMENT_INDEX);
+ }
+
+ @Test
+ public void parse_emptyPaymentIndex_failure() {
+ // Case: empty token between commas (e.g. "p/1,,2")
+ assertParseFailure(parser, "1 p/1,,2",
+ "Empty payment indexes are not allowed. Remove stray commas/spaces. Example: p/1,2,3");
+
+ // Case: trailing comma (e.g. "p/1,2,")
+ assertParseFailure(parser, "1 p/1,2,",
+ "Empty payment indexes are not allowed. Remove stray commas/spaces. Example: p/1,2,3");
+
+ // Case: extra spaces (e.g. "p/1, ,2")
+ assertParseFailure(parser, "1 p/1, ,2",
+ "Empty payment indexes are not allowed. Remove stray commas/spaces. Example: p/1,2,3");
+ }
+}
diff --git a/src/test/java/seedu/address/logic/parser/EditCommandParserTest.java b/src/test/java/seedu/address/logic/parser/EditCommandParserTest.java
index cc7175172d4..82f1b723d45 100644
--- a/src/test/java/seedu/address/logic/parser/EditCommandParserTest.java
+++ b/src/test/java/seedu/address/logic/parser/EditCommandParserTest.java
@@ -1,29 +1,29 @@
package seedu.address.logic.parser;
import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
-import static seedu.address.logic.commands.CommandTestUtil.ADDRESS_DESC_AMY;
-import static seedu.address.logic.commands.CommandTestUtil.ADDRESS_DESC_BOB;
import static seedu.address.logic.commands.CommandTestUtil.EMAIL_DESC_AMY;
import static seedu.address.logic.commands.CommandTestUtil.EMAIL_DESC_BOB;
-import static seedu.address.logic.commands.CommandTestUtil.INVALID_ADDRESS_DESC;
import static seedu.address.logic.commands.CommandTestUtil.INVALID_EMAIL_DESC;
+import static seedu.address.logic.commands.CommandTestUtil.INVALID_MATRICULATIONNUM_DESC;
import static seedu.address.logic.commands.CommandTestUtil.INVALID_NAME_DESC;
import static seedu.address.logic.commands.CommandTestUtil.INVALID_PHONE_DESC;
import static seedu.address.logic.commands.CommandTestUtil.INVALID_TAG_DESC;
+import static seedu.address.logic.commands.CommandTestUtil.MATRICULATIONNUM_DESC_AMY;
+import static seedu.address.logic.commands.CommandTestUtil.MATRICULATIONNUM_DESC_BOB;
import static seedu.address.logic.commands.CommandTestUtil.NAME_DESC_AMY;
import static seedu.address.logic.commands.CommandTestUtil.PHONE_DESC_AMY;
import static seedu.address.logic.commands.CommandTestUtil.PHONE_DESC_BOB;
import static seedu.address.logic.commands.CommandTestUtil.TAG_DESC_FRIEND;
import static seedu.address.logic.commands.CommandTestUtil.TAG_DESC_HUSBAND;
-import static seedu.address.logic.commands.CommandTestUtil.VALID_ADDRESS_AMY;
import static seedu.address.logic.commands.CommandTestUtil.VALID_EMAIL_AMY;
+import static seedu.address.logic.commands.CommandTestUtil.VALID_MATRICULATIONNUM_AMY;
import static seedu.address.logic.commands.CommandTestUtil.VALID_NAME_AMY;
import static seedu.address.logic.commands.CommandTestUtil.VALID_PHONE_AMY;
import static seedu.address.logic.commands.CommandTestUtil.VALID_PHONE_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_TAG_FRIEND;
import static seedu.address.logic.commands.CommandTestUtil.VALID_TAG_HUSBAND;
-import static seedu.address.logic.parser.CliSyntax.PREFIX_ADDRESS;
import static seedu.address.logic.parser.CliSyntax.PREFIX_EMAIL;
+import static seedu.address.logic.parser.CliSyntax.PREFIX_MATRICULATIONNUMBER;
import static seedu.address.logic.parser.CliSyntax.PREFIX_PHONE;
import static seedu.address.logic.parser.CliSyntax.PREFIX_TAG;
import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
@@ -38,8 +38,8 @@
import seedu.address.logic.Messages;
import seedu.address.logic.commands.EditCommand;
import seedu.address.logic.commands.EditCommand.EditPersonDescriptor;
-import seedu.address.model.person.Address;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Phone;
import seedu.address.model.tag.Tag;
@@ -50,7 +50,7 @@ public class EditCommandParserTest {
private static final String TAG_EMPTY = " " + PREFIX_TAG;
private static final String MESSAGE_INVALID_FORMAT =
- String.format(MESSAGE_INVALID_COMMAND_FORMAT, EditCommand.MESSAGE_USAGE);
+ String.format(MESSAGE_INVALID_COMMAND_FORMAT, EditCommand.MESSAGE_USAGE);
private EditCommandParser parser = new EditCommandParser();
@@ -86,7 +86,9 @@ public void parse_invalidValue_failure() {
assertParseFailure(parser, "1" + INVALID_NAME_DESC, Name.MESSAGE_CONSTRAINTS); // invalid name
assertParseFailure(parser, "1" + INVALID_PHONE_DESC, Phone.MESSAGE_CONSTRAINTS); // invalid phone
assertParseFailure(parser, "1" + INVALID_EMAIL_DESC, Email.MESSAGE_CONSTRAINTS); // invalid email
- assertParseFailure(parser, "1" + INVALID_ADDRESS_DESC, Address.MESSAGE_CONSTRAINTS); // invalid address
+ assertParseFailure(parser, "1"
+ + INVALID_MATRICULATIONNUM_DESC, MatriculationNumber.MESSAGE_CONSTRAINTS); // invalid
+ // matriculation number
assertParseFailure(parser, "1" + INVALID_TAG_DESC, Tag.MESSAGE_CONSTRAINTS); // invalid tag
// invalid phone followed by valid email
@@ -94,24 +96,37 @@ public void parse_invalidValue_failure() {
// while parsing {@code PREFIX_TAG} alone will reset the tags of the {@code Person} being edited,
// parsing it together with a valid tag results in error
- assertParseFailure(parser, "1" + TAG_DESC_FRIEND + TAG_DESC_HUSBAND + TAG_EMPTY, Tag.MESSAGE_CONSTRAINTS);
- assertParseFailure(parser, "1" + TAG_DESC_FRIEND + TAG_EMPTY + TAG_DESC_HUSBAND, Tag.MESSAGE_CONSTRAINTS);
- assertParseFailure(parser, "1" + TAG_EMPTY + TAG_DESC_FRIEND + TAG_DESC_HUSBAND, Tag.MESSAGE_CONSTRAINTS);
+ assertParseFailure(parser, "1"
+ + TAG_DESC_FRIEND
+ + TAG_DESC_HUSBAND
+ + TAG_EMPTY, Tag.MESSAGE_CONSTRAINTS);
+ assertParseFailure(parser, "1"
+ + TAG_DESC_FRIEND
+ + TAG_EMPTY
+ + TAG_DESC_HUSBAND, Tag.MESSAGE_CONSTRAINTS);
+ assertParseFailure(parser, "1"
+ + TAG_EMPTY
+ + TAG_DESC_FRIEND
+ + TAG_DESC_HUSBAND, Tag.MESSAGE_CONSTRAINTS);
// multiple invalid values, but only the first invalid value is captured
- assertParseFailure(parser, "1" + INVALID_NAME_DESC + INVALID_EMAIL_DESC + VALID_ADDRESS_AMY + VALID_PHONE_AMY,
- Name.MESSAGE_CONSTRAINTS);
+ assertParseFailure(parser, "1"
+ + INVALID_NAME_DESC
+ + INVALID_EMAIL_DESC
+ + VALID_MATRICULATIONNUM_AMY
+ + VALID_PHONE_AMY,
+ Name.MESSAGE_CONSTRAINTS);
}
@Test
public void parse_allFieldsSpecified_success() {
Index targetIndex = INDEX_SECOND_PERSON;
String userInput = targetIndex.getOneBased() + PHONE_DESC_BOB + TAG_DESC_HUSBAND
- + EMAIL_DESC_AMY + ADDRESS_DESC_AMY + NAME_DESC_AMY + TAG_DESC_FRIEND;
+ + EMAIL_DESC_AMY + MATRICULATIONNUM_DESC_AMY + NAME_DESC_AMY + TAG_DESC_FRIEND;
EditPersonDescriptor descriptor = new EditPersonDescriptorBuilder().withName(VALID_NAME_AMY)
- .withPhone(VALID_PHONE_BOB).withEmail(VALID_EMAIL_AMY).withAddress(VALID_ADDRESS_AMY)
- .withTags(VALID_TAG_HUSBAND, VALID_TAG_FRIEND).build();
+ .withPhone(VALID_PHONE_BOB).withEmail(VALID_EMAIL_AMY).withMatriculationNumber(VALID_MATRICULATIONNUM_AMY)
+ .withTags(VALID_TAG_HUSBAND, VALID_TAG_FRIEND).build();
EditCommand expectedCommand = new EditCommand(targetIndex, descriptor);
assertParseSuccess(parser, userInput, expectedCommand);
@@ -123,7 +138,7 @@ public void parse_someFieldsSpecified_success() {
String userInput = targetIndex.getOneBased() + PHONE_DESC_BOB + EMAIL_DESC_AMY;
EditPersonDescriptor descriptor = new EditPersonDescriptorBuilder().withPhone(VALID_PHONE_BOB)
- .withEmail(VALID_EMAIL_AMY).build();
+ .withEmail(VALID_EMAIL_AMY).build();
EditCommand expectedCommand = new EditCommand(targetIndex, descriptor);
assertParseSuccess(parser, userInput, expectedCommand);
@@ -150,9 +165,10 @@ public void parse_oneFieldSpecified_success() {
expectedCommand = new EditCommand(targetIndex, descriptor);
assertParseSuccess(parser, userInput, expectedCommand);
- // address
- userInput = targetIndex.getOneBased() + ADDRESS_DESC_AMY;
- descriptor = new EditPersonDescriptorBuilder().withAddress(VALID_ADDRESS_AMY).build();
+ // MATRICULATIONNUM
+ userInput = targetIndex.getOneBased() + MATRICULATIONNUM_DESC_AMY;
+ descriptor =
+ new EditPersonDescriptorBuilder().withMatriculationNumber(VALID_MATRICULATIONNUM_AMY).build();
expectedCommand = new EditCommand(targetIndex, descriptor);
assertParseSuccess(parser, userInput, expectedCommand);
@@ -180,19 +196,26 @@ public void parse_multipleRepeatedFields_failure() {
assertParseFailure(parser, userInput, Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE));
// mulltiple valid fields repeated
- userInput = targetIndex.getOneBased() + PHONE_DESC_AMY + ADDRESS_DESC_AMY + EMAIL_DESC_AMY
- + TAG_DESC_FRIEND + PHONE_DESC_AMY + ADDRESS_DESC_AMY + EMAIL_DESC_AMY + TAG_DESC_FRIEND
- + PHONE_DESC_BOB + ADDRESS_DESC_BOB + EMAIL_DESC_BOB + TAG_DESC_HUSBAND;
+ userInput = targetIndex.getOneBased() + PHONE_DESC_AMY + MATRICULATIONNUM_DESC_AMY + EMAIL_DESC_AMY
+ + TAG_DESC_FRIEND + PHONE_DESC_AMY + MATRICULATIONNUM_DESC_AMY + EMAIL_DESC_AMY + TAG_DESC_FRIEND
+ + PHONE_DESC_BOB + MATRICULATIONNUM_DESC_BOB + EMAIL_DESC_BOB + TAG_DESC_HUSBAND;
assertParseFailure(parser, userInput,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE, PREFIX_EMAIL, PREFIX_ADDRESS));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE, PREFIX_EMAIL,
+ PREFIX_MATRICULATIONNUMBER));
// multiple invalid values
- userInput = targetIndex.getOneBased() + INVALID_PHONE_DESC + INVALID_ADDRESS_DESC + INVALID_EMAIL_DESC
- + INVALID_PHONE_DESC + INVALID_ADDRESS_DESC + INVALID_EMAIL_DESC;
+ userInput = targetIndex.getOneBased()
+ + INVALID_PHONE_DESC
+ + INVALID_MATRICULATIONNUM_DESC
+ + INVALID_EMAIL_DESC
+ + INVALID_PHONE_DESC
+ + INVALID_MATRICULATIONNUM_DESC
+ + INVALID_EMAIL_DESC;
assertParseFailure(parser, userInput,
- Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE, PREFIX_EMAIL, PREFIX_ADDRESS));
+ Messages.getErrorMessageForDuplicatePrefixes(PREFIX_PHONE, PREFIX_EMAIL,
+ PREFIX_MATRICULATIONNUMBER));
}
@Test
diff --git a/src/test/java/seedu/address/logic/parser/EditPaymentCommandParserEdgeTest.java b/src/test/java/seedu/address/logic/parser/EditPaymentCommandParserEdgeTest.java
new file mode 100644
index 00000000000..44308d4aa4d
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/EditPaymentCommandParserEdgeTest.java
@@ -0,0 +1,23 @@
+// src/test/java/seedu/address/logic/parser/EditPaymentCommandParserEdgeTest.java
+package seedu.address.logic.parser;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.logic.parser.exceptions.ParseException;
+
+public class EditPaymentCommandParserEdgeTest {
+
+ private final EditPaymentCommandParser parser = new EditPaymentCommandParser();
+
+ @Test
+ public void parse_missingPaymentIndex_failure() {
+ assertThrows(ParseException.class, () -> parser.parse("1 a/10.50"));
+ }
+
+ @Test
+ public void parse_noEditableFields_failure() {
+ assertThrows(ParseException.class, () -> parser.parse("1 p/1"));
+ }
+}
diff --git a/src/test/java/seedu/address/logic/parser/EditPaymentCommandParserTest.java b/src/test/java/seedu/address/logic/parser/EditPaymentCommandParserTest.java
new file mode 100644
index 00000000000..46cbc37fb6a
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/EditPaymentCommandParserTest.java
@@ -0,0 +1,48 @@
+package seedu.address.logic.parser;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.EditPaymentCommand;
+import seedu.address.logic.commands.EditPaymentCommand.EditPaymentDescriptor;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+/**
+ * Tests for {@link EditPaymentCommandParser}.
+ * Expected CLI: PERSON_INDEX p/PAYMENT_INDEX [a/AMOUNT] [d/DATE] [r/REMARKS]
+ */
+public class EditPaymentCommandParserTest {
+
+ private final EditPaymentCommandParser parser = new EditPaymentCommandParser();
+
+ @Test
+ public void parse_minimalFields_success() throws Exception {
+ String input = "1 p/2 r/late fee";
+ EditPaymentCommand actual = parser.parse(input);
+
+ EditPaymentDescriptor d = new EditPaymentDescriptor();
+ d.setRemarks("late fee");
+ EditPaymentCommand expected = new EditPaymentCommand(Index.fromOneBased(1), 2, d);
+
+ assertEquals(expected, actual);
+ }
+
+ @Test
+ public void parse_missingPaymentIndex_failure() {
+ assertThrows(ParseException.class, () -> parser.parse("1 a/10.50"));
+ }
+
+ @Test
+ public void parse_invalidPersonIndex_failure() {
+ assertThrows(ParseException.class, () -> parser.parse("0 p/1 r/x"));
+ assertThrows(ParseException.class, () -> parser.parse("-3 p/1 r/x"));
+ }
+
+ @Test
+ public void parse_noEditableFields_failure() {
+ assertThrows(ParseException.class, () -> parser.parse("1 p/1"));
+ }
+}
diff --git a/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java b/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java
index d92e64d12f9..a9a657ad180 100644
--- a/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java
+++ b/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java
@@ -1,15 +1,14 @@
package seedu.address.logic.parser;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
-import static seedu.address.logic.parser.CommandParserTestUtil.assertParseSuccess;
-
-import java.util.Arrays;
import org.junit.jupiter.api.Test;
import seedu.address.logic.commands.FindCommand;
-import seedu.address.model.person.NameContainsKeywordsPredicate;
+
public class FindCommandParserTest {
@@ -17,18 +16,20 @@ public class FindCommandParserTest {
@Test
public void parse_emptyArg_throwsParseException() {
- assertParseFailure(parser, " ", String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, " ",
+ String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE));
}
@Test
public void parse_validArgs_returnsFindCommand() {
- // no leading and trailing whitespaces
- FindCommand expectedFindCommand =
- new FindCommand(new NameContainsKeywordsPredicate(Arrays.asList("Alice", "Bob")));
- assertParseSuccess(parser, "Alice Bob", expectedFindCommand);
-
- // multiple whitespaces between keywords
- assertParseSuccess(parser, " \n Alice \n \t Bob \t", expectedFindCommand);
+ assertDoesNotThrow(() -> {
+ FindCommand cmd1 = parser.parse("Alice Bob");
+ assertTrue(cmd1 instanceof FindCommand);
+ });
+
+ assertDoesNotThrow(() -> {
+ FindCommand cmd2 = parser.parse(" \n Alice \n \t Bob \t");
+ assertTrue(cmd2 instanceof FindCommand);
+ });
}
-
}
diff --git a/src/test/java/seedu/address/logic/parser/FindPaymentCommandParserTest.java b/src/test/java/seedu/address/logic/parser/FindPaymentCommandParserTest.java
new file mode 100644
index 00000000000..9bb8980217f
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/FindPaymentCommandParserTest.java
@@ -0,0 +1,76 @@
+package seedu.address.logic.parser;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.FindPaymentCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+import seedu.address.model.payment.Amount;
+
+public class FindPaymentCommandParserTest {
+
+ private final FindPaymentCommandParser parser = new FindPaymentCommandParser();
+
+ @Test
+ public void parse_validAmount_returnsCommand() throws Exception {
+ FindPaymentCommand command = parser.parse("1 a/50.00");
+ FindPaymentCommand expected = new FindPaymentCommand(Index.fromOneBased(1),
+ new Amount(new BigDecimal("50.00")), null, null);
+ assertEquals(expected, command);
+ }
+
+ @Test
+ public void parse_validRemark_returnsCommand() throws Exception {
+ FindPaymentCommand command = parser.parse("1 r/CCA");
+ FindPaymentCommand expected = new FindPaymentCommand(Index.fromOneBased(1), null, "CCA", null);
+ assertEquals(expected, command);
+ }
+
+ @Test
+ public void parse_validDate_returnsCommand() throws Exception {
+ LocalDate date = LocalDate.of(2025, 1, 1);
+ FindPaymentCommand command = parser.parse("1 d/2025-01-01");
+ FindPaymentCommand expected = new FindPaymentCommand(Index.fromOneBased(1), null, null, date);
+ assertEquals(expected, command);
+ }
+
+ @Test
+ public void parse_missingFilter_throwsParseException() {
+ ParseException e = assertThrows(ParseException.class, () -> parser.parse("1"));
+ assertEquals("Please provide one filter: a/AMOUNT, d/DATE or r/REMARK", e.getMessage());
+ }
+
+ @Test
+ public void parse_multipleFilters_throwsParseException() {
+ ParseException e = assertThrows(ParseException.class, () -> parser.parse("1 a/50.00 r/CCA"));
+ assertEquals("Please specify only one filter at a time.", e.getMessage());
+ }
+
+ @Test
+ public void parse_invalidAmount_throwsParseException() {
+ ParseException e = assertThrows(ParseException.class, () -> parser.parse("1 a/abc"));
+ assertEquals("Invalid amount: must be positive, ≤ 2 decimal places, and at most 1 million.", e.getMessage());
+ }
+
+ @Test
+ public void parse_invalidDate_throwsParseException() {
+ ParseException e = assertThrows(ParseException.class, () -> parser.parse("1 d/2025-31-12"));
+ assertEquals(
+ "Invalid date. Please use the strict format YYYY-MM-DD and ensure it is not in the future.",
+ e.getMessage()
+ );
+ }
+
+
+ @Test
+ public void parse_emptyRemark_throwsParseException() {
+ ParseException e = assertThrows(ParseException.class, () -> parser.parse("1 r/ "));
+ assertEquals("Remark cannot be empty.", e.getMessage());
+ }
+}
diff --git a/src/test/java/seedu/address/logic/parser/ListArchivedCommandParserTest.java b/src/test/java/seedu/address/logic/parser/ListArchivedCommandParserTest.java
new file mode 100644
index 00000000000..ad4e150b22f
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/ListArchivedCommandParserTest.java
@@ -0,0 +1,47 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseSuccess;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.logic.commands.ListArchivedCommand;
+
+public class ListArchivedCommandParserTest {
+
+ private final ListArchivedCommandParser parser = new ListArchivedCommandParser();
+
+ @Test
+ public void parse_empty_success() {
+ assertParseSuccess(parser, "", new ListArchivedCommand());
+ }
+
+ @Test
+ public void parse_whitespace_success() {
+ assertParseSuccess(parser, " \t \n ", new ListArchivedCommand());
+ }
+
+ @Test
+ public void parseExtraArgs_throwsParseException() {
+ assertParseFailure(
+ parser,
+ " anything",
+ String.format(seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ListArchivedCommand.MESSAGE_USAGE)
+ );
+ assertParseFailure(
+ parser,
+ " 123",
+ String.format(seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ListArchivedCommand.MESSAGE_USAGE)
+ );
+ assertParseFailure(
+ parser,
+ " archived please",
+ String.format(seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ ListArchivedCommand.MESSAGE_USAGE)
+ );
+ }
+
+}
+
diff --git a/src/test/java/seedu/address/logic/parser/ParserUtilTest.java b/src/test/java/seedu/address/logic/parser/ParserUtilTest.java
index 4256788b1a7..24c8d9a1d6e 100644
--- a/src/test/java/seedu/address/logic/parser/ParserUtilTest.java
+++ b/src/test/java/seedu/address/logic/parser/ParserUtilTest.java
@@ -14,8 +14,8 @@
import org.junit.jupiter.api.Test;
import seedu.address.logic.parser.exceptions.ParseException;
-import seedu.address.model.person.Address;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Phone;
import seedu.address.model.tag.Tag;
@@ -23,13 +23,13 @@
public class ParserUtilTest {
private static final String INVALID_NAME = "R@chel";
private static final String INVALID_PHONE = "+651234";
- private static final String INVALID_ADDRESS = " ";
+ private static final String INVALID_MATRICULATIONNUM = " ";
private static final String INVALID_EMAIL = "example.com";
private static final String INVALID_TAG = "#friend";
private static final String VALID_NAME = "Rachel Walker";
- private static final String VALID_PHONE = "123456";
- private static final String VALID_ADDRESS = "123 Main Street #0505";
+ private static final String VALID_PHONE = "12457890";
+ private static final String VALID_MATRICULATIONNUM = "A4444444Z";
private static final String VALID_EMAIL = "rachel@example.com";
private static final String VALID_TAG_1 = "friend";
private static final String VALID_TAG_2 = "neighbour";
@@ -109,20 +109,20 @@ public void parseAddress_null_throwsNullPointerException() {
@Test
public void parseAddress_invalidValue_throwsParseException() {
- assertThrows(ParseException.class, () -> ParserUtil.parseAddress(INVALID_ADDRESS));
+ assertThrows(ParseException.class, () -> ParserUtil.parseAddress(INVALID_MATRICULATIONNUM));
}
@Test
public void parseAddress_validValueWithoutWhitespace_returnsAddress() throws Exception {
- Address expectedAddress = new Address(VALID_ADDRESS);
- assertEquals(expectedAddress, ParserUtil.parseAddress(VALID_ADDRESS));
+ MatriculationNumber expectedMatriculationNum = new MatriculationNumber(VALID_MATRICULATIONNUM);
+ assertEquals(expectedMatriculationNum, ParserUtil.parseAddress(VALID_MATRICULATIONNUM));
}
@Test
- public void parseAddress_validValueWithWhitespace_returnsTrimmedAddress() throws Exception {
- String addressWithWhitespace = WHITESPACE + VALID_ADDRESS + WHITESPACE;
- Address expectedAddress = new Address(VALID_ADDRESS);
- assertEquals(expectedAddress, ParserUtil.parseAddress(addressWithWhitespace));
+ public void parseMatriculationNum_validValueWithWhitespace_returnsTrimmedAddress() throws Exception {
+ String matriculationNumWithWhitespace = WHITESPACE + VALID_MATRICULATIONNUM + WHITESPACE;
+ MatriculationNumber expectedAddress = new MatriculationNumber(VALID_MATRICULATIONNUM);
+ assertEquals(expectedAddress, ParserUtil.parseAddress(matriculationNumWithWhitespace));
}
@Test
@@ -178,7 +178,8 @@ public void parseTags_null_throwsNullPointerException() {
@Test
public void parseTags_collectionWithInvalidTags_throwsParseException() {
- assertThrows(ParseException.class, () -> ParserUtil.parseTags(Arrays.asList(VALID_TAG_1, INVALID_TAG)));
+ assertThrows(ParseException.class, () -> ParserUtil.parseTags(Arrays.asList(VALID_TAG_1,
+ INVALID_TAG)));
}
@Test
diff --git a/src/test/java/seedu/address/logic/parser/RedoCommandParserTest.java b/src/test/java/seedu/address/logic/parser/RedoCommandParserTest.java
new file mode 100644
index 00000000000..6f29c226b07
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/RedoCommandParserTest.java
@@ -0,0 +1,46 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseSuccess;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.logic.commands.RedoCommand;
+
+public class RedoCommandParserTest {
+
+ private final RedoCommandParser parser = new RedoCommandParser();
+
+ @Test
+ public void parseEmpty_success() {
+ assertParseSuccess(parser, "", new RedoCommand());
+ }
+
+ @Test
+ public void parseWhitespace_success() {
+ assertParseSuccess(parser, " \t \n ", new RedoCommand());
+ }
+
+ @Test
+ public void parseExtraArgs_throwsParseException() {
+ assertParseFailure(
+ parser,
+ " please",
+ String.format(seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ RedoCommand.MESSAGE_USAGE)
+ );
+ assertParseFailure(
+ parser,
+ " 123",
+ String.format(seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ RedoCommand.MESSAGE_USAGE)
+ );
+ assertParseFailure(
+ parser,
+ " again",
+ String.format(seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ RedoCommand.MESSAGE_USAGE)
+ );
+ }
+
+}
diff --git a/src/test/java/seedu/address/logic/parser/UnarchiveCommandParserTest.java b/src/test/java/seedu/address/logic/parser/UnarchiveCommandParserTest.java
new file mode 100644
index 00000000000..97f30bd8619
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/UnarchiveCommandParserTest.java
@@ -0,0 +1,84 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseSuccess;
+import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON;
+import static seedu.address.testutil.TypicalIndexes.INDEX_SECOND_PERSON;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.logic.commands.UnarchiveCommand;
+
+/**
+ * Tests for UnarchiveCommandParser with 100% coverage.
+ */
+public class UnarchiveCommandParserTest {
+
+ private final UnarchiveCommandParser parser = new UnarchiveCommandParser();
+
+ @Test
+ public void parse_emptyArgs_throwsParseException() {
+ assertParseFailure(parser, "",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UnarchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, " ",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UnarchiveCommand.MESSAGE_USAGE));
+ }
+
+ @Test
+ public void parse_onlyCommas_throwsParseException() {
+ assertParseFailure(parser, ",,,",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UnarchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, " , , ",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UnarchiveCommand.MESSAGE_USAGE));
+ }
+
+ @Test
+ public void parse_nonNumericToken_throwsParseException() {
+ assertParseFailure(parser, "one",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UnarchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, "1, x",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UnarchiveCommand.MESSAGE_USAGE));
+ }
+
+ @Test
+ public void parse_zeroOrNegativeIndex_throwsParseException() {
+ assertParseFailure(parser, "0",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UnarchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, "-1",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UnarchiveCommand.MESSAGE_USAGE));
+ assertParseFailure(parser, "1, -2",
+ String.format(seedu.address.commons.core.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UnarchiveCommand.MESSAGE_USAGE));
+ }
+
+ @Test
+ public void parse_singleIndex_success() {
+ UnarchiveCommand expected = new UnarchiveCommand(Arrays.asList(INDEX_FIRST_PERSON));
+ assertParseSuccess(parser, "1", expected);
+ assertParseSuccess(parser, " 1 ", expected);
+ }
+
+ @Test
+ public void parseMultipleIndicesWithSpaces_success() {
+ UnarchiveCommand expected = new UnarchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+ assertParseSuccess(parser, "1,2", expected);
+ assertParseSuccess(parser, " 1 , 2 ", expected);
+ assertParseSuccess(parser, " 1 , 2 , ", expected);
+ }
+
+ @Test
+ public void parseDuplicatesInput_success_dedupByCommand() {
+ UnarchiveCommand expected = new UnarchiveCommand(Arrays.asList(INDEX_FIRST_PERSON, INDEX_SECOND_PERSON));
+ assertParseSuccess(parser, "1,1,2,1", expected);
+ }
+}
diff --git a/src/test/java/seedu/address/logic/parser/UndoCommandParserTest.java b/src/test/java/seedu/address/logic/parser/UndoCommandParserTest.java
new file mode 100644
index 00000000000..1903f83b65a
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/UndoCommandParserTest.java
@@ -0,0 +1,47 @@
+package seedu.address.logic.parser;
+
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure;
+import static seedu.address.logic.parser.CommandParserTestUtil.assertParseSuccess;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.logic.commands.UndoCommand;
+
+public class UndoCommandParserTest {
+
+ private final UndoCommandParser parser = new UndoCommandParser();
+
+ @Test
+ public void parseEmpty_success() {
+ assertParseSuccess(parser, "", new UndoCommand());
+ }
+
+ @Test
+ public void parseWhitespace_success() {
+ assertParseSuccess(parser, " \t \n ", new UndoCommand());
+ }
+
+ @Test
+ public void parseExtraArgs_throwsParseException() {
+ assertParseFailure(
+ parser,
+ " please",
+ String.format(seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UndoCommand.MESSAGE_USAGE)
+ );
+ assertParseFailure(
+ parser,
+ " 123",
+ String.format(seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UndoCommand.MESSAGE_USAGE)
+ );
+ assertParseFailure(
+ parser,
+ " now",
+ String.format(seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT,
+ UndoCommand.MESSAGE_USAGE)
+ );
+ }
+
+}
+
diff --git a/src/test/java/seedu/address/logic/parser/ViewPaymentCommandParserTest.java b/src/test/java/seedu/address/logic/parser/ViewPaymentCommandParserTest.java
new file mode 100644
index 00000000000..bdd42d1a401
--- /dev/null
+++ b/src/test/java/seedu/address/logic/parser/ViewPaymentCommandParserTest.java
@@ -0,0 +1,38 @@
+package seedu.address.logic.parser;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.commons.core.index.Index;
+import seedu.address.logic.commands.ViewPaymentCommand;
+import seedu.address.logic.parser.exceptions.ParseException;
+
+public class ViewPaymentCommandParserTest {
+
+ private final ViewPaymentCommandParser parser = new ViewPaymentCommandParser();
+
+ @Test
+ public void parse_validIndex_success() throws Exception {
+ ViewPaymentCommand cmd = parser.parse("1");
+ assertEquals(new ViewPaymentCommand(Index.fromOneBased(1)), cmd);
+ }
+
+ @Test
+ public void parse_zeroIndex_failure() {
+ assertThrows(ParseException.class, () -> parser.parse("0"));
+ }
+
+ @Test
+ public void parse_negativeIndex_failure() {
+ assertThrows(ParseException.class, () -> parser.parse("-3"));
+ }
+
+ @Test
+ public void parse_nonInteger_failure() {
+ assertThrows(ParseException.class, () -> parser.parse("abc"));
+ assertThrows(ParseException.class, () -> parser.parse("1.2"));
+ assertThrows(ParseException.class, () -> parser.parse(""));
+ }
+}
diff --git a/src/test/java/seedu/address/model/AddressBookTest.java b/src/test/java/seedu/address/model/AddressBookTest.java
index 68c8c5ba4d5..844fc045958 100644
--- a/src/test/java/seedu/address/model/AddressBookTest.java
+++ b/src/test/java/seedu/address/model/AddressBookTest.java
@@ -3,7 +3,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static seedu.address.logic.commands.CommandTestUtil.VALID_ADDRESS_BOB;
+import static seedu.address.logic.commands.CommandTestUtil.VALID_NAME_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_TAG_HUSBAND;
import static seedu.address.testutil.Assert.assertThrows;
import static seedu.address.testutil.TypicalPersons.ALICE;
@@ -46,7 +46,8 @@ public void resetData_withValidReadOnlyAddressBook_replacesData() {
@Test
public void resetData_withDuplicatePersons_throwsDuplicatePersonException() {
// Two persons with the same identity fields
- Person editedAlice = new PersonBuilder(ALICE).withAddress(VALID_ADDRESS_BOB).withTags(VALID_TAG_HUSBAND)
+ Person editedAlice =
+ new PersonBuilder(ALICE).withName(VALID_NAME_BOB).withTags(VALID_TAG_HUSBAND)
.build();
List newPersons = Arrays.asList(ALICE, editedAlice);
AddressBookStub newData = new AddressBookStub(newPersons);
@@ -73,7 +74,8 @@ public void hasPerson_personInAddressBook_returnsTrue() {
@Test
public void hasPerson_personWithSameIdentityFieldsInAddressBook_returnsTrue() {
addressBook.addPerson(ALICE);
- Person editedAlice = new PersonBuilder(ALICE).withAddress(VALID_ADDRESS_BOB).withTags(VALID_TAG_HUSBAND)
+ Person editedAlice =
+ new PersonBuilder(ALICE).withName(VALID_NAME_BOB).withTags(VALID_TAG_HUSBAND)
.build();
assertTrue(addressBook.hasPerson(editedAlice));
}
@@ -85,7 +87,10 @@ public void getPersonList_modifyList_throwsUnsupportedOperationException() {
@Test
public void toStringMethod() {
- String expected = AddressBook.class.getCanonicalName() + "{persons=" + addressBook.getPersonList() + "}";
+ String expected = AddressBook.class.getCanonicalName()
+ + "{persons="
+ + addressBook.getPersonList()
+ + "}";
assertEquals(expected, addressBook.toString());
}
diff --git a/src/test/java/seedu/address/model/person/AddressTest.java b/src/test/java/seedu/address/model/person/AddressTest.java
deleted file mode 100644
index 314885eca26..00000000000
--- a/src/test/java/seedu/address/model/person/AddressTest.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package seedu.address.model.person;
-
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static seedu.address.testutil.Assert.assertThrows;
-
-import org.junit.jupiter.api.Test;
-
-public class AddressTest {
-
- @Test
- public void constructor_null_throwsNullPointerException() {
- assertThrows(NullPointerException.class, () -> new Address(null));
- }
-
- @Test
- public void constructor_invalidAddress_throwsIllegalArgumentException() {
- String invalidAddress = "";
- assertThrows(IllegalArgumentException.class, () -> new Address(invalidAddress));
- }
-
- @Test
- public void isValidAddress() {
- // null address
- assertThrows(NullPointerException.class, () -> Address.isValidAddress(null));
-
- // invalid addresses
- assertFalse(Address.isValidAddress("")); // empty string
- assertFalse(Address.isValidAddress(" ")); // spaces only
-
- // valid addresses
- assertTrue(Address.isValidAddress("Blk 456, Den Road, #01-355"));
- assertTrue(Address.isValidAddress("-")); // one character
- assertTrue(Address.isValidAddress("Leng Inc; 1234 Market St; San Francisco CA 2349879; USA")); // long address
- }
-
- @Test
- public void equals() {
- Address address = new Address("Valid Address");
-
- // same values -> returns true
- assertTrue(address.equals(new Address("Valid Address")));
-
- // same object -> returns true
- assertTrue(address.equals(address));
-
- // null -> returns false
- assertFalse(address.equals(null));
-
- // different types -> returns false
- assertFalse(address.equals(5.0f));
-
- // different values -> returns false
- assertFalse(address.equals(new Address("Other Valid Address")));
- }
-}
diff --git a/src/test/java/seedu/address/model/person/MatriculationNumberTest.java b/src/test/java/seedu/address/model/person/MatriculationNumberTest.java
new file mode 100644
index 00000000000..59ec312d179
--- /dev/null
+++ b/src/test/java/seedu/address/model/person/MatriculationNumberTest.java
@@ -0,0 +1,117 @@
+package seedu.address.model.person;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import seedu.address.model.person.exceptions.InvalidMatriculationNumberException;
+
+/**
+ * Unit tests for {@link MatriculationNumber}.
+ */
+public class MatriculationNumberTest {
+
+ // ✅ ---------------- VALID CASES ----------------
+ @Test
+ public void constructor_validMatriculationNumber_success() {
+ MatriculationNumber m = new MatriculationNumber("A1234567X");
+ assertEquals("A1234567X", m.value);
+ }
+
+ @Test
+ public void constructor_lowercase_convertedToUppercase() {
+ MatriculationNumber m = new MatriculationNumber("a1234567b");
+ assertEquals("A1234567B", m.value);
+ }
+
+ @Test
+ public void isValidMatriculationNumber_validExamples_returnTrue() {
+ assertTrue(MatriculationNumber.isValidMatriculationNumber("A1234567X"));
+ assertTrue(MatriculationNumber.isValidMatriculationNumber("A7172828B"));
+ assertTrue(MatriculationNumber.isValidMatriculationNumber("A0000000Z"));
+ }
+
+ @Test
+ public void constructor_null_throwsNullPointerException() {
+ assertThrows(NullPointerException.class, () -> new MatriculationNumber(null));
+ }
+
+ @Test
+ public void constructor_invalidMatriculationNumber_throwsIllegalArgumentException() {
+ // too short
+ assertThrows(InvalidMatriculationNumberException.class, () -> new MatriculationNumber("A123B"));
+ // too long (11 chars)
+ assertThrows(InvalidMatriculationNumberException.class, () -> new MatriculationNumber("A123456789"));
+ // double letters at end (11 chars)
+ assertThrows(InvalidMatriculationNumberException.class, () -> new MatriculationNumber("A1234567BB"));
+ // lowercase ending
+ assertThrows(InvalidMatriculationNumberException.class, () -> new MatriculationNumber("A12345678bb"));
+ // starts with lowercase letter
+ assertThrows(InvalidMatriculationNumberException.class, () -> new MatriculationNumber("b12312311b"));
+ // starts with wrong letter
+ assertThrows(InvalidMatriculationNumberException.class, () -> new MatriculationNumber("C12312311B"));
+ // starts with digit
+ assertThrows(InvalidMatriculationNumberException.class, () -> new MatriculationNumber("12312312HH"));
+ // no digits in middle
+ assertThrows(InvalidMatriculationNumberException.class, () -> new MatriculationNumber("AABCDEFGHX"));
+ // only 10 total characters
+ assertThrows(InvalidMatriculationNumberException.class, () -> new MatriculationNumber("A01234567B"));
+ }
+
+ @Test
+ public void isValidMatriculationNumber_invalidExamples_returnFalse() {
+ String[] invalids = {
+ "", " ", "A", "A123B", "A123456789", "A1234567BB",
+ "a01234567b", "B01234567X", "C12312311B", "12312312HH",
+ "AABCDEFGHX", "A1234567@", "A12", "A00000000", "Z00000000X"
+ };
+ for (String test : invalids) {
+ assertFalse(MatriculationNumber.isValidMatriculationNumber(test), "Failed at: " + test);
+ }
+ }
+
+ @Test
+ public void equals_sameValue_true() {
+ MatriculationNumber m1 = new MatriculationNumber("A1234567X");
+ MatriculationNumber m2 = new MatriculationNumber("a1234567x");
+ assertEquals(m1, m2);
+ }
+
+ @Test
+ public void equals_differentValue_false() {
+ MatriculationNumber m1 = new MatriculationNumber("A1234567X");
+ MatriculationNumber m2 = new MatriculationNumber("A1234567Y");
+ assertNotEquals(m1, m2);
+ }
+
+ @Test
+ public void equals_sameObject_true() {
+ MatriculationNumber m = new MatriculationNumber("A1234567X");
+ assertTrue(m.equals(m)); // self equality
+ }
+
+ @Test
+ public void equals_differentType_false() {
+ MatriculationNumber m = new MatriculationNumber("A1234567X");
+ assertFalse(m.equals("A1234567X")); // different type
+ assertFalse(m.equals(123)); // integer
+ }
+
+ @Test
+ public void hashCode_sameValue_sameHash() {
+ MatriculationNumber m1 = new MatriculationNumber("A1234567X");
+ MatriculationNumber m2 = new MatriculationNumber("a1234567x");
+ assertEquals(m1.hashCode(), m2.hashCode());
+ }
+
+ @Test
+ public void toString_returnsValue() {
+ MatriculationNumber m = new MatriculationNumber("A1234567X");
+ assertEquals("A1234567X", m.toString());
+ }
+
+}
diff --git a/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java b/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java
index 6b3fd90ade7..51359ba8909 100644
--- a/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java
+++ b/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java
@@ -19,14 +19,17 @@ public void equals() {
List firstPredicateKeywordList = Collections.singletonList("first");
List secondPredicateKeywordList = Arrays.asList("first", "second");
- NameContainsKeywordsPredicate firstPredicate = new NameContainsKeywordsPredicate(firstPredicateKeywordList);
- NameContainsKeywordsPredicate secondPredicate = new NameContainsKeywordsPredicate(secondPredicateKeywordList);
+ NameContainsKeywordsPredicate firstPredicate =
+ new NameContainsKeywordsPredicate(firstPredicateKeywordList);
+ NameContainsKeywordsPredicate secondPredicate =
+ new NameContainsKeywordsPredicate(secondPredicateKeywordList);
// same object -> returns true
assertTrue(firstPredicate.equals(firstPredicate));
// same values -> returns true
- NameContainsKeywordsPredicate firstPredicateCopy = new NameContainsKeywordsPredicate(firstPredicateKeywordList);
+ NameContainsKeywordsPredicate firstPredicateCopy =
+ new NameContainsKeywordsPredicate(firstPredicateKeywordList);
assertTrue(firstPredicate.equals(firstPredicateCopy));
// different types -> returns false
@@ -42,7 +45,8 @@ public void equals() {
@Test
public void test_nameContainsKeywords_returnsTrue() {
// One keyword
- NameContainsKeywordsPredicate predicate = new NameContainsKeywordsPredicate(Collections.singletonList("Alice"));
+ NameContainsKeywordsPredicate predicate =
+ new NameContainsKeywordsPredicate(Collections.singletonList("Alice"));
assertTrue(predicate.test(new PersonBuilder().withName("Alice Bob").build()));
// Multiple keywords
@@ -68,18 +72,26 @@ public void test_nameDoesNotContainKeywords_returnsFalse() {
predicate = new NameContainsKeywordsPredicate(Arrays.asList("Carol"));
assertFalse(predicate.test(new PersonBuilder().withName("Alice Bob").build()));
- // Keywords match phone, email and address, but does not match name
- predicate = new NameContainsKeywordsPredicate(Arrays.asList("12345", "alice@email.com", "Main", "Street"));
- assertFalse(predicate.test(new PersonBuilder().withName("Alice").withPhone("12345")
- .withEmail("alice@email.com").withAddress("Main Street").build()));
+ // Keywords match phone, email, and matriculation number, but do not match name
+ predicate = new NameContainsKeywordsPredicate(Arrays.asList("61627364", "alice@email.com", "A1818182D"
+ ));
+ assertFalse(predicate.test(new PersonBuilder().withName("Alice")
+ .withPhone("61627364")
+ .withEmail("alice@email.com")
+ .withMatriculationNumber("A1818182D")
+ .build()));
}
+
@Test
public void toStringMethod() {
List keywords = List.of("keyword1", "keyword2");
NameContainsKeywordsPredicate predicate = new NameContainsKeywordsPredicate(keywords);
- String expected = NameContainsKeywordsPredicate.class.getCanonicalName() + "{keywords=" + keywords + "}";
+ String expected = NameContainsKeywordsPredicate.class.getCanonicalName()
+ + "{keywords="
+ + keywords
+ + "}";
assertEquals(expected, predicate.toString());
}
}
diff --git a/src/test/java/seedu/address/model/person/NameTest.java b/src/test/java/seedu/address/model/person/NameTest.java
index 94e3dd726bd..df246c75724 100644
--- a/src/test/java/seedu/address/model/person/NameTest.java
+++ b/src/test/java/seedu/address/model/person/NameTest.java
@@ -1,60 +1,151 @@
package seedu.address.model.person;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static seedu.address.testutil.Assert.assertThrows;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
public class NameTest {
+ // ---------------------------
+ // Constructor behavior
+ // ---------------------------
+
@Test
+ @DisplayName("constructor(null) -> NullPointerException")
public void constructor_null_throwsNullPointerException() {
assertThrows(NullPointerException.class, () -> new Name(null));
}
@Test
+ @DisplayName("constructor(invalid) -> IllegalArgumentException")
public void constructor_invalidName_throwsIllegalArgumentException() {
- String invalidName = "";
- assertThrows(IllegalArgumentException.class, () -> new Name(invalidName));
+ // A representative set; full invalid set is covered below in isValidName()
+ assertThrows(IllegalArgumentException.class, () -> new Name(""));
+ assertThrows(IllegalArgumentException.class, () -> new Name(" "));
+ assertThrows(IllegalArgumentException.class, () -> new Name("John2"));
+ assertThrows(IllegalArgumentException.class, () -> new Name(" John"));
+ assertThrows(IllegalArgumentException.class, () -> new Name("John@"));
}
+ // ---------------------------
+ // Validation behavior
+ // ---------------------------
+
@Test
- public void isValidName() {
- // null name
+ @DisplayName("isValidName(null) -> NullPointerException")
+ public void isValidName_null_throwsNullPointerException() {
assertThrows(NullPointerException.class, () -> Name.isValidName(null));
+ }
- // invalid name
+ @Test
+ @DisplayName("isValidName(invalid cases)")
+ public void isValidName_invalid() {
+ // Empty / whitespace-only
assertFalse(Name.isValidName("")); // empty string
- assertFalse(Name.isValidName(" ")); // spaces only
- assertFalse(Name.isValidName("^")); // only non-alphanumeric characters
- assertFalse(Name.isValidName("peter*")); // contains non-alphanumeric characters
+ assertFalse(Name.isValidName(" ")); // one space
+ assertFalse(Name.isValidName(" ")); // multiple spaces
+
+ // Must start with a letter
+ assertFalse(Name.isValidName(" John")); // leading space
+ assertFalse(Name.isValidName("-John")); // leading hyphen
+ assertFalse(Name.isValidName("'John")); // leading apostrophe
+ assertFalse(Name.isValidName(".John")); // leading period
+ assertFalse(Name.isValidName("2John")); // leading digit
+
+ // Disallow digits anywhere
+ assertFalse(Name.isValidName("John2"));
+ assertFalse(Name.isValidName("peter the 2nd"));
+ assertFalse(Name.isValidName("12345"));
- // valid name
- assertTrue(Name.isValidName("peter jack")); // alphabets only
- assertTrue(Name.isValidName("12345")); // numbers only
- assertTrue(Name.isValidName("peter the 2nd")); // alphanumeric characters
- assertTrue(Name.isValidName("Capital Tan")); // with capital letters
- assertTrue(Name.isValidName("David Roger Jackson Ray Jr 2nd")); // long names
+ // Disallow other symbols
+ assertFalse(Name.isValidName("^"));
+ assertFalse(Name.isValidName("peter*"));
+ assertFalse(Name.isValidName("John@Doe"));
+ assertFalse(Name.isValidName("John_Doe")); // underscore not allowed
+
+ // Edge: only punctuation (no letters)
+ assertFalse(Name.isValidName(".'-")); // has allowed punctuation but no letters
+ assertFalse(Name.isValidName(".")); // single period, no letters
+ assertFalse(Name.isValidName("-")); // single hyphen, no letters
+ assertFalse(Name.isValidName("'")); // single apostrophe, no letters
}
@Test
- public void equals() {
- Name name = new Name("Valid Name");
+ @DisplayName("isValidName(valid cases)")
+ public void isValidName_valid() {
+ // Simple alphabetic
+ assertTrue(Name.isValidName("peter jack"));
+ assertTrue(Name.isValidName("Capital Tan"));
+ assertTrue(Name.isValidName("A")); // single-letter name
+
+ // Common punctuation in names
+ assertTrue(Name.isValidName("Jean-Luc Picard")); // hyphen
+ assertTrue(Name.isValidName("Anne-Marie")); // hyphen internal
+ assertTrue(Name.isValidName("O'Connor")); // apostrophe (ASCII)
+ assertTrue(Name.isValidName("J. K. Rowling")); // periods in initials
+ assertTrue(Name.isValidName("Mary Jane")); // single space between words
- // same values -> returns true
- assertTrue(name.equals(new Name("Valid Name")));
+ // Unicode letters (international names)
+ assertTrue(Name.isValidName("José María")); // accented letters
- // same object -> returns true
- assertTrue(name.equals(name));
+ // Boundary around starting-with-letter rule
+ assertTrue(Name.isValidName("A.")); // letter + period
+ assertTrue(Name.isValidName("J.")); // initial style
+ }
- // null -> returns false
- assertFalse(name.equals(null));
+ // ---------------------------
+ // equals / hashCode / toString
+ // ---------------------------
+
+ @Test
+ @DisplayName("equals: reflexive, symmetric, transitive, and null/type safety")
+ public void equals_contract() {
+ Name a1 = new Name("Valid Name");
+ Name a2 = new Name("Valid Name");
+ Name b = new Name("Other Valid Name");
- // different types -> returns false
- assertFalse(name.equals(5.0f));
+ // Reflexive
+ assertTrue(a1.equals(a1));
- // different values -> returns false
- assertFalse(name.equals(new Name("Other Valid Name")));
+ // Symmetric
+ assertTrue(a1.equals(a2));
+ assertTrue(a2.equals(a1));
+
+ // Transitive
+ Name a3 = new Name("Valid Name");
+ assertTrue(a1.equals(a2));
+ assertTrue(a2.equals(a3));
+ assertTrue(a1.equals(a3));
+
+ // Consistent & inequality
+ assertFalse(a1.equals(b));
+ assertFalse(b.equals(a1));
+
+ // Null & type safety
+ assertFalse(a1.equals(null));
+ assertFalse(a1.equals(5.0f));
+ }
+
+ @Test
+ @DisplayName("hashCode: equal objects have equal hash codes")
+ public void hashCode_consistency() {
+ Name a1 = new Name("Valid Name");
+ Name a2 = new Name("Valid Name");
+ Name b = new Name("Other Valid Name");
+
+ assertEquals(a1.hashCode(), a2.hashCode());
+ assertNotEquals(a1.hashCode(), b.hashCode());
+ }
+
+ @Test
+ @DisplayName("toString returns the full name verbatim")
+ public void toString_returnsFullName() {
+ assertEquals("Alex Yeoh", new Name("Alex Yeoh").toString());
+ assertEquals("J. K. Rowling", new Name("J. K. Rowling").toString());
}
}
diff --git a/src/test/java/seedu/address/model/person/PersonTest.java b/src/test/java/seedu/address/model/person/PersonTest.java
index 31a10d156c9..69e7be4f704 100644
--- a/src/test/java/seedu/address/model/person/PersonTest.java
+++ b/src/test/java/seedu/address/model/person/PersonTest.java
@@ -3,8 +3,8 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static seedu.address.logic.commands.CommandTestUtil.VALID_ADDRESS_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_EMAIL_BOB;
+import static seedu.address.logic.commands.CommandTestUtil.VALID_MATRICULATIONNUM_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_NAME_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_PHONE_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_TAG_HUSBAND;
@@ -32,23 +32,14 @@ public void isSamePerson() {
// null -> returns false
assertFalse(ALICE.isSamePerson(null));
- // same name, all other attributes different -> returns true
+ // same matriculation number, all other attributes different -> returns true
Person editedAlice = new PersonBuilder(ALICE).withPhone(VALID_PHONE_BOB).withEmail(VALID_EMAIL_BOB)
- .withAddress(VALID_ADDRESS_BOB).withTags(VALID_TAG_HUSBAND).build();
+ .withName(VALID_NAME_BOB).withTags(VALID_TAG_HUSBAND).build();
assertTrue(ALICE.isSamePerson(editedAlice));
- // different name, all other attributes same -> returns false
- editedAlice = new PersonBuilder(ALICE).withName(VALID_NAME_BOB).build();
+ // different matriculation number, all other attributes same -> returns false
+ editedAlice = new PersonBuilder(ALICE).withMatriculationNumber(VALID_MATRICULATIONNUM_BOB).build();
assertFalse(ALICE.isSamePerson(editedAlice));
-
- // name differs in case, all other attributes same -> returns false
- Person editedBob = new PersonBuilder(BOB).withName(VALID_NAME_BOB.toLowerCase()).build();
- assertFalse(BOB.isSamePerson(editedBob));
-
- // name has trailing spaces, all other attributes same -> returns false
- String nameWithTrailingSpaces = VALID_NAME_BOB + " ";
- editedBob = new PersonBuilder(BOB).withName(nameWithTrailingSpaces).build();
- assertFalse(BOB.isSamePerson(editedBob));
}
@Test
@@ -82,7 +73,7 @@ public void equals() {
assertFalse(ALICE.equals(editedAlice));
// different address -> returns false
- editedAlice = new PersonBuilder(ALICE).withAddress(VALID_ADDRESS_BOB).build();
+ editedAlice = new PersonBuilder(ALICE).withMatriculationNumber(VALID_MATRICULATIONNUM_BOB).build();
assertFalse(ALICE.equals(editedAlice));
// different tags -> returns false
@@ -92,8 +83,16 @@ public void equals() {
@Test
public void toStringMethod() {
- String expected = Person.class.getCanonicalName() + "{name=" + ALICE.getName() + ", phone=" + ALICE.getPhone()
- + ", email=" + ALICE.getEmail() + ", address=" + ALICE.getAddress() + ", tags=" + ALICE.getTags() + "}";
+ String expected = Person.class.getCanonicalName()
+ + "{name=" + ALICE.getName()
+ + ", phone=" + ALICE.getPhone()
+ + ", email=" + ALICE.getEmail()
+ + ", matriculationNumber=" + ALICE.getMatriculationNumber()
+ + ", tags=" + ALICE.getTags()
+ + ", archived=" + ALICE.isArchived()
+ + ", paymentsCount=" + ALICE.getPayments().size()
+ + "}";
assertEquals(expected, ALICE.toString());
}
+
}
diff --git a/src/test/java/seedu/address/model/person/PhoneTest.java b/src/test/java/seedu/address/model/person/PhoneTest.java
index deaaa5ba190..a81478965a6 100644
--- a/src/test/java/seedu/address/model/person/PhoneTest.java
+++ b/src/test/java/seedu/address/model/person/PhoneTest.java
@@ -33,17 +33,16 @@ public void isValidPhone() {
assertFalse(Phone.isValidPhone("9312 1534")); // spaces within digits
// valid phone numbers
- assertTrue(Phone.isValidPhone("911")); // exactly 3 numbers
assertTrue(Phone.isValidPhone("93121534"));
- assertTrue(Phone.isValidPhone("124293842033123")); // long phone numbers
+ assertTrue(Phone.isValidPhone("12345678")); // long phone numbers
}
@Test
public void equals() {
- Phone phone = new Phone("999");
+ Phone phone = new Phone("12345678");
// same values -> returns true
- assertTrue(phone.equals(new Phone("999")));
+ assertTrue(phone.equals(new Phone("12345678")));
// same object -> returns true
assertTrue(phone.equals(phone));
@@ -55,6 +54,6 @@ public void equals() {
assertFalse(phone.equals(5.0f));
// different values -> returns false
- assertFalse(phone.equals(new Phone("995")));
+ assertFalse(phone.equals(new Phone("87654321")));
}
}
diff --git a/src/test/java/seedu/address/model/person/UniquePersonListTest.java b/src/test/java/seedu/address/model/person/UniquePersonListTest.java
index 17ae501df08..82652ee1e07 100644
--- a/src/test/java/seedu/address/model/person/UniquePersonListTest.java
+++ b/src/test/java/seedu/address/model/person/UniquePersonListTest.java
@@ -3,7 +3,9 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static seedu.address.logic.commands.CommandTestUtil.VALID_ADDRESS_BOB;
+import static seedu.address.logic.commands.CommandTestUtil.VALID_MATRICULATIONNUM_AMY;
+import static seedu.address.logic.commands.CommandTestUtil.VALID_MATRICULATIONNUM_BOB;
+import static seedu.address.logic.commands.CommandTestUtil.VALID_NAME_BOB;
import static seedu.address.logic.commands.CommandTestUtil.VALID_TAG_HUSBAND;
import static seedu.address.testutil.Assert.assertThrows;
import static seedu.address.testutil.TypicalPersons.ALICE;
@@ -40,13 +42,26 @@ public void contains_personInList_returnsTrue() {
}
@Test
- public void contains_personWithSameIdentityFieldsInList_returnsTrue() {
+ public void contains_personWithSameMatricNumberInList_returnsTrue() {
uniquePersonList.add(ALICE);
- Person editedAlice = new PersonBuilder(ALICE).withAddress(VALID_ADDRESS_BOB).withTags(VALID_TAG_HUSBAND)
- .build();
+ // editedAlice has different name/tags but same matric number
+ Person editedAlice = new PersonBuilder(ALICE)
+ .withTags(VALID_TAG_HUSBAND)
+ .withName(VALID_NAME_BOB)
+ .build();
assertTrue(uniquePersonList.contains(editedAlice));
}
+ @Test
+ public void contains_personWithDifferentMatricNumber_returnsFalse() {
+ uniquePersonList.add(ALICE);
+ // editedAlice has a different matric number → should not be same person
+ Person editedAlice = new PersonBuilder(ALICE)
+ .withMatriculationNumber(VALID_MATRICULATIONNUM_BOB)
+ .build();
+ assertFalse(uniquePersonList.contains(editedAlice));
+ }
+
@Test
public void add_nullPerson_throwsNullPointerException() {
assertThrows(NullPointerException.class, () -> uniquePersonList.add(null));
@@ -83,10 +98,14 @@ public void setPerson_editedPersonIsSamePerson_success() {
}
@Test
- public void setPerson_editedPersonHasSameIdentity_success() {
+ public void setPerson_editedPersonHasSameMatricNumber_success() {
uniquePersonList.add(ALICE);
- Person editedAlice = new PersonBuilder(ALICE).withAddress(VALID_ADDRESS_BOB).withTags(VALID_TAG_HUSBAND)
- .build();
+ // Same matric number but different name and tags
+ Person editedAlice = new PersonBuilder(ALICE)
+ .withName("Different Name")
+ .withTags(VALID_TAG_HUSBAND)
+ .withMatriculationNumber(VALID_MATRICULATIONNUM_AMY)
+ .build();
uniquePersonList.setPerson(ALICE, editedAlice);
UniquePersonList expectedUniquePersonList = new UniquePersonList();
expectedUniquePersonList.add(editedAlice);
@@ -94,19 +113,27 @@ public void setPerson_editedPersonHasSameIdentity_success() {
}
@Test
- public void setPerson_editedPersonHasDifferentIdentity_success() {
+ public void setPerson_editedPersonHasDifferentMatricNumber_success() {
uniquePersonList.add(ALICE);
- uniquePersonList.setPerson(ALICE, BOB);
+ // different matric number → different identity
+ Person editedAlice = new PersonBuilder(BOB)
+ .withMatriculationNumber(VALID_MATRICULATIONNUM_BOB)
+ .build();
+ uniquePersonList.setPerson(ALICE, editedAlice);
UniquePersonList expectedUniquePersonList = new UniquePersonList();
- expectedUniquePersonList.add(BOB);
+ expectedUniquePersonList.add(editedAlice);
assertEquals(expectedUniquePersonList, uniquePersonList);
}
@Test
- public void setPerson_editedPersonHasNonUniqueIdentity_throwsDuplicatePersonException() {
+ public void setPerson_editedPersonHasNonUniqueMatricNumber_throwsDuplicatePersonException() {
uniquePersonList.add(ALICE);
uniquePersonList.add(BOB);
- assertThrows(DuplicatePersonException.class, () -> uniquePersonList.setPerson(ALICE, BOB));
+ // Edited Alice now shares BOB’s matric number → duplicate
+ Person editedAlice = new PersonBuilder(ALICE)
+ .withMatriculationNumber(BOB.getMatriculationNumber().value)
+ .build();
+ assertThrows(DuplicatePersonException.class, () -> uniquePersonList.setPerson(ALICE, editedAlice));
}
@Test
@@ -157,15 +184,18 @@ public void setPersons_list_replacesOwnListWithProvidedList() {
}
@Test
- public void setPersons_listWithDuplicatePersons_throwsDuplicatePersonException() {
- List listWithDuplicatePersons = Arrays.asList(ALICE, ALICE);
+ public void setPersons_listWithDuplicateMatricNumbers_throwsDuplicatePersonException() {
+ Person aliceDuplicate = new PersonBuilder(ALICE)
+ .withMatriculationNumber(ALICE.getMatriculationNumber().value)
+ .build();
+ List listWithDuplicatePersons = Arrays.asList(ALICE, aliceDuplicate);
assertThrows(DuplicatePersonException.class, () -> uniquePersonList.setPersons(listWithDuplicatePersons));
}
@Test
public void asUnmodifiableObservableList_modifyList_throwsUnsupportedOperationException() {
- assertThrows(UnsupportedOperationException.class, ()
- -> uniquePersonList.asUnmodifiableObservableList().remove(0));
+ assertThrows(UnsupportedOperationException.class, (
+ ) -> uniquePersonList.asUnmodifiableObservableList().remove(0));
}
@Test
diff --git a/src/test/java/seedu/address/storage/JsonAdaptedPersonTest.java b/src/test/java/seedu/address/storage/JsonAdaptedPersonTest.java
index 83b11331cdb..c908ff2de39 100644
--- a/src/test/java/seedu/address/storage/JsonAdaptedPersonTest.java
+++ b/src/test/java/seedu/address/storage/JsonAdaptedPersonTest.java
@@ -12,25 +12,25 @@
import org.junit.jupiter.api.Test;
import seedu.address.commons.exceptions.IllegalValueException;
-import seedu.address.model.person.Address;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Phone;
public class JsonAdaptedPersonTest {
private static final String INVALID_NAME = "R@chel";
private static final String INVALID_PHONE = "+651234";
- private static final String INVALID_ADDRESS = " ";
+ private static final String INVALID_MATRICULATIONNUM = " ";
private static final String INVALID_EMAIL = "example.com";
private static final String INVALID_TAG = "#friend";
private static final String VALID_NAME = BENSON.getName().toString();
private static final String VALID_PHONE = BENSON.getPhone().toString();
private static final String VALID_EMAIL = BENSON.getEmail().toString();
- private static final String VALID_ADDRESS = BENSON.getAddress().toString();
+ private static final String VALID_MATRICULATIONNUM = BENSON.getMatriculationNumber().toString();
private static final List VALID_TAGS = BENSON.getTags().stream()
- .map(JsonAdaptedTag::new)
- .collect(Collectors.toList());
+ .map(JsonAdaptedTag::new)
+ .collect(Collectors.toList());
@Test
public void toModelType_validPersonDetails_returnsPerson() throws Exception {
@@ -41,14 +41,17 @@ public void toModelType_validPersonDetails_returnsPerson() throws Exception {
@Test
public void toModelType_invalidName_throwsIllegalValueException() {
JsonAdaptedPerson person =
- new JsonAdaptedPerson(INVALID_NAME, VALID_PHONE, VALID_EMAIL, VALID_ADDRESS, VALID_TAGS);
+ new JsonAdaptedPerson(INVALID_NAME, VALID_PHONE, VALID_EMAIL,
+ VALID_MATRICULATIONNUM, VALID_TAGS, null, null);
String expectedMessage = Name.MESSAGE_CONSTRAINTS;
assertThrows(IllegalValueException.class, expectedMessage, person::toModelType);
}
@Test
public void toModelType_nullName_throwsIllegalValueException() {
- JsonAdaptedPerson person = new JsonAdaptedPerson(null, VALID_PHONE, VALID_EMAIL, VALID_ADDRESS, VALID_TAGS);
+ JsonAdaptedPerson person =
+ new JsonAdaptedPerson(null, VALID_PHONE, VALID_EMAIL,
+ VALID_MATRICULATIONNUM, VALID_TAGS, null, null);
String expectedMessage = String.format(MISSING_FIELD_MESSAGE_FORMAT, Name.class.getSimpleName());
assertThrows(IllegalValueException.class, expectedMessage, person::toModelType);
}
@@ -56,14 +59,17 @@ public void toModelType_nullName_throwsIllegalValueException() {
@Test
public void toModelType_invalidPhone_throwsIllegalValueException() {
JsonAdaptedPerson person =
- new JsonAdaptedPerson(VALID_NAME, INVALID_PHONE, VALID_EMAIL, VALID_ADDRESS, VALID_TAGS);
+ new JsonAdaptedPerson(VALID_NAME, INVALID_PHONE, VALID_EMAIL,
+ VALID_MATRICULATIONNUM, VALID_TAGS, null, null);
String expectedMessage = Phone.MESSAGE_CONSTRAINTS;
assertThrows(IllegalValueException.class, expectedMessage, person::toModelType);
}
@Test
public void toModelType_nullPhone_throwsIllegalValueException() {
- JsonAdaptedPerson person = new JsonAdaptedPerson(VALID_NAME, null, VALID_EMAIL, VALID_ADDRESS, VALID_TAGS);
+ JsonAdaptedPerson person =
+ new JsonAdaptedPerson(VALID_NAME, null, VALID_EMAIL,
+ VALID_MATRICULATIONNUM, VALID_TAGS, null, null);
String expectedMessage = String.format(MISSING_FIELD_MESSAGE_FORMAT, Phone.class.getSimpleName());
assertThrows(IllegalValueException.class, expectedMessage, person::toModelType);
}
@@ -71,30 +77,37 @@ public void toModelType_nullPhone_throwsIllegalValueException() {
@Test
public void toModelType_invalidEmail_throwsIllegalValueException() {
JsonAdaptedPerson person =
- new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, INVALID_EMAIL, VALID_ADDRESS, VALID_TAGS);
+ new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, INVALID_EMAIL,
+ VALID_MATRICULATIONNUM, VALID_TAGS, null, null);
String expectedMessage = Email.MESSAGE_CONSTRAINTS;
assertThrows(IllegalValueException.class, expectedMessage, person::toModelType);
}
@Test
public void toModelType_nullEmail_throwsIllegalValueException() {
- JsonAdaptedPerson person = new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, null, VALID_ADDRESS, VALID_TAGS);
+ JsonAdaptedPerson person =
+ new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, null,
+ VALID_MATRICULATIONNUM, VALID_TAGS, null, null);
String expectedMessage = String.format(MISSING_FIELD_MESSAGE_FORMAT, Email.class.getSimpleName());
assertThrows(IllegalValueException.class, expectedMessage, person::toModelType);
}
@Test
- public void toModelType_invalidAddress_throwsIllegalValueException() {
+ public void toModelType_invalidMatricNumber_throwsIllegalValueException() {
JsonAdaptedPerson person =
- new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, VALID_EMAIL, INVALID_ADDRESS, VALID_TAGS);
- String expectedMessage = Address.MESSAGE_CONSTRAINTS;
+ new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, VALID_EMAIL,
+ INVALID_MATRICULATIONNUM, VALID_TAGS, null, null);
+ String expectedMessage = MatriculationNumber.MESSAGE_CONSTRAINTS;
assertThrows(IllegalValueException.class, expectedMessage, person::toModelType);
}
@Test
- public void toModelType_nullAddress_throwsIllegalValueException() {
- JsonAdaptedPerson person = new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, VALID_EMAIL, null, VALID_TAGS);
- String expectedMessage = String.format(MISSING_FIELD_MESSAGE_FORMAT, Address.class.getSimpleName());
+ public void toModelType_nullMatricNumber_throwsIllegalValueException() {
+ JsonAdaptedPerson person =
+ new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, VALID_EMAIL,
+ null, VALID_TAGS, null, null);
+ String expectedMessage = String.format(MISSING_FIELD_MESSAGE_FORMAT,
+ MatriculationNumber.class.getSimpleName());
assertThrows(IllegalValueException.class, expectedMessage, person::toModelType);
}
@@ -103,8 +116,8 @@ public void toModelType_invalidTags_throwsIllegalValueException() {
List invalidTags = new ArrayList<>(VALID_TAGS);
invalidTags.add(new JsonAdaptedTag(INVALID_TAG));
JsonAdaptedPerson person =
- new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, VALID_EMAIL, VALID_ADDRESS, invalidTags);
+ new JsonAdaptedPerson(VALID_NAME, VALID_PHONE, VALID_EMAIL,
+ VALID_MATRICULATIONNUM, invalidTags, null, null);
assertThrows(IllegalValueException.class, person::toModelType);
}
-
}
diff --git a/src/test/java/seedu/address/storage/JsonSerializableAddressBookTest.java b/src/test/java/seedu/address/storage/JsonSerializableAddressBookTest.java
index 188c9058d20..50e895717d4 100644
--- a/src/test/java/seedu/address/storage/JsonSerializableAddressBookTest.java
+++ b/src/test/java/seedu/address/storage/JsonSerializableAddressBookTest.java
@@ -15,15 +15,18 @@
public class JsonSerializableAddressBookTest {
- private static final Path TEST_DATA_FOLDER = Paths.get("src", "test", "data", "JsonSerializableAddressBookTest");
- private static final Path TYPICAL_PERSONS_FILE = TEST_DATA_FOLDER.resolve("typicalPersonsAddressBook.json");
+ private static final Path TEST_DATA_FOLDER = Paths.get("src", "test", "data",
+ "JsonSerializableAddressBookTest");
+ private static final Path TYPICAL_PERSONS_FILE = TEST_DATA_FOLDER.resolve("typicalPersonsAddressBook"
+ + ".json");
private static final Path INVALID_PERSON_FILE = TEST_DATA_FOLDER.resolve("invalidPersonAddressBook.json");
- private static final Path DUPLICATE_PERSON_FILE = TEST_DATA_FOLDER.resolve("duplicatePersonAddressBook.json");
+ private static final Path DUPLICATE_PERSON_FILE = TEST_DATA_FOLDER.resolve("duplicatePersonAddressBook"
+ + ".json");
@Test
public void toModelType_typicalPersonsFile_success() throws Exception {
JsonSerializableAddressBook dataFromFile = JsonUtil.readJsonFile(TYPICAL_PERSONS_FILE,
- JsonSerializableAddressBook.class).get();
+ JsonSerializableAddressBook.class).get();
AddressBook addressBookFromFile = dataFromFile.toModelType();
AddressBook typicalPersonsAddressBook = TypicalPersons.getTypicalAddressBook();
assertEquals(addressBookFromFile, typicalPersonsAddressBook);
@@ -32,16 +35,16 @@ public void toModelType_typicalPersonsFile_success() throws Exception {
@Test
public void toModelType_invalidPersonFile_throwsIllegalValueException() throws Exception {
JsonSerializableAddressBook dataFromFile = JsonUtil.readJsonFile(INVALID_PERSON_FILE,
- JsonSerializableAddressBook.class).get();
+ JsonSerializableAddressBook.class).get();
assertThrows(IllegalValueException.class, dataFromFile::toModelType);
}
@Test
public void toModelType_duplicatePersons_throwsIllegalValueException() throws Exception {
JsonSerializableAddressBook dataFromFile = JsonUtil.readJsonFile(DUPLICATE_PERSON_FILE,
- JsonSerializableAddressBook.class).get();
+ JsonSerializableAddressBook.class).get();
assertThrows(IllegalValueException.class, JsonSerializableAddressBook.MESSAGE_DUPLICATE_PERSON,
- dataFromFile::toModelType);
+ dataFromFile::toModelType);
}
}
diff --git a/src/test/java/seedu/address/testutil/EditPersonDescriptorBuilder.java b/src/test/java/seedu/address/testutil/EditPersonDescriptorBuilder.java
index 4584bd5044e..8bccc198356 100644
--- a/src/test/java/seedu/address/testutil/EditPersonDescriptorBuilder.java
+++ b/src/test/java/seedu/address/testutil/EditPersonDescriptorBuilder.java
@@ -5,8 +5,8 @@
import java.util.stream.Stream;
import seedu.address.logic.commands.EditCommand.EditPersonDescriptor;
-import seedu.address.model.person.Address;
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Person;
import seedu.address.model.person.Phone;
@@ -35,7 +35,7 @@ public EditPersonDescriptorBuilder(Person person) {
descriptor.setName(person.getName());
descriptor.setPhone(person.getPhone());
descriptor.setEmail(person.getEmail());
- descriptor.setAddress(person.getAddress());
+ descriptor.setMatriculationNumber(person.getMatriculationNumber());
descriptor.setTags(person.getTags());
}
@@ -66,8 +66,8 @@ public EditPersonDescriptorBuilder withEmail(String email) {
/**
* Sets the {@code Address} of the {@code EditPersonDescriptor} that we are building.
*/
- public EditPersonDescriptorBuilder withAddress(String address) {
- descriptor.setAddress(new Address(address));
+ public EditPersonDescriptorBuilder withMatriculationNumber(String matriculationNumber) {
+ descriptor.setMatriculationNumber(new MatriculationNumber(matriculationNumber));
return this;
}
diff --git a/src/test/java/seedu/address/testutil/PaymentBuilder.java b/src/test/java/seedu/address/testutil/PaymentBuilder.java
new file mode 100644
index 00000000000..1e95676e27a
--- /dev/null
+++ b/src/test/java/seedu/address/testutil/PaymentBuilder.java
@@ -0,0 +1,123 @@
+package seedu.address.testutil;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.LocalDate;
+
+import seedu.address.model.payment.Amount;
+import seedu.address.model.payment.Payment;
+
+/**
+ * Test helper to build {@link Payment} instances.
+ *
+ *
It supports Payment constructors of either
+ * (Amount, LocalDate, String) or (Amount, LocalDate, String, Instant),
+ * and supports {@link Amount} creation via:
+ *
+ *
new Amount(BigDecimal)
+ *
new Amount(String)
+ *
Amount.of(String)
+ *
Amount.valueOf(String)
+ *
Amount.parse(String)
+ *
+ */
+public class PaymentBuilder {
+
+ private String amount = "10.00";
+ private String date = "2025-01-01";
+ private String remarks = "";
+ private Instant recordedAt = Instant.parse("2025-01-01T00:00:00Z");
+
+ /** Sets the amount string for this builder. */
+ public PaymentBuilder withAmount(String amount) {
+ this.amount = amount;
+ return this;
+ }
+
+ /** Sets the transaction date (YYYY-MM-DD) for this builder. */
+ public PaymentBuilder withDate(String yyyyMmDd) {
+ this.date = yyyyMmDd;
+ return this;
+ }
+
+ /** Sets the remarks for this builder. */
+ public PaymentBuilder withRemarks(String remarks) {
+ this.remarks = remarks;
+ return this;
+ }
+
+ /** Sets the recorded-at timestamp for this builder (used if a 4-arg ctor exists). */
+ public PaymentBuilder withRecordedAt(Instant recordedAt) {
+ this.recordedAt = recordedAt;
+ return this;
+ }
+
+ /** Builds and returns the {@link Payment}. */
+ public Payment build() {
+ try {
+ Amount amt = buildAmountObj();
+ LocalDate dt = LocalDate.parse(this.date);
+
+ // Prefer 4-arg ctor if present
+ try {
+ Constructor c4 = Payment.class.getConstructor(
+ Amount.class, LocalDate.class, String.class, Instant.class);
+ return c4.newInstance(amt, dt, remarks, recordedAt);
+ } catch (NoSuchMethodException e) {
+ // Fall through to 3-arg constructor.
+ }
+
+ Constructor c3 = Payment.class.getConstructor(
+ Amount.class, LocalDate.class, String.class);
+ return c3.newInstance(amt, dt, remarks);
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Unable to construct Payment via reflection", e);
+ }
+ }
+
+ private Amount buildAmountObj() {
+ ReflectiveOperationException last = null;
+
+ // Try Amount(BigDecimal)
+ try {
+ Constructor c = Amount.class.getConstructor(BigDecimal.class);
+ return c.newInstance(new BigDecimal(this.amount));
+ } catch (ReflectiveOperationException e) {
+ last = e;
+ }
+
+ // Try Amount(String)
+ try {
+ Constructor c = Amount.class.getConstructor(String.class);
+ return c.newInstance(this.amount);
+ } catch (ReflectiveOperationException e) {
+ last = e;
+ }
+
+ // Try static factories: of / valueOf / parse
+ try {
+ Method m = Amount.class.getMethod("of", String.class);
+ return (Amount) m.invoke(null, this.amount);
+ } catch (ReflectiveOperationException e) {
+ last = e;
+ }
+
+ try {
+ Method m = Amount.class.getMethod("valueOf", String.class);
+ return (Amount) m.invoke(null, this.amount);
+ } catch (ReflectiveOperationException e) {
+ last = e;
+ }
+
+ try {
+ Method m = Amount.class.getMethod("parse", String.class);
+ return (Amount) m.invoke(null, this.amount);
+ } catch (ReflectiveOperationException e) {
+ last = e;
+ }
+
+ throw new RuntimeException("Unable to construct Amount from: " + this.amount, last);
+ }
+}
diff --git a/src/test/java/seedu/address/testutil/PersonBuilder.java b/src/test/java/seedu/address/testutil/PersonBuilder.java
index 6be381d39ba..94a26163b78 100644
--- a/src/test/java/seedu/address/testutil/PersonBuilder.java
+++ b/src/test/java/seedu/address/testutil/PersonBuilder.java
@@ -1,10 +1,16 @@
package seedu.address.testutil;
-import java.util.HashSet;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet; // must be before List (lexicographic)
+import java.util.List;
import java.util.Set;
-import seedu.address.model.person.Address;
+import seedu.address.model.payment.Payment; // must be before SampleDataUtil
import seedu.address.model.person.Email;
+import seedu.address.model.person.MatriculationNumber;
import seedu.address.model.person.Name;
import seedu.address.model.person.Person;
import seedu.address.model.person.Phone;
@@ -12,21 +18,25 @@
import seedu.address.model.util.SampleDataUtil;
/**
- * A utility class to help with building Person objects.
+ * A utility class to help with building Person objects for tests.
+ * Supports adding payments via {@link #withPayments(Payment...)}.
*/
public class PersonBuilder {
public static final String DEFAULT_NAME = "Amy Bee";
public static final String DEFAULT_PHONE = "85355255";
public static final String DEFAULT_EMAIL = "amy@gmail.com";
- public static final String DEFAULT_ADDRESS = "123, Jurong West Ave 6, #08-111";
+ public static final String DEFAULT_MATRICULATIONNUM = "A3333333A";
private Name name;
private Phone phone;
private Email email;
- private Address address;
+ private MatriculationNumber matriculationNumber;
private Set tags;
+ // Payments to attach to the built Person (optional)
+ private List payments = new ArrayList<>();
+
/**
* Creates a {@code PersonBuilder} with the default details.
*/
@@ -34,63 +44,128 @@ public PersonBuilder() {
name = new Name(DEFAULT_NAME);
phone = new Phone(DEFAULT_PHONE);
email = new Email(DEFAULT_EMAIL);
- address = new Address(DEFAULT_ADDRESS);
+ matriculationNumber = new MatriculationNumber(DEFAULT_MATRICULATIONNUM);
tags = new HashSet<>();
}
/**
* Initializes the PersonBuilder with the data of {@code personToCopy}.
+ * Note: payments are intentionally not copied to keep cloning predictable in tests.
*/
public PersonBuilder(Person personToCopy) {
name = personToCopy.getName();
phone = personToCopy.getPhone();
email = personToCopy.getEmail();
- address = personToCopy.getAddress();
+ matriculationNumber = personToCopy.getMatriculationNumber();
tags = new HashSet<>(personToCopy.getTags());
}
- /**
- * Sets the {@code Name} of the {@code Person} that we are building.
- */
+ /** Sets the {@code Name} of the {@code Person} that we are building. */
public PersonBuilder withName(String name) {
this.name = new Name(name);
return this;
}
- /**
- * Parses the {@code tags} into a {@code Set} and set it to the {@code Person} that we are building.
- */
- public PersonBuilder withTags(String ... tags) {
+ /** Parses the {@code tags} into a {@code Set} and set it to the {@code Person} that we are building. */
+ public PersonBuilder withTags(String... tags) {
this.tags = SampleDataUtil.getTagSet(tags);
return this;
}
- /**
- * Sets the {@code Address} of the {@code Person} that we are building.
- */
- public PersonBuilder withAddress(String address) {
- this.address = new Address(address);
+ /** Sets the {@code MatriculationNumber} of the {@code Person} that we are building. */
+ public PersonBuilder withMatriculationNumber(String matriculationNum) {
+ this.matriculationNumber = new MatriculationNumber(matriculationNum);
return this;
}
- /**
- * Sets the {@code Phone} of the {@code Person} that we are building.
- */
+ /** Sets the {@code Phone} of the {@code Person} that we are building. */
public PersonBuilder withPhone(String phone) {
this.phone = new Phone(phone);
return this;
}
- /**
- * Sets the {@code Email} of the {@code Person} that we are building.
- */
+ /** Sets the {@code Email} of the {@code Person} that we are building. */
public PersonBuilder withEmail(String email) {
this.email = new Email(email);
return this;
}
+ /** Adds payments to the person being built. */
+ public PersonBuilder withPayments(Payment... payments) {
+ this.payments = Arrays.asList(payments);
+ return this;
+ }
+
+ /**
+ * Builds the Person. If payments were provided, this tries the following (in order):
+ * 1) person.withPayments(List<Payment>)
+ * 2) person.withAddedPayment(Payment) repeatedly
+ * 3) A Person constructor that accepts payments as an extra parameter
+ * Falls back to base person if none exist.
+ */
public Person build() {
- return new Person(name, phone, email, address, tags);
+ Person base = new Person(name, phone, email, matriculationNumber, tags);
+
+ if (payments.isEmpty()) {
+ return base;
+ }
+
+ // Option 1: withPayments(List)
+ Method withPaymentsMethod = findMethod(Person.class, "withPayments", List.class);
+ if (withPaymentsMethod != null) {
+ try {
+ Object out = withPaymentsMethod.invoke(base, payments);
+ return (Person) out;
+ } catch (ReflectiveOperationException e) {
+ // Fail fast if the method exists but invocation failed.
+ throw new RuntimeException("Invoking Person.withPayments(List) failed", e);
+ }
+ }
+
+ // Option 2: withAddedPayment(Payment) repeatedly
+ Method withAddedPaymentMethod = findMethod(Person.class, "withAddedPayment", Payment.class);
+ if (withAddedPaymentMethod != null) {
+ try {
+ Person current = base;
+ for (Payment p : payments) {
+ current = (Person) withAddedPaymentMethod.invoke(current, p);
+ }
+ return current;
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException("Invoking Person.withAddedPayment(Payment) failed", e);
+ }
+ }
+
+ // Option 3: constructor that includes payments (… , Set