Merge pull request #15 from andrewlalis/transaction-properties
Add Transaction Properties
This commit is contained in:
		
						commit
						6900fdb481
					
				
							
								
								
									
										27
									
								
								README.md
								
								
								
								
							
							
						
						
									
										27
									
								
								README.md
								
								
								
								
							| 
						 | 
					@ -37,3 +37,30 @@ to set the version everywhere that it needs to be.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Once that's done, the workflow will start, and you should see a release appear
 | 
					Once that's done, the workflow will start, and you should see a release appear
 | 
				
			||||||
in the next few minutes.
 | 
					in the next few minutes.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Migration Procedure
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Because this application relies on a structured relational database schema,
 | 
				
			||||||
 | 
					changes to the schema must be handled with care to avoid destroying users' data.
 | 
				
			||||||
 | 
					Specifically, when changes are made to the schema, a *migration* must be defined
 | 
				
			||||||
 | 
					which provides instructions for Perfin to safely apply changes to an old schema.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The database schema is versioned using whole-number versions (1, 2, 3, ...), and
 | 
				
			||||||
 | 
					a migration is defined for each transition from version to version, such that
 | 
				
			||||||
 | 
					any older version can be incrementally upgraded, step by step, to the latest
 | 
				
			||||||
 | 
					schema version.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Perfin only supports the latest schema version, as defined by `JdbcDataSourceFactory.SCHEMA_VERSION`.
 | 
				
			||||||
 | 
					When the app loads a profile, it'll check that profile's schema version by
 | 
				
			||||||
 | 
					reading a `.jdbc-schema-version.txt` file in the profile's main directory. If
 | 
				
			||||||
 | 
					the profile's schema version is **less than** the current, Perfin will
 | 
				
			||||||
 | 
					ask the user if they want to upgrade. If the profile's schema version is
 | 
				
			||||||
 | 
					**greater than** the current, Perfin will tell the user that it can't load a
 | 
				
			||||||
 | 
					schema from a newer version, and will prompt the user to upgrade.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Writing a Migration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Write your migration. This can be plain SQL (placed in `resources/sql/migration`), or Java code.
 | 
				
			||||||
 | 
					2. Add your migration to `com.andrewlalis.perfin.data.impl.migration.Migrations#getMigrations()`.
 | 
				
			||||||
 | 
					3. Increment the schema version defined in `JdbcDataSourceFactory`.
 | 
				
			||||||
 | 
					4. Test the migration yourself on a profile with data.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										2
									
								
								pom.xml
								
								
								
								
							
							
						
						
									
										2
									
								
								pom.xml
								
								
								
								
							| 
						 | 
					@ -6,7 +6,7 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <groupId>com.andrewlalis</groupId>
 | 
					    <groupId>com.andrewlalis</groupId>
 | 
				
			||||||
    <artifactId>perfin</artifactId>
 | 
					    <artifactId>perfin</artifactId>
 | 
				
			||||||
    <version>1.4.0</version>
 | 
					    <version>1.5.0</version>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <properties>
 | 
					    <properties>
 | 
				
			||||||
        <maven.compiler.source>21</maven.compiler.source>
 | 
					        <maven.compiler.source>21</maven.compiler.source>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
jpackage \
 | 
					jpackage \
 | 
				
			||||||
  --name "Perfin" \
 | 
					  --name "Perfin" \
 | 
				
			||||||
  --app-version "1.4.0" \
 | 
					  --app-version "1.5.0" \
 | 
				
			||||||
  --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
 | 
					  --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
 | 
				
			||||||
  --icon design/perfin-logo_256.png \
 | 
					  --icon design/perfin-logo_256.png \
 | 
				
			||||||
  --vendor "Andrew Lalis" \
 | 
					  --vendor "Andrew Lalis" \
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,7 +12,7 @@ $modulePath = "$modulePath;target\modules\h2-2.2.224.jar"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
jpackage `
 | 
					jpackage `
 | 
				
			||||||
  --name "Perfin" `
 | 
					  --name "Perfin" `
 | 
				
			||||||
  --app-version "1.4.0" `
 | 
					  --app-version "1.5.0" `
 | 
				
			||||||
  --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
 | 
					  --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
 | 
				
			||||||
  --icon design\perfin-logo_256.ico `
 | 
					  --icon design\perfin-logo_256.ico `
 | 
				
			||||||
  --vendor "Andrew Lalis" `
 | 
					  --vendor "Andrew Lalis" `
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,7 +3,9 @@ package com.andrewlalis.perfin;
 | 
				
			||||||
import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
 | 
					import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
 | 
				
			||||||
import com.andrewlalis.javafx_scene_router.SceneRouter;
 | 
					import com.andrewlalis.javafx_scene_router.SceneRouter;
 | 
				
			||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
					import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Profile;
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.ProfileLoader;
 | 
				
			||||||
import com.andrewlalis.perfin.view.ImageCache;
 | 
					import com.andrewlalis.perfin.view.ImageCache;
 | 
				
			||||||
import com.andrewlalis.perfin.view.SceneUtil;
 | 
					import com.andrewlalis.perfin.view.SceneUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.view.StartupSplashScreen;
 | 
					import com.andrewlalis.perfin.view.StartupSplashScreen;
 | 
				
			||||||
| 
						 | 
					@ -29,6 +31,7 @@ public class PerfinApp extends Application {
 | 
				
			||||||
    private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
 | 
					    private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
 | 
				
			||||||
    public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
 | 
					    public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
 | 
				
			||||||
    public static PerfinApp instance;
 | 
					    public static PerfinApp instance;
 | 
				
			||||||
 | 
					    public static ProfileLoader profileLoader;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * The router that's used for navigating between different "pages" in the application.
 | 
					     * The router that's used for navigating between different "pages" in the application.
 | 
				
			||||||
| 
						 | 
					@ -48,13 +51,14 @@ public class PerfinApp extends Application {
 | 
				
			||||||
    @Override
 | 
					    @Override
 | 
				
			||||||
    public void start(Stage stage) {
 | 
					    public void start(Stage stage) {
 | 
				
			||||||
        instance = this;
 | 
					        instance = this;
 | 
				
			||||||
 | 
					        profileLoader = new ProfileLoader(stage, new JdbcDataSourceFactory());
 | 
				
			||||||
        loadFonts();
 | 
					        loadFonts();
 | 
				
			||||||
        var splashScreen = new StartupSplashScreen(List.of(
 | 
					        var splashScreen = new StartupSplashScreen(List.of(
 | 
				
			||||||
                PerfinApp::defineRoutes,
 | 
					                PerfinApp::defineRoutes,
 | 
				
			||||||
                PerfinApp::initAppDir,
 | 
					                PerfinApp::initAppDir,
 | 
				
			||||||
                c -> initMainScreen(stage, c),
 | 
					                c -> initMainScreen(stage, c),
 | 
				
			||||||
                PerfinApp::loadLastUsedProfile
 | 
					                PerfinApp::loadLastUsedProfile
 | 
				
			||||||
        ));
 | 
					        ), false);
 | 
				
			||||||
        splashScreen.showAndWait();
 | 
					        splashScreen.showAndWait();
 | 
				
			||||||
        if (splashScreen.isStartupSuccessful()) {
 | 
					        if (splashScreen.isStartupSuccessful()) {
 | 
				
			||||||
            stage.show();
 | 
					            stage.show();
 | 
				
			||||||
| 
						 | 
					@ -87,6 +91,11 @@ public class PerfinApp extends Application {
 | 
				
			||||||
            router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml"));
 | 
					            router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml"));
 | 
				
			||||||
            router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml"));
 | 
					            router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml"));
 | 
				
			||||||
            router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml"));
 | 
					            router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml"));
 | 
				
			||||||
 | 
					            router.map("vendors", PerfinApp.class.getResource("/vendors-view.fxml"));
 | 
				
			||||||
 | 
					            router.map("edit-vendor", PerfinApp.class.getResource("/edit-vendor.fxml"));
 | 
				
			||||||
 | 
					            router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
 | 
				
			||||||
 | 
					            router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
 | 
				
			||||||
 | 
					            router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Help pages.
 | 
					            // Help pages.
 | 
				
			||||||
            helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
 | 
					            helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
 | 
				
			||||||
| 
						 | 
					@ -112,9 +121,10 @@ public class PerfinApp extends Application {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
 | 
					    private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
 | 
				
			||||||
        msgConsumer.accept("Loading the most recent profile.");
 | 
					        String lastProfile = ProfileLoader.getLastProfile();
 | 
				
			||||||
 | 
					        msgConsumer.accept("Loading the most recent profile: \"" + lastProfile + "\".");
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
            Profile.loadLast();
 | 
					            Profile.setCurrent(profileLoader.load(lastProfile));
 | 
				
			||||||
        } catch (ProfileLoadException e) {
 | 
					        } catch (ProfileLoadException e) {
 | 
				
			||||||
            msgConsumer.accept("Failed to load the profile: " + e.getMessage());
 | 
					            msgConsumer.accept("Failed to load the profile: " + e.getMessage());
 | 
				
			||||||
            throw e;
 | 
					            throw e;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,12 +1,12 @@
 | 
				
			||||||
package com.andrewlalis.perfin.control;
 | 
					package com.andrewlalis.perfin.control;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
					import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountRepository;
 | 
					import com.andrewlalis.perfin.data.AccountRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.HistoryRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
					import com.andrewlalis.perfin.data.util.DateUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Account;
 | 
					import com.andrewlalis.perfin.model.Account;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Profile;
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
					import com.andrewlalis.perfin.model.history.HistoryItem;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.AccountHistoryItemTile;
 | 
					import com.andrewlalis.perfin.view.component.AccountHistoryItemTile;
 | 
				
			||||||
import javafx.application.Platform;
 | 
					import javafx.application.Platform;
 | 
				
			||||||
import javafx.beans.binding.BooleanExpression;
 | 
					import javafx.beans.binding.BooleanExpression;
 | 
				
			||||||
| 
						 | 
					@ -64,7 +64,7 @@ public class AccountViewController implements RouteSelectionListener {
 | 
				
			||||||
        accountNumberLabel.setText(account.getAccountNumber());
 | 
					        accountNumberLabel.setText(account.getAccountNumber());
 | 
				
			||||||
        accountCurrencyLabel.setText(account.getCurrency().getDisplayName());
 | 
					        accountCurrencyLabel.setText(account.getCurrency().getDisplayName());
 | 
				
			||||||
        accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
 | 
					        accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
 | 
				
			||||||
        Profile.getCurrent().getDataSource().getAccountBalanceText(account)
 | 
					        Profile.getCurrent().dataSource().getAccountBalanceText(account)
 | 
				
			||||||
                .thenAccept(accountBalanceLabel::setText);
 | 
					                .thenAccept(accountBalanceLabel::setText);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        reloadHistory();
 | 
					        reloadHistory();
 | 
				
			||||||
| 
						 | 
					@ -89,6 +89,7 @@ public class AccountViewController implements RouteSelectionListener {
 | 
				
			||||||
    @FXML
 | 
					    @FXML
 | 
				
			||||||
    public void archiveAccount() {
 | 
					    public void archiveAccount() {
 | 
				
			||||||
        boolean confirmResult = Popups.confirm(
 | 
					        boolean confirmResult = Popups.confirm(
 | 
				
			||||||
 | 
					                titleLabel,
 | 
				
			||||||
                "Are you sure you want to archive this account? It will no " +
 | 
					                "Are you sure you want to archive this account? It will no " +
 | 
				
			||||||
                        "longer show up in the app normally, and you won't be " +
 | 
					                        "longer show up in the app normally, and you won't be " +
 | 
				
			||||||
                        "able to add new transactions to it. You'll still be " +
 | 
					                        "able to add new transactions to it. You'll still be " +
 | 
				
			||||||
| 
						 | 
					@ -96,18 +97,19 @@ public class AccountViewController implements RouteSelectionListener {
 | 
				
			||||||
                        "later if you need to."
 | 
					                        "later if you need to."
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        if (confirmResult) {
 | 
					        if (confirmResult) {
 | 
				
			||||||
            Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id));
 | 
					            Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id));
 | 
				
			||||||
            router.replace("accounts");
 | 
					            router.replace("accounts");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public void unarchiveAccount() {
 | 
					    @FXML public void unarchiveAccount() {
 | 
				
			||||||
        boolean confirm = Popups.confirm(
 | 
					        boolean confirm = Popups.confirm(
 | 
				
			||||||
 | 
					                titleLabel,
 | 
				
			||||||
                "Are you sure you want to restore this account from its archived " +
 | 
					                "Are you sure you want to restore this account from its archived " +
 | 
				
			||||||
                        "status?"
 | 
					                        "status?"
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        if (confirm) {
 | 
					        if (confirm) {
 | 
				
			||||||
            Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id));
 | 
					            Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id));
 | 
				
			||||||
            router.replace("accounts");
 | 
					            router.replace("accounts");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -115,6 +117,7 @@ public class AccountViewController implements RouteSelectionListener {
 | 
				
			||||||
    @FXML
 | 
					    @FXML
 | 
				
			||||||
    public void deleteAccount() {
 | 
					    public void deleteAccount() {
 | 
				
			||||||
        boolean confirm = Popups.confirm(
 | 
					        boolean confirm = Popups.confirm(
 | 
				
			||||||
 | 
					                titleLabel,
 | 
				
			||||||
                "Are you sure you want to permanently delete this account and " +
 | 
					                "Are you sure you want to permanently delete this account and " +
 | 
				
			||||||
                        "all data directly associated with it? This cannot be " +
 | 
					                        "all data directly associated with it? This cannot be " +
 | 
				
			||||||
                        "undone; deleted accounts are not recoverable at all. " +
 | 
					                        "undone; deleted accounts are not recoverable at all. " +
 | 
				
			||||||
| 
						 | 
					@ -122,26 +125,21 @@ public class AccountViewController implements RouteSelectionListener {
 | 
				
			||||||
                        "want to hide it."
 | 
					                        "want to hide it."
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        if (confirm) {
 | 
					        if (confirm) {
 | 
				
			||||||
            Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.delete(account));
 | 
					            Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(account));
 | 
				
			||||||
            router.replace("accounts");
 | 
					            router.replace("accounts");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public void loadMoreHistory() {
 | 
					    @FXML public void loadMoreHistory() {
 | 
				
			||||||
        Profile.getCurrent().getDataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> {
 | 
					        Profile.getCurrent().dataSource().useRepoAsync(HistoryRepository.class, repo -> {
 | 
				
			||||||
            List<AccountHistoryItem> historyItems = repo.findMostRecentForAccount(
 | 
					            long historyId = repo.getOrCreateHistoryForAccount(account.id);
 | 
				
			||||||
                    account.id,
 | 
					            List<HistoryItem> items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom);
 | 
				
			||||||
                    loadHistoryFrom,
 | 
					            if (items.size() < historyLoadSize) {
 | 
				
			||||||
                    historyLoadSize
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
            if (historyItems.size() < historyLoadSize) {
 | 
					 | 
				
			||||||
                Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
 | 
					                Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
 | 
				
			||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
                loadHistoryFrom = historyItems.getLast().getTimestamp();
 | 
					                loadHistoryFrom = items.getLast().getTimestamp();
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            List<? extends Node> nodes = historyItems.stream()
 | 
					            List<? extends Node> nodes = items.stream().map(AccountHistoryItemTile::forItem).toList();
 | 
				
			||||||
                    .map(item -> AccountHistoryItemTile.forItem(item, repo, this))
 | 
					 | 
				
			||||||
                    .toList();
 | 
					 | 
				
			||||||
            Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
 | 
					            Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -49,7 +49,7 @@ public class AccountsViewController implements RouteSelectionListener {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public void refreshAccounts() {
 | 
					    public void refreshAccounts() {
 | 
				
			||||||
        Profile.whenLoaded(profile -> {
 | 
					        Profile.whenLoaded(profile -> {
 | 
				
			||||||
            profile.getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
					            profile.dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
				
			||||||
                List<Account> accounts = repo.findAllOrderedByRecentHistory();
 | 
					                List<Account> accounts = repo.findAllOrderedByRecentHistory();
 | 
				
			||||||
                Platform.runLater(() -> accountsPane.getChildren()
 | 
					                Platform.runLater(() -> accountsPane.getChildren()
 | 
				
			||||||
                        .setAll(accounts.stream()
 | 
					                        .setAll(accounts.stream()
 | 
				
			||||||
| 
						 | 
					@ -59,7 +59,7 @@ public class AccountsViewController implements RouteSelectionListener {
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
            // Compute grand totals!
 | 
					            // Compute grand totals!
 | 
				
			||||||
            Thread.ofVirtual().start(() -> {
 | 
					            Thread.ofVirtual().start(() -> {
 | 
				
			||||||
                var totals = profile.getDataSource().getCombinedAccountBalances();
 | 
					                var totals = profile.dataSource().getCombinedAccountBalances();
 | 
				
			||||||
                StringBuilder sb = new StringBuilder("Totals: ");
 | 
					                StringBuilder sb = new StringBuilder("Totals: ");
 | 
				
			||||||
                for (var entry : totals.entrySet()) {
 | 
					                for (var entry : totals.entrySet()) {
 | 
				
			||||||
                    sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey())));
 | 
					                    sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey())));
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -41,16 +41,19 @@ public class BalanceRecordViewController implements RouteSelectionListener {
 | 
				
			||||||
        timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
 | 
					        timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
 | 
				
			||||||
        balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
 | 
					        balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
 | 
				
			||||||
        currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
 | 
					        currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
 | 
				
			||||||
        Profile.getCurrent().getDataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
 | 
					        Profile.getCurrent().dataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
 | 
				
			||||||
            List<Attachment> attachments = repo.findAttachments(balanceRecord.id);
 | 
					            List<Attachment> attachments = repo.findAttachments(balanceRecord.id);
 | 
				
			||||||
            Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments));
 | 
					            Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments));
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public void delete() {
 | 
					    @FXML public void delete() {
 | 
				
			||||||
        boolean confirm = Popups.confirm("Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin.");
 | 
					        boolean confirm = Popups.confirm(
 | 
				
			||||||
 | 
					                titleLabel,
 | 
				
			||||||
 | 
					                "Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin."
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
        if (confirm) {
 | 
					        if (confirm) {
 | 
				
			||||||
            Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
 | 
					            Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
 | 
				
			||||||
            router.navigateBackAndClear();
 | 
					            router.navigateBackAndClear();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,63 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.control;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.impl.JdbcDataSource;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.BindingUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.component.CategoryTile;
 | 
				
			||||||
 | 
					import javafx.application.Platform;
 | 
				
			||||||
 | 
					import javafx.collections.FXCollections;
 | 
				
			||||||
 | 
					import javafx.collections.ObservableList;
 | 
				
			||||||
 | 
					import javafx.fxml.FXML;
 | 
				
			||||||
 | 
					import javafx.scene.layout.VBox;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.io.IOException;
 | 
				
			||||||
 | 
					import java.io.UncheckedIOException;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import static com.andrewlalis.perfin.PerfinApp.router;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class CategoriesViewController implements RouteSelectionListener {
 | 
				
			||||||
 | 
					    @FXML public VBox categoriesVBox;
 | 
				
			||||||
 | 
					    private final ObservableList<TransactionCategoryRepository.CategoryTreeNode> categoryTreeNodes = FXCollections.observableArrayList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void initialize() {
 | 
				
			||||||
 | 
					        BindingUtil.mapContent(categoriesVBox.getChildren(), categoryTreeNodes, node -> new CategoryTile(node, this::refreshCategories));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void onRouteSelected(Object context) {
 | 
				
			||||||
 | 
					        refreshCategories();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void addCategory() {
 | 
				
			||||||
 | 
					        router.navigate("edit-category");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void refreshCategories() {
 | 
				
			||||||
 | 
					        Profile.getCurrent().dataSource().mapRepoAsync(
 | 
				
			||||||
 | 
					                TransactionCategoryRepository.class,
 | 
				
			||||||
 | 
					                TransactionCategoryRepository::findTree
 | 
				
			||||||
 | 
					        ).thenAccept(nodes -> Platform.runLater(() -> categoryTreeNodes.setAll(nodes)));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void addDefaultCategories() {
 | 
				
			||||||
 | 
					        boolean confirm = Popups.confirm(categoriesVBox, "Are you sure you want to add all of Perfin's default categories to your profile? This might interfere with existing categories of the same name.");
 | 
				
			||||||
 | 
					        if (!confirm) return;
 | 
				
			||||||
 | 
					        JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
 | 
				
			||||||
 | 
					        try (var conn = ds.getConnection()) {
 | 
				
			||||||
 | 
					            DbUtil.doTransaction(conn, () -> {
 | 
				
			||||||
 | 
					                try {
 | 
				
			||||||
 | 
					                    new JdbcDataSourceFactory().insertDefaultCategories(conn);
 | 
				
			||||||
 | 
					                } catch (IOException e) {
 | 
				
			||||||
 | 
					                    throw new UncheckedIOException(e);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					            refreshCategories();
 | 
				
			||||||
 | 
					        } catch (Exception e) {
 | 
				
			||||||
 | 
					            Popups.error(categoriesVBox, e);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -11,6 +11,7 @@ import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
 | 
					import com.andrewlalis.perfin.view.component.FileSelectionArea;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
 | 
					import com.andrewlalis.perfin.view.component.PropertiesPane;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
					import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
 | 
					import com.andrewlalis.perfin.view.component.validation.ValidationResult;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
 | 
					import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
 | 
				
			||||||
import javafx.application.Platform;
 | 
					import javafx.application.Platform;
 | 
				
			||||||
| 
						 | 
					@ -39,7 +40,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
				
			||||||
    private Account account;
 | 
					    private Account account;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public void initialize() {
 | 
					    @FXML public void initialize() {
 | 
				
			||||||
        var timestampValid = new ValidationApplier<String>(input -> {
 | 
					        var timestampValid = new ValidationApplier<>((ValidationFunction<String>)  input -> {
 | 
				
			||||||
            try {
 | 
					            try {
 | 
				
			||||||
                DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
 | 
					                DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
 | 
				
			||||||
                return ValidationResult.valid();
 | 
					                return ValidationResult.valid();
 | 
				
			||||||
| 
						 | 
					@ -60,7 +61,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
				
			||||||
                return;
 | 
					                return;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            BigDecimal reportedBalance = new BigDecimal(newValue);
 | 
					            BigDecimal reportedBalance = new BigDecimal(newValue);
 | 
				
			||||||
            Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
					            Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
				
			||||||
                BigDecimal derivedBalance = repo.deriveCurrentBalance(account.id);
 | 
					                BigDecimal derivedBalance = repo.deriveCurrentBalance(account.id);
 | 
				
			||||||
                Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(
 | 
					                Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(
 | 
				
			||||||
                        !reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance)
 | 
					                        !reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance)
 | 
				
			||||||
| 
						 | 
					@ -76,7 +77,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
				
			||||||
    public void onRouteSelected(Object context) {
 | 
					    public void onRouteSelected(Object context) {
 | 
				
			||||||
        this.account = (Account) context;
 | 
					        this.account = (Account) context;
 | 
				
			||||||
        timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
 | 
					        timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
 | 
				
			||||||
        Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
					        Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
				
			||||||
            BigDecimal value = repo.deriveCurrentBalance(account.id);
 | 
					            BigDecimal value = repo.deriveCurrentBalance(account.id);
 | 
				
			||||||
            Platform.runLater(() -> balanceField.setText(
 | 
					            Platform.runLater(() -> balanceField.setText(
 | 
				
			||||||
                    CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
 | 
					                    CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
 | 
				
			||||||
| 
						 | 
					@ -89,13 +90,13 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
				
			||||||
        LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
 | 
					        LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
 | 
				
			||||||
        BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
 | 
					        BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        boolean confirm = Popups.confirm("Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted(
 | 
					        boolean confirm = Popups.confirm(timestampField, "Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted(
 | 
				
			||||||
                account.getShortName(),
 | 
					                account.getShortName(),
 | 
				
			||||||
                CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
 | 
					                CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
 | 
				
			||||||
                localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
 | 
					                localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
 | 
				
			||||||
        ));
 | 
					        ));
 | 
				
			||||||
        if (confirm && confirmIfInconsistentBalance(reportedBalance)) {
 | 
					        if (confirm && confirmIfInconsistentBalance(reportedBalance)) {
 | 
				
			||||||
            Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> {
 | 
					            Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
 | 
				
			||||||
                repo.insert(
 | 
					                repo.insert(
 | 
				
			||||||
                        DateUtil.localToUTC(localTimestamp),
 | 
					                        DateUtil.localToUTC(localTimestamp),
 | 
				
			||||||
                        account.id,
 | 
					                        account.id,
 | 
				
			||||||
| 
						 | 
					@ -113,7 +114,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) {
 | 
					    private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) {
 | 
				
			||||||
        BigDecimal currentDerivedBalance = Profile.getCurrent().getDataSource().mapRepo(
 | 
					        BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
 | 
				
			||||||
                AccountRepository.class,
 | 
					                AccountRepository.class,
 | 
				
			||||||
                repo -> repo.deriveCurrentBalance(account.id)
 | 
					                repo -> repo.deriveCurrentBalance(account.id)
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
| 
						 | 
					@ -122,7 +123,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
				
			||||||
                    CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())),
 | 
					                    CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())),
 | 
				
			||||||
                    CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency()))
 | 
					                    CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency()))
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
            return Popups.confirm(msg);
 | 
					            return Popups.confirm(timestampField, msg);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        return true;
 | 
					        return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -106,8 +106,8 @@ public class EditAccountController implements RouteSelectionListener {
 | 
				
			||||||
    @FXML
 | 
					    @FXML
 | 
				
			||||||
    public void save() {
 | 
					    public void save() {
 | 
				
			||||||
        try (
 | 
					        try (
 | 
				
			||||||
                var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
 | 
					                var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
 | 
				
			||||||
                var balanceRepo = Profile.getCurrent().getDataSource().getBalanceRecordRepository()
 | 
					                var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
 | 
				
			||||||
        ) {
 | 
					        ) {
 | 
				
			||||||
            if (creatingNewAccount.get()) {
 | 
					            if (creatingNewAccount.get()) {
 | 
				
			||||||
                String name = accountNameField.getText().strip();
 | 
					                String name = accountNameField.getText().strip();
 | 
				
			||||||
| 
						 | 
					@ -117,7 +117,7 @@ public class EditAccountController implements RouteSelectionListener {
 | 
				
			||||||
                BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
 | 
					                BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
 | 
				
			||||||
                List<Path> attachments = Collections.emptyList();
 | 
					                List<Path> attachments = Collections.emptyList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                boolean success = Popups.confirm("Are you sure you want to create this account?");
 | 
					                boolean success = Popups.confirm(accountNameField, "Are you sure you want to create this account?");
 | 
				
			||||||
                if (success) {
 | 
					                if (success) {
 | 
				
			||||||
                    long id = accountRepo.insert(type, number, name, currency);
 | 
					                    long id = accountRepo.insert(type, number, name, currency);
 | 
				
			||||||
                    balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
 | 
					                    balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
 | 
				
			||||||
| 
						 | 
					@ -138,7 +138,7 @@ public class EditAccountController implements RouteSelectionListener {
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        } catch (Exception e) {
 | 
					        } catch (Exception e) {
 | 
				
			||||||
            log.error("Failed to save (or update) account " + account.id, e);
 | 
					            log.error("Failed to save (or update) account " + account.id, e);
 | 
				
			||||||
            Popups.error("Failed to save the account: " + e.getMessage());
 | 
					            Popups.error(accountNameField, "Failed to save the account: " + e.getMessage());
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,108 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.control;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.TransactionCategory;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
				
			||||||
 | 
					import javafx.fxml.FXML;
 | 
				
			||||||
 | 
					import javafx.scene.control.Button;
 | 
				
			||||||
 | 
					import javafx.scene.control.ColorPicker;
 | 
				
			||||||
 | 
					import javafx.scene.control.TextField;
 | 
				
			||||||
 | 
					import javafx.scene.paint.Color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletableFuture;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import static com.andrewlalis.perfin.PerfinApp.router;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class EditCategoryController implements RouteSelectionListener {
 | 
				
			||||||
 | 
					    public record CategoryRouteContext(TransactionCategory category) implements RouteContext {}
 | 
				
			||||||
 | 
					    public record AddSubcategoryRouteContext(TransactionCategory parent) implements RouteContext {}
 | 
				
			||||||
 | 
					    private sealed interface RouteContext permits AddSubcategoryRouteContext, CategoryRouteContext {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private TransactionCategory category;
 | 
				
			||||||
 | 
					    private TransactionCategory parent;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public TextField nameField;
 | 
				
			||||||
 | 
					    @FXML public ColorPicker colorPicker;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public Button saveButton;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void initialize() {
 | 
				
			||||||
 | 
					        var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
				
			||||||
 | 
					                .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
 | 
				
			||||||
 | 
					                .addPredicate(s -> s.strip().length() <= TransactionCategory.NAME_MAX_LENGTH, "Name is too long.")
 | 
				
			||||||
 | 
					                .addAsyncPredicate(
 | 
				
			||||||
 | 
					                        s -> {
 | 
				
			||||||
 | 
					                            if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
 | 
				
			||||||
 | 
					                            return Profile.getCurrent().dataSource().mapRepoAsync(
 | 
				
			||||||
 | 
					                                    TransactionCategoryRepository.class,
 | 
				
			||||||
 | 
					                                    repo -> {
 | 
				
			||||||
 | 
					                                        var categoryByName = repo.findByName(s).orElse(null);
 | 
				
			||||||
 | 
					                                        if (this.category != null) {
 | 
				
			||||||
 | 
					                                            return this.category.equals(categoryByName) || categoryByName == null;
 | 
				
			||||||
 | 
					                                        }
 | 
				
			||||||
 | 
					                                        return categoryByName == null;
 | 
				
			||||||
 | 
					                                    }
 | 
				
			||||||
 | 
					                            );
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        "Category with this name already exists."
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					        ).validatedInitially().attachToTextField(nameField);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        saveButton.disableProperty().bind(nameValid.not());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void onRouteSelected(Object context) {
 | 
				
			||||||
 | 
					        this.category = null;
 | 
				
			||||||
 | 
					        this.parent = null;
 | 
				
			||||||
 | 
					        if (context instanceof RouteContext ctx) {
 | 
				
			||||||
 | 
					            switch (ctx) {
 | 
				
			||||||
 | 
					                case CategoryRouteContext(var cat):
 | 
				
			||||||
 | 
					                    this.category = cat;
 | 
				
			||||||
 | 
					                    nameField.setText(cat.getName());
 | 
				
			||||||
 | 
					                    colorPicker.setValue(cat.getColor());
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					                case AddSubcategoryRouteContext(var par):
 | 
				
			||||||
 | 
					                    this.parent = par;
 | 
				
			||||||
 | 
					                    nameField.setText(null);
 | 
				
			||||||
 | 
					                    colorPicker.setValue(parent.getColor());
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            nameField.setText(null);
 | 
				
			||||||
 | 
					            colorPicker.setValue(Color.WHITE);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void save() {
 | 
				
			||||||
 | 
					        final String name = nameField.getText().strip();
 | 
				
			||||||
 | 
					        final Color color = colorPicker.getValue();
 | 
				
			||||||
 | 
					        if (this.category == null && this.parent == null) {
 | 
				
			||||||
 | 
					            // New top-level category.
 | 
				
			||||||
 | 
					            Profile.getCurrent().dataSource().useRepo(
 | 
				
			||||||
 | 
					                    TransactionCategoryRepository.class,
 | 
				
			||||||
 | 
					                    repo -> repo.insert(name, color)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        } else if (this.category == null) {
 | 
				
			||||||
 | 
					            // New subcategory.
 | 
				
			||||||
 | 
					            Profile.getCurrent().dataSource().useRepo(
 | 
				
			||||||
 | 
					                    TransactionCategoryRepository.class,
 | 
				
			||||||
 | 
					                    repo -> repo.insert(parent.id, name, color)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        } else if (this.parent == null) {
 | 
				
			||||||
 | 
					            // Save edits to an existing category.
 | 
				
			||||||
 | 
					            Profile.getCurrent().dataSource().useRepo(
 | 
				
			||||||
 | 
					                    TransactionCategoryRepository.class,
 | 
				
			||||||
 | 
					                    repo -> repo.update(category.id, name, color)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        router.replace("categories");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void cancel() {
 | 
				
			||||||
 | 
					        router.navigateBackAndClear();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,24 +1,36 @@
 | 
				
			||||||
package com.andrewlalis.perfin.control;
 | 
					package com.andrewlalis.perfin.control;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
					import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.DataSource;
 | 
				
			||||||
import com.andrewlalis.perfin.data.TransactionRepository;
 | 
					import com.andrewlalis.perfin.data.TransactionRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
					import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
				
			||||||
import com.andrewlalis.perfin.data.pagination.Sort;
 | 
					import com.andrewlalis.perfin.data.pagination.Sort;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
					import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
					import com.andrewlalis.perfin.data.util.DateUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.*;
 | 
					import com.andrewlalis.perfin.model.*;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.BindingUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
 | 
					import com.andrewlalis.perfin.view.component.AccountSelectionBox;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.component.CategorySelectionBox;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
 | 
					import com.andrewlalis.perfin.view.component.FileSelectionArea;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
					import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
 | 
					import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
					import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
				
			||||||
import javafx.application.Platform;
 | 
					import javafx.application.Platform;
 | 
				
			||||||
 | 
					import javafx.beans.binding.BooleanExpression;
 | 
				
			||||||
 | 
					import javafx.beans.property.BooleanProperty;
 | 
				
			||||||
import javafx.beans.property.Property;
 | 
					import javafx.beans.property.Property;
 | 
				
			||||||
 | 
					import javafx.beans.property.SimpleBooleanProperty;
 | 
				
			||||||
import javafx.beans.property.SimpleObjectProperty;
 | 
					import javafx.beans.property.SimpleObjectProperty;
 | 
				
			||||||
 | 
					import javafx.collections.FXCollections;
 | 
				
			||||||
 | 
					import javafx.collections.ObservableList;
 | 
				
			||||||
import javafx.fxml.FXML;
 | 
					import javafx.fxml.FXML;
 | 
				
			||||||
 | 
					import javafx.geometry.Pos;
 | 
				
			||||||
 | 
					import javafx.scene.Node;
 | 
				
			||||||
import javafx.scene.control.*;
 | 
					import javafx.scene.control.*;
 | 
				
			||||||
 | 
					import javafx.scene.input.KeyCode;
 | 
				
			||||||
import javafx.scene.layout.BorderPane;
 | 
					import javafx.scene.layout.BorderPane;
 | 
				
			||||||
import javafx.scene.layout.HBox;
 | 
					import javafx.scene.layout.HBox;
 | 
				
			||||||
 | 
					import javafx.scene.layout.VBox;
 | 
				
			||||||
import org.slf4j.Logger;
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
import org.slf4j.LoggerFactory;
 | 
					import org.slf4j.LoggerFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -27,13 +39,14 @@ import java.nio.file.Path;
 | 
				
			||||||
import java.time.DateTimeException;
 | 
					import java.time.DateTimeException;
 | 
				
			||||||
import java.time.LocalDateTime;
 | 
					import java.time.LocalDateTime;
 | 
				
			||||||
import java.time.format.DateTimeFormatter;
 | 
					import java.time.format.DateTimeFormatter;
 | 
				
			||||||
import java.util.Collections;
 | 
					import java.util.*;
 | 
				
			||||||
import java.util.Comparator;
 | 
					 | 
				
			||||||
import java.util.Currency;
 | 
					 | 
				
			||||||
import java.util.List;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
					import static com.andrewlalis.perfin.PerfinApp.router;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Controller for the "edit-transaction" view, which is where the user can
 | 
				
			||||||
 | 
					 * create or edit transactions.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
public class EditTransactionController implements RouteSelectionListener {
 | 
					public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
    private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
 | 
					    private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -49,6 +62,25 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
    @FXML public AccountSelectionBox debitAccountSelector;
 | 
					    @FXML public AccountSelectionBox debitAccountSelector;
 | 
				
			||||||
    @FXML public AccountSelectionBox creditAccountSelector;
 | 
					    @FXML public AccountSelectionBox creditAccountSelector;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public ComboBox<String> vendorComboBox;
 | 
				
			||||||
 | 
					    @FXML public Hyperlink vendorsHyperlink;
 | 
				
			||||||
 | 
					    @FXML public CategorySelectionBox categoryComboBox;
 | 
				
			||||||
 | 
					    @FXML public Hyperlink categoriesHyperlink;
 | 
				
			||||||
 | 
					    @FXML public ComboBox<String> tagsComboBox;
 | 
				
			||||||
 | 
					    @FXML public Hyperlink tagsHyperlink;
 | 
				
			||||||
 | 
					    @FXML public Button addTagButton;
 | 
				
			||||||
 | 
					    @FXML public VBox tagsVBox;
 | 
				
			||||||
 | 
					    private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public Spinner<Integer> lineItemQuantitySpinner;
 | 
				
			||||||
 | 
					    @FXML public TextField lineItemValueField;
 | 
				
			||||||
 | 
					    @FXML public TextField lineItemDescriptionField;
 | 
				
			||||||
 | 
					    @FXML public Button addLineItemButton;
 | 
				
			||||||
 | 
					    @FXML public VBox addLineItemForm;
 | 
				
			||||||
 | 
					    @FXML public Button addLineItemAddButton;
 | 
				
			||||||
 | 
					    @FXML public Button addLineItemCancelButton;
 | 
				
			||||||
 | 
					    @FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public FileSelectionArea attachmentsSelectionArea;
 | 
					    @FXML public FileSelectionArea attachmentsSelectionArea;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public Button saveButton;
 | 
					    @FXML public Button saveButton;
 | 
				
			||||||
| 
						 | 
					@ -70,32 +102,32 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
        var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
					        var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
				
			||||||
                .addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
 | 
					                .addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
 | 
				
			||||||
        ).validatedInitially().attach(descriptionField, descriptionField.textProperty());
 | 
					        ).validatedInitially().attach(descriptionField, descriptionField.textProperty());
 | 
				
			||||||
 | 
					        var linkedAccountsValid = initializeLinkedAccountsValidationUi();
 | 
				
			||||||
 | 
					        initializeTagSelectionUi();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Linked accounts will use a property derived from both the debit and credit selections.
 | 
					        vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
 | 
				
			||||||
        Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
 | 
					        categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
 | 
				
			||||||
        debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
 | 
					        tagsHyperlink.setOnAction(event -> router.navigate("tags"));
 | 
				
			||||||
        creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
 | 
					
 | 
				
			||||||
        var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator<CreditAndDebitAccounts>()
 | 
					        // Initialize line item stuff.
 | 
				
			||||||
                .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
 | 
					        addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
 | 
				
			||||||
                .addPredicate(
 | 
					        addLineItemCancelButton.setOnAction(event -> {
 | 
				
			||||||
                        accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
 | 
					            lineItemQuantitySpinner.getValueFactory().setValue(1);
 | 
				
			||||||
                        "The credit and debit accounts cannot be the same."
 | 
					            lineItemValueField.setText(null);
 | 
				
			||||||
                )
 | 
					            lineItemDescriptionField.setText(null);
 | 
				
			||||||
                .addPredicate(
 | 
					            addingLineItemProperty.set(false);
 | 
				
			||||||
                        accounts -> (
 | 
					        });
 | 
				
			||||||
                                (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
 | 
					        BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
 | 
				
			||||||
                                (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
 | 
					        BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
 | 
				
			||||||
                        ),
 | 
					        lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
 | 
				
			||||||
                        "Linked accounts must use the same currency."
 | 
					        var lineItemValueValid = new ValidationApplier<>(
 | 
				
			||||||
                )
 | 
					                new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
 | 
				
			||||||
                .addPredicate(
 | 
					        ).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
 | 
				
			||||||
                        accounts -> (
 | 
					        var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
				
			||||||
                                (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
 | 
					                .addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
 | 
				
			||||||
                                (!accounts.hasDebit() || !accounts.debitAccount().isArchived())
 | 
					        ).attachToTextField(lineItemDescriptionField);
 | 
				
			||||||
                        ),
 | 
					        var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
 | 
				
			||||||
                        "Linked accounts must not be archived."
 | 
					        addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
        ).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
 | 
					        var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
 | 
				
			||||||
        saveButton.disableProperty().bind(formValid.not());
 | 
					        saveButton.disableProperty().bind(formValid.not());
 | 
				
			||||||
| 
						 | 
					@ -107,11 +139,14 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
        Currency currency = currencyChoiceBox.getValue();
 | 
					        Currency currency = currencyChoiceBox.getValue();
 | 
				
			||||||
        String description = getSanitizedDescription();
 | 
					        String description = getSanitizedDescription();
 | 
				
			||||||
        CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
 | 
					        CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
 | 
				
			||||||
 | 
					        String vendor = vendorComboBox.getValue();
 | 
				
			||||||
 | 
					        String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName();
 | 
				
			||||||
 | 
					        Set<String> tags = new HashSet<>(selectedTags);
 | 
				
			||||||
        List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
 | 
					        List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
 | 
				
			||||||
        List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
 | 
					        List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
 | 
				
			||||||
        final long idToNavigate;
 | 
					        final long idToNavigate;
 | 
				
			||||||
        if (transaction == null) {
 | 
					        if (transaction == null) {
 | 
				
			||||||
            idToNavigate = Profile.getCurrent().getDataSource().mapRepo(
 | 
					            idToNavigate = Profile.getCurrent().dataSource().mapRepo(
 | 
				
			||||||
                TransactionRepository.class,
 | 
					                TransactionRepository.class,
 | 
				
			||||||
                repo -> repo.insert(
 | 
					                repo -> repo.insert(
 | 
				
			||||||
                    utcTimestamp,
 | 
					                    utcTimestamp,
 | 
				
			||||||
| 
						 | 
					@ -119,11 +154,14 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
                    currency,
 | 
					                    currency,
 | 
				
			||||||
                    description,
 | 
					                    description,
 | 
				
			||||||
                    linkedAccounts,
 | 
					                    linkedAccounts,
 | 
				
			||||||
 | 
					                    vendor,
 | 
				
			||||||
 | 
					                    category,
 | 
				
			||||||
 | 
					                    tags,
 | 
				
			||||||
                    newAttachmentPaths
 | 
					                    newAttachmentPaths
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            Profile.getCurrent().getDataSource().useRepo(
 | 
					            Profile.getCurrent().dataSource().useRepo(
 | 
				
			||||||
                TransactionRepository.class,
 | 
					                TransactionRepository.class,
 | 
				
			||||||
                repo -> repo.update(
 | 
					                repo -> repo.update(
 | 
				
			||||||
                        transaction.id,
 | 
					                        transaction.id,
 | 
				
			||||||
| 
						 | 
					@ -132,6 +170,9 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
                        currency,
 | 
					                        currency,
 | 
				
			||||||
                        description,
 | 
					                        description,
 | 
				
			||||||
                        linkedAccounts,
 | 
					                        linkedAccounts,
 | 
				
			||||||
 | 
					                        vendor,
 | 
				
			||||||
 | 
					                        category,
 | 
				
			||||||
 | 
					                        tags,
 | 
				
			||||||
                        existingAttachments,
 | 
					                        existingAttachments,
 | 
				
			||||||
                        newAttachmentPaths
 | 
					                        newAttachmentPaths
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
| 
						 | 
					@ -149,6 +190,11 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
    public void onRouteSelected(Object context) {
 | 
					    public void onRouteSelected(Object context) {
 | 
				
			||||||
        transaction = (Transaction) context;
 | 
					        transaction = (Transaction) context;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Clear some initial fields immediately:
 | 
				
			||||||
 | 
					        tagsComboBox.setValue(null);
 | 
				
			||||||
 | 
					        vendorComboBox.setValue(null);
 | 
				
			||||||
 | 
					        categoryComboBox.select(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (transaction == null) {
 | 
					        if (transaction == null) {
 | 
				
			||||||
            titleLabel.setText("Create New Transaction");
 | 
					            titleLabel.setText("Create New Transaction");
 | 
				
			||||||
            timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
 | 
					            timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
 | 
				
			||||||
| 
						 | 
					@ -163,10 +209,13 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Fetch some account-specific data.
 | 
					        // Fetch some account-specific data.
 | 
				
			||||||
        container.setDisable(true);
 | 
					        container.setDisable(true);
 | 
				
			||||||
 | 
					        DataSource ds = Profile.getCurrent().dataSource();
 | 
				
			||||||
        Thread.ofVirtual().start(() -> {
 | 
					        Thread.ofVirtual().start(() -> {
 | 
				
			||||||
            try (
 | 
					            try (
 | 
				
			||||||
                    var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
 | 
					                    var accountRepo = ds.getAccountRepository();
 | 
				
			||||||
                    var transactionRepo = Profile.getCurrent().getDataSource().getTransactionRepository()
 | 
					                    var transactionRepo = ds.getTransactionRepository();
 | 
				
			||||||
 | 
					                    var vendorRepo = ds.getTransactionVendorRepository();
 | 
				
			||||||
 | 
					                    var categoryRepo = ds.getTransactionCategoryRepository()
 | 
				
			||||||
            ) {
 | 
					            ) {
 | 
				
			||||||
                // First fetch all the data.
 | 
					                // First fetch all the data.
 | 
				
			||||||
                List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
 | 
					                List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
 | 
				
			||||||
| 
						 | 
					@ -174,23 +223,50 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
                        .toList();
 | 
					                        .toList();
 | 
				
			||||||
                List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
 | 
					                List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
 | 
				
			||||||
                final List<Attachment> attachments;
 | 
					                final List<Attachment> attachments;
 | 
				
			||||||
 | 
					                final var categoryTreeNodes = categoryRepo.findTree();
 | 
				
			||||||
 | 
					                final List<String> availableTags = transactionRepo.findAllTags();
 | 
				
			||||||
 | 
					                final List<String> tags;
 | 
				
			||||||
                final CreditAndDebitAccounts linkedAccounts;
 | 
					                final CreditAndDebitAccounts linkedAccounts;
 | 
				
			||||||
 | 
					                final String vendorName;
 | 
				
			||||||
 | 
					                final TransactionCategory category;
 | 
				
			||||||
                if (transaction == null) {
 | 
					                if (transaction == null) {
 | 
				
			||||||
                    attachments = Collections.emptyList();
 | 
					                    attachments = Collections.emptyList();
 | 
				
			||||||
 | 
					                    tags = Collections.emptyList();
 | 
				
			||||||
                    linkedAccounts = new CreditAndDebitAccounts(null, null);
 | 
					                    linkedAccounts = new CreditAndDebitAccounts(null, null);
 | 
				
			||||||
 | 
					                    vendorName = null;
 | 
				
			||||||
 | 
					                    category = null;
 | 
				
			||||||
                } else {
 | 
					                } else {
 | 
				
			||||||
                    attachments = transactionRepo.findAttachments(transaction.id);
 | 
					                    attachments = transactionRepo.findAttachments(transaction.id);
 | 
				
			||||||
 | 
					                    tags = transactionRepo.findTags(transaction.id);
 | 
				
			||||||
                    linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
 | 
					                    linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
 | 
				
			||||||
 | 
					                    if (transaction.getVendorId() != null) {
 | 
				
			||||||
 | 
					                        vendorName = vendorRepo.findById(transaction.getVendorId())
 | 
				
			||||||
 | 
					                                .map(TransactionVendor::getName).orElse(null);
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        vendorName = null;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    if (transaction.getCategoryId() != null) {
 | 
				
			||||||
 | 
					                        category = categoryRepo.findById(transaction.getCategoryId()).orElse(null);
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        category = null;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					                final List<TransactionVendor> availableVendors = vendorRepo.findAll();
 | 
				
			||||||
                // Then make updates to the view.
 | 
					                // Then make updates to the view.
 | 
				
			||||||
                Platform.runLater(() -> {
 | 
					                Platform.runLater(() -> {
 | 
				
			||||||
 | 
					                    currencyChoiceBox.getItems().setAll(currencies);
 | 
				
			||||||
                    creditAccountSelector.setAccounts(accounts);
 | 
					                    creditAccountSelector.setAccounts(accounts);
 | 
				
			||||||
                    debitAccountSelector.setAccounts(accounts);
 | 
					                    debitAccountSelector.setAccounts(accounts);
 | 
				
			||||||
                    currencyChoiceBox.getItems().setAll(currencies);
 | 
					                    vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList());
 | 
				
			||||||
 | 
					                    vendorComboBox.setValue(vendorName);
 | 
				
			||||||
 | 
					                    categoryComboBox.loadCategories(categoryTreeNodes);
 | 
				
			||||||
 | 
					                    categoryComboBox.select(category);
 | 
				
			||||||
 | 
					                    tagsComboBox.getItems().setAll(availableTags);
 | 
				
			||||||
                    attachmentsSelectionArea.clear();
 | 
					                    attachmentsSelectionArea.clear();
 | 
				
			||||||
                    attachmentsSelectionArea.addAttachments(attachments);
 | 
					                    attachmentsSelectionArea.addAttachments(attachments);
 | 
				
			||||||
 | 
					                    selectedTags.clear();
 | 
				
			||||||
 | 
					                    selectedTags.addAll(tags);
 | 
				
			||||||
                    if (transaction == null) {
 | 
					                    if (transaction == null) {
 | 
				
			||||||
                        // TODO: Allow user to select a default currency.
 | 
					 | 
				
			||||||
                        currencyChoiceBox.getSelectionModel().selectFirst();
 | 
					                        currencyChoiceBox.getSelectionModel().selectFirst();
 | 
				
			||||||
                        creditAccountSelector.select(null);
 | 
					                        creditAccountSelector.select(null);
 | 
				
			||||||
                        debitAccountSelector.select(null);
 | 
					                        debitAccountSelector.select(null);
 | 
				
			||||||
| 
						 | 
					@ -203,11 +279,53 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
                });
 | 
					                });
 | 
				
			||||||
            } catch (Exception e) {
 | 
					            } catch (Exception e) {
 | 
				
			||||||
                log.error("Failed to get repositories.", e);
 | 
					                log.error("Failed to get repositories.", e);
 | 
				
			||||||
                Popups.error("Failed to fetch account-specific data: " + e.getMessage());
 | 
					                Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()));
 | 
				
			||||||
 | 
					                router.navigateBackAndClear();
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private BooleanExpression initializeLinkedAccountsValidationUi() {
 | 
				
			||||||
 | 
					        Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
 | 
				
			||||||
 | 
					        debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
 | 
				
			||||||
 | 
					        creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
 | 
				
			||||||
 | 
					        return new ValidationApplier<>(getLinkedAccountsValidator())
 | 
				
			||||||
 | 
					                .validatedInitially()
 | 
				
			||||||
 | 
					                .attach(linkedAccountsContainer, linkedAccountsProperty);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void initializeTagSelectionUi() {
 | 
				
			||||||
 | 
					        addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
 | 
				
			||||||
 | 
					        addTagButton.setOnAction(event -> {
 | 
				
			||||||
 | 
					            if (tagsComboBox.getValue() == null) return;
 | 
				
			||||||
 | 
					            String tag = tagsComboBox.getValue().strip();
 | 
				
			||||||
 | 
					            if (!selectedTags.contains(tag)) {
 | 
				
			||||||
 | 
					                selectedTags.add(tag);
 | 
				
			||||||
 | 
					                selectedTags.sort(String::compareToIgnoreCase);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            tagsComboBox.setValue(null);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        tagsComboBox.setOnKeyPressed(event -> {
 | 
				
			||||||
 | 
					            if (event.getCode() == KeyCode.ENTER) {
 | 
				
			||||||
 | 
					                addTagButton.fire();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, this::createTagListTile);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private Node createTagListTile(String tag) {
 | 
				
			||||||
 | 
					        Label label = new Label(tag);
 | 
				
			||||||
 | 
					        label.setMaxWidth(Double.POSITIVE_INFINITY);
 | 
				
			||||||
 | 
					        label.getStyleClass().addAll("bold-text");
 | 
				
			||||||
 | 
					        Button removeButton = new Button("Remove");
 | 
				
			||||||
 | 
					        removeButton.setOnAction(event -> selectedTags.remove(tag));
 | 
				
			||||||
 | 
					        BorderPane tile = new BorderPane(label);
 | 
				
			||||||
 | 
					        tile.setRight(removeButton);
 | 
				
			||||||
 | 
					        tile.getStyleClass().addAll("std-spacing");
 | 
				
			||||||
 | 
					        BorderPane.setAlignment(label, Pos.CENTER_LEFT);
 | 
				
			||||||
 | 
					        return tile;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private CreditAndDebitAccounts getSelectedAccounts() {
 | 
					    private CreditAndDebitAccounts getSelectedAccounts() {
 | 
				
			||||||
        return new CreditAndDebitAccounts(
 | 
					        return new CreditAndDebitAccounts(
 | 
				
			||||||
                creditAccountSelector.getValue(),
 | 
					                creditAccountSelector.getValue(),
 | 
				
			||||||
| 
						 | 
					@ -215,6 +333,29 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private PredicateValidator<CreditAndDebitAccounts> getLinkedAccountsValidator() {
 | 
				
			||||||
 | 
					        return new PredicateValidator<CreditAndDebitAccounts>()
 | 
				
			||||||
 | 
					            .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
 | 
				
			||||||
 | 
					            .addPredicate(
 | 
				
			||||||
 | 
					                accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
 | 
				
			||||||
 | 
					                "The credit and debit accounts cannot be the same."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .addPredicate(
 | 
				
			||||||
 | 
					                accounts -> (
 | 
				
			||||||
 | 
					                    (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
 | 
				
			||||||
 | 
					                    (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                "Linked accounts must use the same currency."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .addPredicate(
 | 
				
			||||||
 | 
					                accounts -> (
 | 
				
			||||||
 | 
					                    (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
 | 
				
			||||||
 | 
					                    (!accounts.hasDebit() || !accounts.debitAccount().isArchived())
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                "Linked accounts must not be archived."
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private LocalDateTime parseTimestamp() {
 | 
					    private LocalDateTime parseTimestamp() {
 | 
				
			||||||
        List<DateTimeFormatter> formatters = List.of(
 | 
					        List<DateTimeFormatter> formatters = List.of(
 | 
				
			||||||
                DateTimeFormatter.ISO_LOCAL_DATE_TIME,
 | 
					                DateTimeFormatter.ISO_LOCAL_DATE_TIME,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,93 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.control;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.DataSource;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.TransactionVendor;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
				
			||||||
 | 
					import javafx.fxml.FXML;
 | 
				
			||||||
 | 
					import javafx.scene.control.Button;
 | 
				
			||||||
 | 
					import javafx.scene.control.TextArea;
 | 
				
			||||||
 | 
					import javafx.scene.control.TextField;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.Objects;
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletableFuture;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import static com.andrewlalis.perfin.PerfinApp.router;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class EditVendorController implements RouteSelectionListener {
 | 
				
			||||||
 | 
					    private TransactionVendor vendor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public TextField nameField;
 | 
				
			||||||
 | 
					    @FXML public TextArea descriptionField;
 | 
				
			||||||
 | 
					    @FXML public Button saveButton;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void initialize() {
 | 
				
			||||||
 | 
					        var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
				
			||||||
 | 
					                .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
 | 
				
			||||||
 | 
					                .addPredicate(s -> s.strip().length() <= TransactionVendor.NAME_MAX_LENGTH, "Name is too long.")
 | 
				
			||||||
 | 
					                // A predicate that prevents duplicate names.
 | 
				
			||||||
 | 
					                .addAsyncPredicate(
 | 
				
			||||||
 | 
					                        s -> {
 | 
				
			||||||
 | 
					                            if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
 | 
				
			||||||
 | 
					                            return Profile.getCurrent().dataSource().mapRepoAsync(
 | 
				
			||||||
 | 
					                                    TransactionVendorRepository.class,
 | 
				
			||||||
 | 
					                                    repo -> {
 | 
				
			||||||
 | 
					                                        var vendorByName = repo.findByName(s).orElse(null);
 | 
				
			||||||
 | 
					                                        if (this.vendor != null) {
 | 
				
			||||||
 | 
					                                            return this.vendor.equals(vendorByName) || vendorByName == null;
 | 
				
			||||||
 | 
					                                        }
 | 
				
			||||||
 | 
					                                        return vendorByName == null;
 | 
				
			||||||
 | 
					                                    }
 | 
				
			||||||
 | 
					                            );
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        "Vendor with this name already exists."
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            ).validatedInitially().attachToTextField(nameField);
 | 
				
			||||||
 | 
					        var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
				
			||||||
 | 
					                .addPredicate(
 | 
				
			||||||
 | 
					                        s -> s == null || s.strip().length() <= TransactionVendor.DESCRIPTION_MAX_LENGTH,
 | 
				
			||||||
 | 
					                        "Description is too long."
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            ).validatedInitially().attach(descriptionField, descriptionField.textProperty());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        var formValid = nameValid.and(descriptionValid);
 | 
				
			||||||
 | 
					        saveButton.disableProperty().bind(formValid.not());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void onRouteSelected(Object context) {
 | 
				
			||||||
 | 
					        if (context instanceof TransactionVendor tv) {
 | 
				
			||||||
 | 
					            this.vendor = tv;
 | 
				
			||||||
 | 
					            nameField.setText(vendor.getName());
 | 
				
			||||||
 | 
					            descriptionField.setText(vendor.getDescription());
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            nameField.setText(null);
 | 
				
			||||||
 | 
					            descriptionField.setText(null);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void save() {
 | 
				
			||||||
 | 
					        String name = nameField.getText().strip();
 | 
				
			||||||
 | 
					        String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
 | 
				
			||||||
 | 
					        DataSource ds = Profile.getCurrent().dataSource();
 | 
				
			||||||
 | 
					        if (vendor != null) {
 | 
				
			||||||
 | 
					            ds.useRepo(TransactionVendorRepository.class, repo -> repo.update(vendor.id, name, description));
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            ds.useRepo(TransactionVendorRepository.class, repo -> {
 | 
				
			||||||
 | 
					                if (description == null || description.isEmpty()) {
 | 
				
			||||||
 | 
					                    repo.insert(name);
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    repo.insert(name, description);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        router.replace("vendors");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void cancel() {
 | 
				
			||||||
 | 
					        router.navigateBackAndClear();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,30 +1,70 @@
 | 
				
			||||||
package com.andrewlalis.perfin.control;
 | 
					package com.andrewlalis.perfin.control;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import javafx.application.Platform;
 | 
				
			||||||
 | 
					import javafx.scene.Node;
 | 
				
			||||||
 | 
					import javafx.scene.Scene;
 | 
				
			||||||
import javafx.scene.control.Alert;
 | 
					import javafx.scene.control.Alert;
 | 
				
			||||||
import javafx.scene.control.ButtonType;
 | 
					import javafx.scene.control.ButtonType;
 | 
				
			||||||
import javafx.stage.Modality;
 | 
					import javafx.stage.Modality;
 | 
				
			||||||
 | 
					import javafx.stage.Window;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Helper class for standardized popups and confirmation dialogs for the app.
 | 
					 * Helper class for standardized popups and confirmation dialogs for the app.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public class Popups {
 | 
					public class Popups {
 | 
				
			||||||
    public static boolean confirm(String text) {
 | 
					    public static boolean confirm(Window owner, String text) {
 | 
				
			||||||
        Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text);
 | 
					        Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text);
 | 
				
			||||||
 | 
					        alert.initOwner(owner);
 | 
				
			||||||
        alert.initModality(Modality.APPLICATION_MODAL);
 | 
					        alert.initModality(Modality.APPLICATION_MODAL);
 | 
				
			||||||
        var result = alert.showAndWait();
 | 
					        var result = alert.showAndWait();
 | 
				
			||||||
        return result.isPresent() && result.get() == ButtonType.OK;
 | 
					        return result.isPresent() && result.get() == ButtonType.OK;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static void message(String text) {
 | 
					    public static boolean confirm(Node node, String text) {
 | 
				
			||||||
 | 
					        return confirm(getWindowFromNode(node), text);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void message(Window owner, String text) {
 | 
				
			||||||
        Alert alert = new Alert(Alert.AlertType.NONE, text);
 | 
					        Alert alert = new Alert(Alert.AlertType.NONE, text);
 | 
				
			||||||
 | 
					        alert.initOwner(owner);
 | 
				
			||||||
        alert.initModality(Modality.APPLICATION_MODAL);
 | 
					        alert.initModality(Modality.APPLICATION_MODAL);
 | 
				
			||||||
        alert.getButtonTypes().setAll(ButtonType.OK);
 | 
					        alert.getButtonTypes().setAll(ButtonType.OK);
 | 
				
			||||||
        alert.showAndWait();
 | 
					        alert.showAndWait();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static void error(String text) {
 | 
					    public static void message(Node node, String text) {
 | 
				
			||||||
 | 
					        message(getWindowFromNode(node), text);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void error(Window owner, String text) {
 | 
				
			||||||
        Alert alert = new Alert(Alert.AlertType.WARNING, text);
 | 
					        Alert alert = new Alert(Alert.AlertType.WARNING, text);
 | 
				
			||||||
 | 
					        alert.initOwner(owner);
 | 
				
			||||||
        alert.initModality(Modality.APPLICATION_MODAL);
 | 
					        alert.initModality(Modality.APPLICATION_MODAL);
 | 
				
			||||||
        alert.showAndWait();
 | 
					        alert.showAndWait();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void error(Node node, String text) {
 | 
				
			||||||
 | 
					        error(getWindowFromNode(node), text);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void error(Window owner, Exception e) {
 | 
				
			||||||
 | 
					        error(owner, "An " + e.getClass().getSimpleName() + " occurred: " + e.getMessage());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void error(Node node, Exception e) {
 | 
				
			||||||
 | 
					        error(getWindowFromNode(node), e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void errorLater(Node node, Exception e) {
 | 
				
			||||||
 | 
					        Platform.runLater(() -> error(node, e));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static Window getWindowFromNode(Node n) {
 | 
				
			||||||
 | 
					        Window owner = null;
 | 
				
			||||||
 | 
					        Scene scene = n.getScene();
 | 
				
			||||||
 | 
					        if (scene != null) {
 | 
				
			||||||
 | 
					            owner = scene.getWindow();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return owner;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,7 @@ import com.andrewlalis.perfin.PerfinApp;
 | 
				
			||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
					import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
					import com.andrewlalis.perfin.data.util.FileUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Profile;
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.ProfileLoader;
 | 
				
			||||||
import com.andrewlalis.perfin.view.ProfilesStage;
 | 
					import com.andrewlalis.perfin.view.ProfilesStage;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
					import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
					import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
				
			||||||
| 
						 | 
					@ -44,11 +45,11 @@ public class ProfilesViewController {
 | 
				
			||||||
    @FXML public void addProfile() {
 | 
					    @FXML public void addProfile() {
 | 
				
			||||||
        String name = newProfileNameField.getText();
 | 
					        String name = newProfileNameField.getText();
 | 
				
			||||||
        boolean valid = Profile.validateName(name);
 | 
					        boolean valid = Profile.validateName(name);
 | 
				
			||||||
        if (valid && !Profile.getAvailableProfiles().contains(name)) {
 | 
					        if (valid && !ProfileLoader.getAvailableProfiles().contains(name)) {
 | 
				
			||||||
            boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?");
 | 
					            boolean confirm = Popups.confirm(profilesVBox, "Are you sure you want to add a new profile named \"" + name + "\"?");
 | 
				
			||||||
            if (confirm) {
 | 
					            if (confirm) {
 | 
				
			||||||
                if (openProfile(name, false)) {
 | 
					                if (openProfile(name, false)) {
 | 
				
			||||||
                    Popups.message("Created new profile \"" + name + "\" and loaded it.");
 | 
					                    Popups.message(profilesVBox, "Created new profile \"" + name + "\" and loaded it.");
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                newProfileNameField.clear();
 | 
					                newProfileNameField.clear();
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
| 
						 | 
					@ -56,8 +57,8 @@ public class ProfilesViewController {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private void refreshAvailableProfiles() {
 | 
					    private void refreshAvailableProfiles() {
 | 
				
			||||||
        List<String> profileNames = Profile.getAvailableProfiles();
 | 
					        List<String> profileNames = ProfileLoader.getAvailableProfiles();
 | 
				
			||||||
        String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName();
 | 
					        String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name();
 | 
				
			||||||
        List<Node> nodes = new ArrayList<>(profileNames.size());
 | 
					        List<Node> nodes = new ArrayList<>(profileNames.size());
 | 
				
			||||||
        for (String profileName : profileNames) {
 | 
					        for (String profileName : profileNames) {
 | 
				
			||||||
            boolean isCurrent = profileName.equals(currentProfile);
 | 
					            boolean isCurrent = profileName.equals(currentProfile);
 | 
				
			||||||
| 
						 | 
					@ -104,30 +105,31 @@ public class ProfilesViewController {
 | 
				
			||||||
    private boolean openProfile(String name, boolean showPopup) {
 | 
					    private boolean openProfile(String name, boolean showPopup) {
 | 
				
			||||||
        log.info("Opening profile \"{}\".", name);
 | 
					        log.info("Opening profile \"{}\".", name);
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
            Profile.load(name);
 | 
					            Profile.setCurrent(PerfinApp.profileLoader.load(name));
 | 
				
			||||||
 | 
					            ProfileLoader.saveLastProfile(name);
 | 
				
			||||||
            ProfilesStage.closeView();
 | 
					            ProfilesStage.closeView();
 | 
				
			||||||
            router.replace("accounts");
 | 
					            router.replace("accounts");
 | 
				
			||||||
            if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded.");
 | 
					            if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");
 | 
				
			||||||
            return true;
 | 
					            return true;
 | 
				
			||||||
        } catch (ProfileLoadException e) {
 | 
					        } catch (ProfileLoadException e) {
 | 
				
			||||||
            Popups.error("Failed to load the profile: " + e.getMessage());
 | 
					            Popups.error(profilesVBox, "Failed to load the profile: " + e.getMessage());
 | 
				
			||||||
            return false;
 | 
					            return false;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private void deleteProfile(String name) {
 | 
					    private void deleteProfile(String name) {
 | 
				
			||||||
        boolean confirmA = Popups.confirm("Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered.");
 | 
					        boolean confirmA = Popups.confirm(profilesVBox, "Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered.");
 | 
				
			||||||
        if (confirmA) {
 | 
					        if (confirmA) {
 | 
				
			||||||
            boolean confirmB = Popups.confirm("Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back.");
 | 
					            boolean confirmB = Popups.confirm(profilesVBox, "Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back.");
 | 
				
			||||||
            if (confirmB) {
 | 
					            if (confirmB) {
 | 
				
			||||||
                try {
 | 
					                try {
 | 
				
			||||||
                    FileUtil.deleteDirRecursive(Profile.getDir(name));
 | 
					                    FileUtil.deleteDirRecursive(Profile.getDir(name));
 | 
				
			||||||
                    // Reset the app's "last profile" to the default if it was the deleted profile.
 | 
					                    // Reset the app's "last profile" to the default if it was the deleted profile.
 | 
				
			||||||
                    if (Profile.getLastProfile().equals(name)) {
 | 
					                    if (ProfileLoader.getLastProfile().equals(name)) {
 | 
				
			||||||
                        Profile.saveLastProfile("default");
 | 
					                        ProfileLoader.saveLastProfile("default");
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    // If the current profile was deleted, switch to the default.
 | 
					                    // If the current profile was deleted, switch to the default.
 | 
				
			||||||
                    if (Profile.getCurrent() != null && Profile.getCurrent().getName().equals(name)) {
 | 
					                    if (Profile.getCurrent() != null && Profile.getCurrent().name().equals(name)) {
 | 
				
			||||||
                        openProfile("default", true);
 | 
					                        openProfile("default", true);
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    refreshAvailableProfiles();
 | 
					                    refreshAvailableProfiles();
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,64 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.control;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.BindingUtil;
 | 
				
			||||||
 | 
					import javafx.application.Platform;
 | 
				
			||||||
 | 
					import javafx.collections.FXCollections;
 | 
				
			||||||
 | 
					import javafx.collections.ObservableList;
 | 
				
			||||||
 | 
					import javafx.fxml.FXML;
 | 
				
			||||||
 | 
					import javafx.scene.Node;
 | 
				
			||||||
 | 
					import javafx.scene.control.Button;
 | 
				
			||||||
 | 
					import javafx.scene.control.Label;
 | 
				
			||||||
 | 
					import javafx.scene.layout.BorderPane;
 | 
				
			||||||
 | 
					import javafx.scene.layout.VBox;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class TagsViewController implements RouteSelectionListener {
 | 
				
			||||||
 | 
					    @FXML public VBox tagsVBox;
 | 
				
			||||||
 | 
					    private final ObservableList<String> tags = FXCollections.observableArrayList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void initialize() {
 | 
				
			||||||
 | 
					        BindingUtil.mapContent(tagsVBox.getChildren(), tags, this::buildTagTile);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void onRouteSelected(Object context) {
 | 
				
			||||||
 | 
					        refreshTags();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void refreshTags() {
 | 
				
			||||||
 | 
					        Profile.getCurrent().dataSource().mapRepoAsync(
 | 
				
			||||||
 | 
					                TransactionRepository.class,
 | 
				
			||||||
 | 
					                TransactionRepository::findAllTags
 | 
				
			||||||
 | 
					        ).thenAccept(strings -> Platform.runLater(() -> tags.setAll(strings)));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private Node buildTagTile(String name) {
 | 
				
			||||||
 | 
					        BorderPane tile = new BorderPane();
 | 
				
			||||||
 | 
					        tile.getStyleClass().addAll("tile");
 | 
				
			||||||
 | 
					        Label nameLabel = new Label(name);
 | 
				
			||||||
 | 
					        nameLabel.getStyleClass().addAll("bold-text");
 | 
				
			||||||
 | 
					        Label usagesLabel = new Label();
 | 
				
			||||||
 | 
					        usagesLabel.getStyleClass().addAll("small-font", "secondary-color-text-fill");
 | 
				
			||||||
 | 
					        Profile.getCurrent().dataSource().mapRepoAsync(
 | 
				
			||||||
 | 
					                TransactionRepository.class,
 | 
				
			||||||
 | 
					                repo -> repo.countTagUsages(name)
 | 
				
			||||||
 | 
					        ).thenAccept(count -> Platform.runLater(() -> usagesLabel.setText("Tagged transactions: " + count)));
 | 
				
			||||||
 | 
					        VBox contentBox = new VBox(nameLabel, usagesLabel);
 | 
				
			||||||
 | 
					        tile.setLeft(contentBox);
 | 
				
			||||||
 | 
					        Button removeButton = new Button("Remove");
 | 
				
			||||||
 | 
					        removeButton.setOnAction(event -> {
 | 
				
			||||||
 | 
					            boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this tag? It will be removed from any transactions. This cannot be undone.");
 | 
				
			||||||
 | 
					            if (confirm) {
 | 
				
			||||||
 | 
					                Profile.getCurrent().dataSource().useRepo(
 | 
				
			||||||
 | 
					                        TransactionRepository.class,
 | 
				
			||||||
 | 
					                        repo -> repo.deleteTag(name)
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					                refreshTags();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        tile.setRight(removeButton);
 | 
				
			||||||
 | 
					        return tile;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -3,23 +3,37 @@ package com.andrewlalis.perfin.control;
 | 
				
			||||||
import com.andrewlalis.perfin.data.TransactionRepository;
 | 
					import com.andrewlalis.perfin.data.TransactionRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
					import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
					import com.andrewlalis.perfin.data.util.DateUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Attachment;
 | 
					import com.andrewlalis.perfin.model.*;
 | 
				
			||||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
 | 
					import com.andrewlalis.perfin.view.BindingUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Profile;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.Transaction;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
 | 
					import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.component.PropertiesPane;
 | 
				
			||||||
import javafx.application.Platform;
 | 
					import javafx.application.Platform;
 | 
				
			||||||
 | 
					import javafx.beans.property.ListProperty;
 | 
				
			||||||
 | 
					import javafx.beans.property.ObjectProperty;
 | 
				
			||||||
 | 
					import javafx.beans.property.SimpleListProperty;
 | 
				
			||||||
 | 
					import javafx.beans.property.SimpleObjectProperty;
 | 
				
			||||||
 | 
					import javafx.collections.FXCollections;
 | 
				
			||||||
 | 
					import javafx.collections.ObservableList;
 | 
				
			||||||
import javafx.fxml.FXML;
 | 
					import javafx.fxml.FXML;
 | 
				
			||||||
import javafx.scene.control.Hyperlink;
 | 
					import javafx.scene.control.Hyperlink;
 | 
				
			||||||
import javafx.scene.control.Label;
 | 
					import javafx.scene.control.Label;
 | 
				
			||||||
 | 
					import javafx.scene.shape.Circle;
 | 
				
			||||||
import javafx.scene.text.TextFlow;
 | 
					import javafx.scene.text.TextFlow;
 | 
				
			||||||
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
import java.util.List;
 | 
					import org.slf4j.LoggerFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
					import static com.andrewlalis.perfin.PerfinApp.router;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class TransactionViewController {
 | 
					public class TransactionViewController {
 | 
				
			||||||
    private Transaction transaction;
 | 
					    private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
 | 
				
			||||||
 | 
					    private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
 | 
				
			||||||
 | 
					    private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
 | 
				
			||||||
 | 
					    private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
 | 
				
			||||||
 | 
					    private final ObservableList<String> tagsList = FXCollections.observableArrayList();
 | 
				
			||||||
 | 
					    private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
 | 
				
			||||||
 | 
					    private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public Label titleLabel;
 | 
					    @FXML public Label titleLabel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -27,51 +41,108 @@ public class TransactionViewController {
 | 
				
			||||||
    @FXML public Label timestampLabel;
 | 
					    @FXML public Label timestampLabel;
 | 
				
			||||||
    @FXML public Label descriptionLabel;
 | 
					    @FXML public Label descriptionLabel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public Label vendorLabel;
 | 
				
			||||||
 | 
					    @FXML public Circle categoryColorIndicator;
 | 
				
			||||||
 | 
					    @FXML public Label categoryLabel;
 | 
				
			||||||
 | 
					    @FXML public Label tagsLabel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public Hyperlink debitAccountLink;
 | 
					    @FXML public Hyperlink debitAccountLink;
 | 
				
			||||||
    @FXML public Hyperlink creditAccountLink;
 | 
					    @FXML public Hyperlink creditAccountLink;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public AttachmentsViewPane attachmentsViewPane;
 | 
					    @FXML public AttachmentsViewPane attachmentsViewPane;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public void initialize() {
 | 
					    @FXML public void initialize() {
 | 
				
			||||||
        configureAccountLinkBindings(debitAccountLink);
 | 
					        titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id));
 | 
				
			||||||
        configureAccountLinkBindings(creditAccountLink);
 | 
					        amountLabel.textProperty().bind(transactionProperty.map(t -> CurrencyUtil.formatMoney(t.getMoneyAmount())));
 | 
				
			||||||
 | 
					        timestampLabel.textProperty().bind(transactionProperty.map(t -> DateUtil.formatUTCAsLocalWithZone(t.getTimestamp())));
 | 
				
			||||||
 | 
					        descriptionLabel.textProperty().bind(transactionProperty.map(Transaction::getDescription));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        PropertiesPane vendorPane = (PropertiesPane) vendorLabel.getParent();
 | 
				
			||||||
 | 
					        BindingUtil.bindManagedAndVisible(vendorPane, vendorProperty.isNotNull());
 | 
				
			||||||
 | 
					        vendorLabel.textProperty().bind(vendorProperty.map(TransactionVendor::getName));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        PropertiesPane categoryPane = (PropertiesPane) categoryLabel.getParent().getParent();
 | 
				
			||||||
 | 
					        BindingUtil.bindManagedAndVisible(categoryPane, categoryProperty.isNotNull());
 | 
				
			||||||
 | 
					        categoryLabel.textProperty().bind(categoryProperty.map(TransactionCategory::getName));
 | 
				
			||||||
 | 
					        categoryColorIndicator.fillProperty().bind(categoryProperty.map(TransactionCategory::getColor));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        PropertiesPane tagsPane = (PropertiesPane) tagsLabel.getParent();
 | 
				
			||||||
 | 
					        BindingUtil.bindManagedAndVisible(tagsPane, tagsListProperty.emptyProperty().not());
 | 
				
			||||||
 | 
					        tagsLabel.textProperty().bind(tagsListProperty.map(tags -> String.join(", ", tags)));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        TextFlow debitText = (TextFlow) debitAccountLink.getParent();
 | 
				
			||||||
 | 
					        BindingUtil.bindManagedAndVisible(debitText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasDebit));
 | 
				
			||||||
 | 
					        debitAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasDebit() ? la.debitAccount().getShortName() : null));
 | 
				
			||||||
 | 
					        debitAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
 | 
				
			||||||
 | 
					            if (la.hasDebit()) {
 | 
				
			||||||
 | 
					                return event -> router.navigate("account", la.debitAccount());
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            return event -> {};
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					        TextFlow creditText = (TextFlow) creditAccountLink.getParent();
 | 
				
			||||||
 | 
					        BindingUtil.bindManagedAndVisible(creditText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasCredit));
 | 
				
			||||||
 | 
					        creditAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasCredit() ? la.creditAccount().getShortName() : null));
 | 
				
			||||||
 | 
					        creditAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
 | 
				
			||||||
 | 
					            if (la.hasCredit()) {
 | 
				
			||||||
 | 
					                return event -> router.navigate("account", la.creditAccount());
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            return event -> {};
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        attachmentsViewPane.hideIfEmpty();
 | 
					        attachmentsViewPane.hideIfEmpty();
 | 
				
			||||||
 | 
					        attachmentsViewPane.listProperty().bindContent(attachmentsList);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        transactionProperty.addListener((observable, oldValue, newValue) -> {
 | 
				
			||||||
 | 
					            if (newValue == null) {
 | 
				
			||||||
 | 
					                linkedAccountsProperty.set(null);
 | 
				
			||||||
 | 
					                vendorProperty.set(null);
 | 
				
			||||||
 | 
					                categoryProperty.set(null);
 | 
				
			||||||
 | 
					                tagsList.clear();
 | 
				
			||||||
 | 
					                attachmentsList.clear();
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                updateLinkedData(newValue);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public void setTransaction(Transaction transaction) {
 | 
					    public void setTransaction(Transaction transaction) {
 | 
				
			||||||
        this.transaction = transaction;
 | 
					        this.transactionProperty.set(transaction);
 | 
				
			||||||
        if (transaction == null) return;
 | 
					    }
 | 
				
			||||||
        titleLabel.setText("Transaction #" + transaction.id);
 | 
					
 | 
				
			||||||
        amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
 | 
					    private void updateLinkedData(Transaction tx) {
 | 
				
			||||||
        timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
 | 
					        var ds = Profile.getCurrent().dataSource();
 | 
				
			||||||
        descriptionLabel.setText(transaction.getDescription());
 | 
					        Thread.ofVirtual().start(() -> {
 | 
				
			||||||
        Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
 | 
					            try (
 | 
				
			||||||
            CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
 | 
					                var transactionRepo = ds.getTransactionRepository();
 | 
				
			||||||
            List<Attachment> attachments = repo.findAttachments(transaction.id);
 | 
					                var vendorRepo = ds.getTransactionVendorRepository();
 | 
				
			||||||
            Platform.runLater(() -> {
 | 
					                var categoryRepo = ds.getTransactionCategoryRepository()
 | 
				
			||||||
                if (accounts.hasDebit()) {
 | 
					            ) {
 | 
				
			||||||
                    debitAccountLink.setText(accounts.debitAccount().getShortName());
 | 
					                final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
 | 
				
			||||||
                    debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount()));
 | 
					                final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
 | 
				
			||||||
                } else {
 | 
					                final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
 | 
				
			||||||
                    debitAccountLink.setText(null);
 | 
					                final var attachments = transactionRepo.findAttachments(tx.id);
 | 
				
			||||||
                }
 | 
					                final var tags = transactionRepo.findTags(tx.id);
 | 
				
			||||||
                if (accounts.hasCredit()) {
 | 
					                Platform.runLater(() -> {
 | 
				
			||||||
                    creditAccountLink.setText(accounts.creditAccount().getShortName());
 | 
					                    linkedAccountsProperty.set(linkedAccounts);
 | 
				
			||||||
                    creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount()));
 | 
					                    vendorProperty.set(vendor);
 | 
				
			||||||
                } else {
 | 
					                    categoryProperty.set(category);
 | 
				
			||||||
                    creditAccountLink.setText(null);
 | 
					                    attachmentsList.setAll(attachments);
 | 
				
			||||||
                }
 | 
					                    tagsList.setAll(tags);
 | 
				
			||||||
                attachmentsViewPane.setAttachments(attachments);
 | 
					                });
 | 
				
			||||||
            });
 | 
					            } catch (Exception e) {
 | 
				
			||||||
 | 
					                log.error("Failed to fetch additional transaction data.", e);
 | 
				
			||||||
 | 
					                Popups.errorLater(titleLabel, e);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public void editTransaction() {
 | 
					    @FXML public void editTransaction() {
 | 
				
			||||||
        router.navigate("edit-transaction", this.transaction);
 | 
					        router.navigate("edit-transaction", this.transactionProperty.get());
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public void deleteTransaction() {
 | 
					    @FXML public void deleteTransaction() {
 | 
				
			||||||
        boolean confirm = Popups.confirm(
 | 
					        boolean confirm = Popups.confirm(
 | 
				
			||||||
 | 
					            titleLabel,
 | 
				
			||||||
            "Are you sure you want to delete this transaction? This will " +
 | 
					            "Are you sure you want to delete this transaction? This will " +
 | 
				
			||||||
            "permanently remove the transaction and its effects on any linked " +
 | 
					            "permanently remove the transaction and its effects on any linked " +
 | 
				
			||||||
            "accounts, as well as remove any attachments from storage within " +
 | 
					            "accounts, as well as remove any attachments from storage within " +
 | 
				
			||||||
| 
						 | 
					@ -81,15 +152,8 @@ public class TransactionViewController {
 | 
				
			||||||
            "it's derived from the most recent balance-record, and transactions."
 | 
					            "it's derived from the most recent balance-record, and transactions."
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        if (confirm) {
 | 
					        if (confirm) {
 | 
				
			||||||
            Profile.getCurrent().getDataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id));
 | 
					            Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(this.transactionProperty.get().id));
 | 
				
			||||||
            router.replace("transactions");
 | 
					            router.replace("transactions");
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
    private void configureAccountLinkBindings(Hyperlink link) {
 | 
					 | 
				
			||||||
        TextFlow parent = (TextFlow) link.getParent();
 | 
					 | 
				
			||||||
        parent.managedProperty().bind(parent.visibleProperty());
 | 
					 | 
				
			||||||
        parent.visibleProperty().bind(link.textProperty().isNotEmpty());
 | 
					 | 
				
			||||||
        link.setText(null);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,14 +3,18 @@ package com.andrewlalis.perfin.control;
 | 
				
			||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
					import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountRepository;
 | 
					import com.andrewlalis.perfin.data.AccountRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.TransactionRepository;
 | 
					import com.andrewlalis.perfin.data.TransactionRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.impl.JdbcDataSource;
 | 
				
			||||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
					import com.andrewlalis.perfin.data.pagination.Page;
 | 
				
			||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
					import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
				
			||||||
import com.andrewlalis.perfin.data.pagination.Sort;
 | 
					import com.andrewlalis.perfin.data.pagination.Sort;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.search.SearchFilter;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
					import com.andrewlalis.perfin.data.util.DateUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.Pair;
 | 
					import com.andrewlalis.perfin.data.util.Pair;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Account;
 | 
					import com.andrewlalis.perfin.model.Account;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Profile;
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Transaction;
 | 
					import com.andrewlalis.perfin.model.Transaction;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.BindingUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.view.SceneUtil;
 | 
					import com.andrewlalis.perfin.view.SceneUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
 | 
					import com.andrewlalis.perfin.view.component.AccountSelectionBox;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
 | 
					import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
 | 
				
			||||||
| 
						 | 
					@ -21,6 +25,7 @@ import javafx.beans.property.SimpleObjectProperty;
 | 
				
			||||||
import javafx.beans.value.ObservableValue;
 | 
					import javafx.beans.value.ObservableValue;
 | 
				
			||||||
import javafx.fxml.FXML;
 | 
					import javafx.fxml.FXML;
 | 
				
			||||||
import javafx.scene.Node;
 | 
					import javafx.scene.Node;
 | 
				
			||||||
 | 
					import javafx.scene.control.TextField;
 | 
				
			||||||
import javafx.scene.layout.BorderPane;
 | 
					import javafx.scene.layout.BorderPane;
 | 
				
			||||||
import javafx.scene.layout.HBox;
 | 
					import javafx.scene.layout.HBox;
 | 
				
			||||||
import javafx.scene.layout.VBox;
 | 
					import javafx.scene.layout.VBox;
 | 
				
			||||||
| 
						 | 
					@ -29,8 +34,9 @@ import javafx.stage.FileChooser;
 | 
				
			||||||
import java.io.File;
 | 
					import java.io.File;
 | 
				
			||||||
import java.io.PrintWriter;
 | 
					import java.io.PrintWriter;
 | 
				
			||||||
import java.nio.charset.StandardCharsets;
 | 
					import java.nio.charset.StandardCharsets;
 | 
				
			||||||
 | 
					import java.util.ArrayList;
 | 
				
			||||||
 | 
					import java.util.Arrays;
 | 
				
			||||||
import java.util.List;
 | 
					import java.util.List;
 | 
				
			||||||
import java.util.Set;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
					import static com.andrewlalis.perfin.PerfinApp.router;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -45,6 +51,7 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
				
			||||||
    public record RouteContext(Long selectedTransactionId) {}
 | 
					    public record RouteContext(Long selectedTransactionId) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @FXML public BorderPane transactionsListBorderPane;
 | 
					    @FXML public BorderPane transactionsListBorderPane;
 | 
				
			||||||
 | 
					    @FXML public TextField searchField;
 | 
				
			||||||
    @FXML public AccountSelectionBox filterByAccountComboBox;
 | 
					    @FXML public AccountSelectionBox filterByAccountComboBox;
 | 
				
			||||||
    @FXML public VBox transactionsVBox;
 | 
					    @FXML public VBox transactionsVBox;
 | 
				
			||||||
    private DataSourcePaginationControls paginationControls;
 | 
					    private DataSourcePaginationControls paginationControls;
 | 
				
			||||||
| 
						 | 
					@ -59,33 +66,30 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
				
			||||||
            paginationControls.setPage(1);
 | 
					            paginationControls.setPage(1);
 | 
				
			||||||
            selectedTransaction.set(null);
 | 
					            selectedTransaction.set(null);
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					        searchField.textProperty().addListener((observable, oldValue, newValue) -> {
 | 
				
			||||||
 | 
					            paginationControls.setPage(1);
 | 
				
			||||||
 | 
					            selectedTransaction.set(null);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        this.paginationControls = new DataSourcePaginationControls(
 | 
					        this.paginationControls = new DataSourcePaginationControls(
 | 
				
			||||||
                transactionsVBox.getChildren(),
 | 
					                transactionsVBox.getChildren(),
 | 
				
			||||||
                new DataSourcePaginationControls.PageFetcherFunction() {
 | 
					                new DataSourcePaginationControls.PageFetcherFunction() {
 | 
				
			||||||
                    @Override
 | 
					                    @Override
 | 
				
			||||||
                    public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
 | 
					                    public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
 | 
				
			||||||
                        Account accountFilter = filterByAccountComboBox.getValue();
 | 
					                        JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
 | 
				
			||||||
                        try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
 | 
					                        try (var conn = ds.getConnection()) {
 | 
				
			||||||
                            Page<Transaction> result;
 | 
					                            JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
 | 
				
			||||||
                            if (accountFilter == null) {
 | 
					                            return searcher.search(pagination, getCurrentSearchFilters())
 | 
				
			||||||
                                result = repo.findAll(pagination);
 | 
					                                    .map(TransactionsViewController.this::makeTile);
 | 
				
			||||||
                            } else {
 | 
					 | 
				
			||||||
                                result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination);
 | 
					 | 
				
			||||||
                            }
 | 
					 | 
				
			||||||
                            return result.map(TransactionsViewController.this::makeTile);
 | 
					 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    @Override
 | 
					                    @Override
 | 
				
			||||||
                    public int getTotalCount() throws Exception {
 | 
					                    public int getTotalCount() throws Exception {
 | 
				
			||||||
                        Account accountFilter = filterByAccountComboBox.getValue();
 | 
					                        JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
 | 
				
			||||||
                        try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
 | 
					                        try (var conn = ds.getConnection()) {
 | 
				
			||||||
                            if (accountFilter == null) {
 | 
					                            JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
 | 
				
			||||||
                                return (int) repo.countAll();
 | 
					                            return (int) searcher.resultCount(getCurrentSearchFilters());
 | 
				
			||||||
                            } else {
 | 
					 | 
				
			||||||
                                return (int) repo.countAllByAccounts(Set.of(accountFilter.id));
 | 
					 | 
				
			||||||
                            }
 | 
					 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
| 
						 | 
					@ -98,18 +102,13 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
				
			||||||
        detailPanel.minWidthProperty().bind(halfWidthProp);
 | 
					        detailPanel.minWidthProperty().bind(halfWidthProp);
 | 
				
			||||||
        detailPanel.maxWidthProperty().bind(halfWidthProp);
 | 
					        detailPanel.maxWidthProperty().bind(halfWidthProp);
 | 
				
			||||||
        detailPanel.prefWidthProperty().bind(halfWidthProp);
 | 
					        detailPanel.prefWidthProperty().bind(halfWidthProp);
 | 
				
			||||||
        detailPanel.managedProperty().bind(detailPanel.visibleProperty());
 | 
					        BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull());
 | 
				
			||||||
        detailPanel.visibleProperty().bind(selectedTransaction.isNotNull());
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
 | 
					        Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
 | 
				
			||||||
        TransactionViewController transactionViewController = detailComponents.second();
 | 
					        TransactionViewController transactionViewController = detailComponents.second();
 | 
				
			||||||
        BorderPane transactionDetailView = detailComponents.first();
 | 
					        BorderPane transactionDetailView = detailComponents.first();
 | 
				
			||||||
        transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty());
 | 
					 | 
				
			||||||
        transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull());
 | 
					 | 
				
			||||||
        detailPanel.getChildren().add(transactionDetailView);
 | 
					        detailPanel.getChildren().add(transactionDetailView);
 | 
				
			||||||
        selectedTransaction.addListener((observable, oldValue, newValue) -> {
 | 
					        selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue));
 | 
				
			||||||
            transactionViewController.setTransaction(newValue);
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Clear the transactions when a new profile is loaded.
 | 
					        // Clear the transactions when a new profile is loaded.
 | 
				
			||||||
        Profile.whenLoaded(profile -> {
 | 
					        Profile.whenLoaded(profile -> {
 | 
				
			||||||
| 
						 | 
					@ -121,10 +120,10 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
				
			||||||
    @Override
 | 
					    @Override
 | 
				
			||||||
    public void onRouteSelected(Object context) {
 | 
					    public void onRouteSelected(Object context) {
 | 
				
			||||||
        paginationControls.sorts.setAll(DEFAULT_SORTS);
 | 
					        paginationControls.sorts.setAll(DEFAULT_SORTS);
 | 
				
			||||||
        transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially.
 | 
					        selectedTransaction.set(null); // Initially set the selected transaction as null.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Refresh account filter options.
 | 
					        // Refresh account filter options.
 | 
				
			||||||
        Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
					        Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
				
			||||||
            List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
 | 
					            List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
 | 
				
			||||||
            Platform.runLater(() -> {
 | 
					            Platform.runLater(() -> {
 | 
				
			||||||
                filterByAccountComboBox.setAccounts(accounts);
 | 
					                filterByAccountComboBox.setAccounts(accounts);
 | 
				
			||||||
| 
						 | 
					@ -135,18 +134,19 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // If a transaction id is given in the route context, navigate to the page it's on and select it.
 | 
					        // If a transaction id is given in the route context, navigate to the page it's on and select it.
 | 
				
			||||||
        if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
 | 
					        if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
 | 
				
			||||||
            Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
 | 
					            Profile.getCurrent().dataSource().useRepoAsync(
 | 
				
			||||||
                repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
 | 
					                    TransactionRepository.class,
 | 
				
			||||||
                    long offset = repo.countAllAfter(tx.id);
 | 
					                    repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
 | 
				
			||||||
                    int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
 | 
					                        long offset = repo.countAllAfter(tx.id);
 | 
				
			||||||
                    Platform.runLater(() -> {
 | 
					                        int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
 | 
				
			||||||
                        paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx));
 | 
					                        Platform.runLater(() -> {
 | 
				
			||||||
                    });
 | 
					                            paginationControls.setPage(pageNumber);
 | 
				
			||||||
                });
 | 
					                            selectedTransaction.set(tx);
 | 
				
			||||||
            });
 | 
					                        });
 | 
				
			||||||
 | 
					                    })
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
            paginationControls.setPage(1);
 | 
					            paginationControls.setPage(1);
 | 
				
			||||||
            selectedTransaction.set(null);
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -161,7 +161,7 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
				
			||||||
        File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow());
 | 
					        File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow());
 | 
				
			||||||
        if (file != null) {
 | 
					        if (file != null) {
 | 
				
			||||||
            try (
 | 
					            try (
 | 
				
			||||||
                    var repo = Profile.getCurrent().getDataSource().getTransactionRepository();
 | 
					                    var repo = Profile.getCurrent().dataSource().getTransactionRepository();
 | 
				
			||||||
                    var out = new PrintWriter(file, StandardCharsets.UTF_8)
 | 
					                    var out = new PrintWriter(file, StandardCharsets.UTF_8)
 | 
				
			||||||
            ) {
 | 
					            ) {
 | 
				
			||||||
                out.println("id,utc-timestamp,amount,currency,description");
 | 
					                out.println("id,utc-timestamp,amount,currency,description");
 | 
				
			||||||
| 
						 | 
					@ -177,11 +177,42 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
				
			||||||
                    ));
 | 
					                    ));
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            } catch (Exception e) {
 | 
					            } catch (Exception e) {
 | 
				
			||||||
                Popups.error("An error occurred: " + e.getMessage());
 | 
					                Popups.error(transactionsListBorderPane, e);
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private List<SearchFilter> getCurrentSearchFilters() {
 | 
				
			||||||
 | 
					        List<SearchFilter> filters = new ArrayList<>();
 | 
				
			||||||
 | 
					        if (searchField.getText() != null && !searchField.getText().isBlank()) {
 | 
				
			||||||
 | 
					            var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+"))
 | 
				
			||||||
 | 
					                    .map(t -> '%'+t+'%')
 | 
				
			||||||
 | 
					                    .toList();
 | 
				
			||||||
 | 
					            var builder = new SearchFilter.Builder();
 | 
				
			||||||
 | 
					            List<String> orClauses = new ArrayList<>(likeTerms.size());
 | 
				
			||||||
 | 
					            for (var term : likeTerms) {
 | 
				
			||||||
 | 
					                orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?");
 | 
				
			||||||
 | 
					                builder.withArg(term);
 | 
				
			||||||
 | 
					                builder.withArg(term);
 | 
				
			||||||
 | 
					                builder.withArg(term);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            builder.where(String.join(" OR ", orClauses));
 | 
				
			||||||
 | 
					            builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id");
 | 
				
			||||||
 | 
					            builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id");
 | 
				
			||||||
 | 
					            filters.add(builder.build());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (filterByAccountComboBox.getValue() != null) {
 | 
				
			||||||
 | 
					            Account filteredAccount = filterByAccountComboBox.getValue();
 | 
				
			||||||
 | 
					            var filter = new SearchFilter.Builder()
 | 
				
			||||||
 | 
					                    .where("fae.account_id = ?")
 | 
				
			||||||
 | 
					                    .withArg(filteredAccount.id)
 | 
				
			||||||
 | 
					                    .withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id")
 | 
				
			||||||
 | 
					                    .build();
 | 
				
			||||||
 | 
					            filters.add(filter);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return filters;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private TransactionTile makeTile(Transaction transaction) {
 | 
					    private TransactionTile makeTile(Transaction transaction) {
 | 
				
			||||||
        var tile = new TransactionTile(transaction);
 | 
					        var tile = new TransactionTile(transaction);
 | 
				
			||||||
        tile.setOnMouseClicked(event -> {
 | 
					        tile.setOnMouseClicked(event -> {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,45 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.control;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.TransactionVendor;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.BindingUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.view.component.VendorTile;
 | 
				
			||||||
 | 
					import javafx.application.Platform;
 | 
				
			||||||
 | 
					import javafx.collections.FXCollections;
 | 
				
			||||||
 | 
					import javafx.collections.ObservableList;
 | 
				
			||||||
 | 
					import javafx.fxml.FXML;
 | 
				
			||||||
 | 
					import javafx.scene.layout.VBox;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import static com.andrewlalis.perfin.PerfinApp.router;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class VendorsViewController implements RouteSelectionListener {
 | 
				
			||||||
 | 
					    @FXML public VBox vendorsVBox;
 | 
				
			||||||
 | 
					    private final ObservableList<TransactionVendor> vendors = FXCollections.observableArrayList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void initialize() {
 | 
				
			||||||
 | 
					        BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, vendor -> new VendorTile(vendor, this::refreshVendors));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void onRouteSelected(Object context) {
 | 
				
			||||||
 | 
					        refreshVendors();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @FXML public void addVendor() {
 | 
				
			||||||
 | 
					        router.navigate("edit-vendor");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void refreshVendors() {
 | 
				
			||||||
 | 
					        Profile.getCurrent().dataSource().useRepoAsync(TransactionVendorRepository.class, repo -> {
 | 
				
			||||||
 | 
					            final List<TransactionVendor> vendors = repo.findAll();
 | 
				
			||||||
 | 
					            Platform.runLater(() -> {
 | 
				
			||||||
 | 
					                this.vendors.clear();
 | 
				
			||||||
 | 
					                this.vendors.addAll(vendors);
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,25 +0,0 @@
 | 
				
			||||||
package com.andrewlalis.perfin.data;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.AccountEntry;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.BalanceRecord;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import java.time.LocalDateTime;
 | 
					 | 
				
			||||||
import java.util.List;
 | 
					 | 
				
			||||||
import java.util.Optional;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public interface AccountHistoryItemRepository extends Repository, AutoCloseable {
 | 
					 | 
				
			||||||
    void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId);
 | 
					 | 
				
			||||||
    void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId);
 | 
					 | 
				
			||||||
    void recordText(LocalDateTime timestamp, long accountId, String text);
 | 
					 | 
				
			||||||
    List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count);
 | 
					 | 
				
			||||||
    default Optional<AccountHistoryItem> getMostRecentForAccount(long accountId) {
 | 
					 | 
				
			||||||
        var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1);
 | 
					 | 
				
			||||||
        if (items.isEmpty()) return Optional.empty();
 | 
					 | 
				
			||||||
        return Optional.of(items.getFirst());
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    String getTextItem(long itemId);
 | 
					 | 
				
			||||||
    AccountEntry getAccountEntryItem(long itemId);
 | 
					 | 
				
			||||||
    BalanceRecord getBalanceRecordItem(long itemId);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -30,8 +30,10 @@ public interface DataSource {
 | 
				
			||||||
    AccountRepository getAccountRepository();
 | 
					    AccountRepository getAccountRepository();
 | 
				
			||||||
    BalanceRecordRepository getBalanceRecordRepository();
 | 
					    BalanceRecordRepository getBalanceRecordRepository();
 | 
				
			||||||
    TransactionRepository getTransactionRepository();
 | 
					    TransactionRepository getTransactionRepository();
 | 
				
			||||||
 | 
					    TransactionVendorRepository getTransactionVendorRepository();
 | 
				
			||||||
 | 
					    TransactionCategoryRepository getTransactionCategoryRepository();
 | 
				
			||||||
    AttachmentRepository getAttachmentRepository();
 | 
					    AttachmentRepository getAttachmentRepository();
 | 
				
			||||||
    AccountHistoryItemRepository getAccountHistoryItemRepository();
 | 
					    HistoryRepository getHistoryRepository();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Repository helper methods:
 | 
					    // Repository helper methods:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -81,8 +83,10 @@ public interface DataSource {
 | 
				
			||||||
                AccountRepository.class, this::getAccountRepository,
 | 
					                AccountRepository.class, this::getAccountRepository,
 | 
				
			||||||
                BalanceRecordRepository.class, this::getBalanceRecordRepository,
 | 
					                BalanceRecordRepository.class, this::getBalanceRecordRepository,
 | 
				
			||||||
                TransactionRepository.class, this::getTransactionRepository,
 | 
					                TransactionRepository.class, this::getTransactionRepository,
 | 
				
			||||||
 | 
					                TransactionVendorRepository.class, this::getTransactionVendorRepository,
 | 
				
			||||||
 | 
					                TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
 | 
				
			||||||
                AttachmentRepository.class, this::getAttachmentRepository,
 | 
					                AttachmentRepository.class, this::getAttachmentRepository,
 | 
				
			||||||
                AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository
 | 
					                HistoryRepository.class, this::getHistoryRepository
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        return (Supplier<R>) repoSuppliers.get(type);
 | 
					        return (Supplier<R>) repoSuppliers.get(type);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,21 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.io.IOException;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Interface that defines the data source factory, a component responsible for
 | 
				
			||||||
 | 
					 * obtaining a data source, and performing some introspection around that data
 | 
				
			||||||
 | 
					 * source before one is obtained.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public interface DataSourceFactory {
 | 
				
			||||||
 | 
					    DataSource getDataSource(String profileName) throws ProfileLoadException;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    enum SchemaStatus {
 | 
				
			||||||
 | 
					        UP_TO_DATE,
 | 
				
			||||||
 | 
					        NEEDS_MIGRATION,
 | 
				
			||||||
 | 
					        INCOMPATIBLE
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    SchemaStatus getSchemaStatus(String profileName) throws IOException;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    int getSchemaVersion(String profileName) throws IOException;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,28 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.pagination.Page;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.DateUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.history.HistoryItem;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.history.HistoryTextItem;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.time.LocalDateTime;
 | 
				
			||||||
 | 
					import java.time.ZoneOffset;
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public interface HistoryRepository extends Repository, AutoCloseable {
 | 
				
			||||||
 | 
					    long getOrCreateHistoryForAccount(long accountId);
 | 
				
			||||||
 | 
					    long getOrCreateHistoryForTransaction(long transactionId);
 | 
				
			||||||
 | 
					    void deleteHistoryForAccount(long accountId);
 | 
				
			||||||
 | 
					    void deleteHistoryForTransaction(long transactionId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description);
 | 
				
			||||||
 | 
					    default HistoryTextItem addTextItem(long historyId, String description) {
 | 
				
			||||||
 | 
					        return addTextItem(historyId, DateUtil.nowAsUTC(), description);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    Page<HistoryItem> getItems(long historyId, PageRequest pagination);
 | 
				
			||||||
 | 
					    List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
 | 
				
			||||||
 | 
					    default List<HistoryItem> getNItemsBeforeNow(long historyId, int n) {
 | 
				
			||||||
 | 
					        return getNItemsBefore(historyId, n, LocalDateTime.now(ZoneOffset.UTC));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,21 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.TransactionCategory;
 | 
				
			||||||
 | 
					import javafx.scene.paint.Color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					import java.util.Optional;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public interface TransactionCategoryRepository extends Repository, AutoCloseable {
 | 
				
			||||||
 | 
					    Optional<TransactionCategory> findById(long id);
 | 
				
			||||||
 | 
					    Optional<TransactionCategory> findByName(String name);
 | 
				
			||||||
 | 
					    List<TransactionCategory> findAllBaseCategories();
 | 
				
			||||||
 | 
					    List<TransactionCategory> findAll();
 | 
				
			||||||
 | 
					    long insert(long parentId, String name, Color color);
 | 
				
			||||||
 | 
					    long insert(String name, Color color);
 | 
				
			||||||
 | 
					    void update(long id, String name, Color color);
 | 
				
			||||||
 | 
					    void deleteById(long id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children){}
 | 
				
			||||||
 | 
					    List<CategoryTreeNode> findTree();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -21,6 +21,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
 | 
				
			||||||
            Currency currency,
 | 
					            Currency currency,
 | 
				
			||||||
            String description,
 | 
					            String description,
 | 
				
			||||||
            CreditAndDebitAccounts linkedAccounts,
 | 
					            CreditAndDebitAccounts linkedAccounts,
 | 
				
			||||||
 | 
					            String vendor,
 | 
				
			||||||
 | 
					            String category,
 | 
				
			||||||
 | 
					            Set<String> tags,
 | 
				
			||||||
            List<Path> attachments
 | 
					            List<Path> attachments
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    Optional<Transaction> findById(long id);
 | 
					    Optional<Transaction> findById(long id);
 | 
				
			||||||
| 
						 | 
					@ -31,6 +34,10 @@ public interface TransactionRepository extends Repository, AutoCloseable {
 | 
				
			||||||
    Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
 | 
					    Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
 | 
				
			||||||
    CreditAndDebitAccounts findLinkedAccounts(long transactionId);
 | 
					    CreditAndDebitAccounts findLinkedAccounts(long transactionId);
 | 
				
			||||||
    List<Attachment> findAttachments(long transactionId);
 | 
					    List<Attachment> findAttachments(long transactionId);
 | 
				
			||||||
 | 
					    List<String> findTags(long transactionId);
 | 
				
			||||||
 | 
					    List<String> findAllTags();
 | 
				
			||||||
 | 
					    void deleteTag(String name);
 | 
				
			||||||
 | 
					    long countTagUsages(String name);
 | 
				
			||||||
    void delete(long transactionId);
 | 
					    void delete(long transactionId);
 | 
				
			||||||
    void update(
 | 
					    void update(
 | 
				
			||||||
            long id,
 | 
					            long id,
 | 
				
			||||||
| 
						 | 
					@ -39,6 +46,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
 | 
				
			||||||
            Currency currency,
 | 
					            Currency currency,
 | 
				
			||||||
            String description,
 | 
					            String description,
 | 
				
			||||||
            CreditAndDebitAccounts linkedAccounts,
 | 
					            CreditAndDebitAccounts linkedAccounts,
 | 
				
			||||||
 | 
					            String vendor,
 | 
				
			||||||
 | 
					            String category,
 | 
				
			||||||
 | 
					            Set<String> tags,
 | 
				
			||||||
            List<Attachment> existingAttachments,
 | 
					            List<Attachment> existingAttachments,
 | 
				
			||||||
            List<Path> newAttachmentPaths
 | 
					            List<Path> newAttachmentPaths
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,16 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.TransactionVendor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					import java.util.Optional;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public interface TransactionVendorRepository extends Repository, AutoCloseable {
 | 
				
			||||||
 | 
					    Optional<TransactionVendor> findById(long id);
 | 
				
			||||||
 | 
					    Optional<TransactionVendor> findByName(String name);
 | 
				
			||||||
 | 
					    List<TransactionVendor> findAll();
 | 
				
			||||||
 | 
					    long insert(String name, String description);
 | 
				
			||||||
 | 
					    long insert(String name);
 | 
				
			||||||
 | 
					    void update(long id, String name, String description);
 | 
				
			||||||
 | 
					    void deleteById(long id);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,7 @@
 | 
				
			||||||
package com.andrewlalis.perfin.data.impl;
 | 
					package com.andrewlalis.perfin.data.impl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
 | 
					import com.andrewlalis.perfin.data.AccountEntryRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
					import com.andrewlalis.perfin.data.HistoryRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.AccountEntry;
 | 
					import com.andrewlalis.perfin.model.AccountEntry;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -31,8 +31,9 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        // Insert an entry into the account's history.
 | 
					        // Insert an entry into the account's history.
 | 
				
			||||||
        AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
 | 
					        HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
 | 
				
			||||||
        historyRepo.recordAccountEntry(timestamp, accountId, entryId);
 | 
					        long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
 | 
				
			||||||
 | 
					        historyRepo.addTextItem(historyId, timestamp, "Entry #" + entryId + " added as a " + type.name() + " from Transaction #" + transactionId + ".");
 | 
				
			||||||
        return entryId;
 | 
					        return entryId;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,120 +0,0 @@
 | 
				
			||||||
package com.andrewlalis.perfin.data.impl;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.AccountEntry;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.BalanceRecord;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItemType;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import java.sql.Connection;
 | 
					 | 
				
			||||||
import java.sql.ResultSet;
 | 
					 | 
				
			||||||
import java.sql.SQLException;
 | 
					 | 
				
			||||||
import java.time.LocalDateTime;
 | 
					 | 
				
			||||||
import java.util.List;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository {
 | 
					 | 
				
			||||||
    @Override
 | 
					 | 
				
			||||||
    public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) {
 | 
					 | 
				
			||||||
        long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY);
 | 
					 | 
				
			||||||
        DbUtil.insertOne(
 | 
					 | 
				
			||||||
                conn,
 | 
					 | 
				
			||||||
                "INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)",
 | 
					 | 
				
			||||||
                List.of(itemId, entryId)
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @Override
 | 
					 | 
				
			||||||
    public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) {
 | 
					 | 
				
			||||||
        long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD);
 | 
					 | 
				
			||||||
        DbUtil.insertOne(
 | 
					 | 
				
			||||||
                conn,
 | 
					 | 
				
			||||||
                "INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)",
 | 
					 | 
				
			||||||
                List.of(itemId, recordId)
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @Override
 | 
					 | 
				
			||||||
    public void recordText(LocalDateTime timestamp, long accountId, String text) {
 | 
					 | 
				
			||||||
        long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT);
 | 
					 | 
				
			||||||
        DbUtil.insertOne(
 | 
					 | 
				
			||||||
                conn,
 | 
					 | 
				
			||||||
                "INSERT INTO account_history_item_text (item_id, description) VALUES (?, ?)",
 | 
					 | 
				
			||||||
                List.of(itemId, text)
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @Override
 | 
					 | 
				
			||||||
    public List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count) {
 | 
					 | 
				
			||||||
        return DbUtil.findAll(
 | 
					 | 
				
			||||||
                conn,
 | 
					 | 
				
			||||||
                "SELECT * FROM account_history_item WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT " + count,
 | 
					 | 
				
			||||||
                List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
 | 
					 | 
				
			||||||
                JdbcAccountHistoryItemRepository::parseHistoryItem
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @Override
 | 
					 | 
				
			||||||
    public String getTextItem(long itemId) {
 | 
					 | 
				
			||||||
        return DbUtil.findOne(
 | 
					 | 
				
			||||||
                conn,
 | 
					 | 
				
			||||||
                "SELECT description FROM account_history_item_text WHERE item_id = ?",
 | 
					 | 
				
			||||||
                List.of(itemId),
 | 
					 | 
				
			||||||
                rs -> rs.getString(1)
 | 
					 | 
				
			||||||
        ).orElse(null);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @Override
 | 
					 | 
				
			||||||
    public AccountEntry getAccountEntryItem(long itemId) {
 | 
					 | 
				
			||||||
        return DbUtil.findOne(
 | 
					 | 
				
			||||||
                conn,
 | 
					 | 
				
			||||||
                """
 | 
					 | 
				
			||||||
                        SELECT *
 | 
					 | 
				
			||||||
                        FROM account_entry
 | 
					 | 
				
			||||||
                        LEFT JOIN account_history_item_account_entry h ON h.entry_id = account_entry.id
 | 
					 | 
				
			||||||
                        WHERE h.item_id = ?""",
 | 
					 | 
				
			||||||
                List.of(itemId),
 | 
					 | 
				
			||||||
                JdbcAccountEntryRepository::parse
 | 
					 | 
				
			||||||
        ).orElse(null);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @Override
 | 
					 | 
				
			||||||
    public BalanceRecord getBalanceRecordItem(long itemId) {
 | 
					 | 
				
			||||||
        return DbUtil.findOne(
 | 
					 | 
				
			||||||
                conn,
 | 
					 | 
				
			||||||
                """
 | 
					 | 
				
			||||||
                        SELECT *
 | 
					 | 
				
			||||||
                        FROM balance_record
 | 
					 | 
				
			||||||
                        LEFT JOIN account_history_item_balance_record h ON h.record_id = balance_record.id
 | 
					 | 
				
			||||||
                        WHERE h.item_id = ?""",
 | 
					 | 
				
			||||||
                List.of(itemId),
 | 
					 | 
				
			||||||
                JdbcBalanceRecordRepository::parse
 | 
					 | 
				
			||||||
        ).orElse(null);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @Override
 | 
					 | 
				
			||||||
    public void close() throws Exception {
 | 
					 | 
				
			||||||
        conn.close();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public static AccountHistoryItem parseHistoryItem(ResultSet rs) throws SQLException {
 | 
					 | 
				
			||||||
        return new AccountHistoryItem(
 | 
					 | 
				
			||||||
                rs.getLong("id"),
 | 
					 | 
				
			||||||
                DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
 | 
					 | 
				
			||||||
                rs.getLong("account_id"),
 | 
					 | 
				
			||||||
                AccountHistoryItemType.valueOf(rs.getString("type"))
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
 | 
					 | 
				
			||||||
        return DbUtil.insertOne(
 | 
					 | 
				
			||||||
                conn,
 | 
					 | 
				
			||||||
                "INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)",
 | 
					 | 
				
			||||||
                List.of(
 | 
					 | 
				
			||||||
                        DbUtil.timestampFromUtcLDT(timestamp),
 | 
					 | 
				
			||||||
                        accountId,
 | 
					 | 
				
			||||||
                        type.name()
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,12 +1,8 @@
 | 
				
			||||||
package com.andrewlalis.perfin.data.impl;
 | 
					package com.andrewlalis.perfin.data.impl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
 | 
					import com.andrewlalis.perfin.data.*;
 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountRepository;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.EntityNotFoundException;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
					import com.andrewlalis.perfin.data.pagination.Page;
 | 
				
			||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
					import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Account;
 | 
					import com.andrewlalis.perfin.model.Account;
 | 
				
			||||||
import com.andrewlalis.perfin.model.AccountEntry;
 | 
					import com.andrewlalis.perfin.model.AccountEntry;
 | 
				
			||||||
| 
						 | 
					@ -43,8 +39,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
            // Insert a history item indicating the creation of the account.
 | 
					            // Insert a history item indicating the creation of the account.
 | 
				
			||||||
            var historyRepo = new JdbcAccountHistoryItemRepository(conn);
 | 
					            HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
 | 
				
			||||||
            historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile.");
 | 
					            long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
 | 
				
			||||||
 | 
					            historyRepo.addTextItem(historyId, "Account added to your Perfin profile.");
 | 
				
			||||||
            return accountId;
 | 
					            return accountId;
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -59,11 +56,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
 | 
				
			||||||
        return DbUtil.findAll(
 | 
					        return DbUtil.findAll(
 | 
				
			||||||
                conn,
 | 
					                conn,
 | 
				
			||||||
                """
 | 
					                """
 | 
				
			||||||
                SELECT DISTINCT ON (account.id) account.*, ahi.timestamp AS _
 | 
					                SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
 | 
				
			||||||
                FROM account
 | 
					                FROM account
 | 
				
			||||||
                LEFT OUTER JOIN account_history_item ahi ON ahi.account_id = account.id
 | 
					                LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
 | 
				
			||||||
 | 
					                LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
 | 
				
			||||||
                WHERE NOT account.archived
 | 
					                WHERE NOT account.archived
 | 
				
			||||||
                ORDER BY ahi.timestamp DESC, account.created_at DESC""",
 | 
					                ORDER BY hi.timestamp DESC, account.created_at DESC""",
 | 
				
			||||||
                JdbcAccountRepository::parseAccount
 | 
					                JdbcAccountRepository::parseAccount
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -160,7 +158,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
 | 
				
			||||||
    public void archive(long accountId) {
 | 
					    public void archive(long accountId) {
 | 
				
			||||||
        DbUtil.doTransaction(conn, () -> {
 | 
					        DbUtil.doTransaction(conn, () -> {
 | 
				
			||||||
            DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId));
 | 
					            DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId));
 | 
				
			||||||
            new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been archived.");
 | 
					            HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
 | 
				
			||||||
 | 
					            long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
 | 
				
			||||||
 | 
					            historyRepo.addTextItem(historyId, "Account has been archived.");
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -168,7 +168,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
 | 
				
			||||||
    public void unarchive(long accountId) {
 | 
					    public void unarchive(long accountId) {
 | 
				
			||||||
        DbUtil.doTransaction(conn, () -> {
 | 
					        DbUtil.doTransaction(conn, () -> {
 | 
				
			||||||
            DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId));
 | 
					            DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId));
 | 
				
			||||||
            new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been unarchived.");
 | 
					            HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
 | 
				
			||||||
 | 
					            long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
 | 
				
			||||||
 | 
					            historyRepo.addTextItem(historyId, "Account has been unarchived.");
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,13 @@
 | 
				
			||||||
package com.andrewlalis.perfin.data.impl;
 | 
					package com.andrewlalis.perfin.data.impl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.AttachmentRepository;
 | 
					import com.andrewlalis.perfin.data.AttachmentRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.BalanceRecordRepository;
 | 
					import com.andrewlalis.perfin.data.BalanceRecordRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.HistoryRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Attachment;
 | 
					import com.andrewlalis.perfin.model.Attachment;
 | 
				
			||||||
import com.andrewlalis.perfin.model.BalanceRecord;
 | 
					import com.andrewlalis.perfin.model.BalanceRecord;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.MoneyValue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import java.math.BigDecimal;
 | 
					import java.math.BigDecimal;
 | 
				
			||||||
import java.nio.file.Path;
 | 
					import java.nio.file.Path;
 | 
				
			||||||
| 
						 | 
					@ -36,8 +38,9 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            // Add a history item entry.
 | 
					            // Add a history item entry.
 | 
				
			||||||
            AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
 | 
					            HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
 | 
				
			||||||
            historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId);
 | 
					            long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
 | 
				
			||||||
 | 
					            historyRepo.addTextItem(historyId, utcTimestamp, "Balance Record #" + recordId + " added with a value of " + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(balance, currency)));
 | 
				
			||||||
            return recordId;
 | 
					            return recordId;
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -49,13 +49,23 @@ public class JdbcDataSource implements DataSource {
 | 
				
			||||||
        return new JdbcTransactionRepository(getConnection(), contentDir);
 | 
					        return new JdbcTransactionRepository(getConnection(), contentDir);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public TransactionVendorRepository getTransactionVendorRepository() {
 | 
				
			||||||
 | 
					        return new JdbcTransactionVendorRepository(getConnection());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public TransactionCategoryRepository getTransactionCategoryRepository() {
 | 
				
			||||||
 | 
					        return new JdbcTransactionCategoryRepository(getConnection());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @Override
 | 
					    @Override
 | 
				
			||||||
    public AttachmentRepository getAttachmentRepository() {
 | 
					    public AttachmentRepository getAttachmentRepository() {
 | 
				
			||||||
        return new JdbcAttachmentRepository(getConnection(), contentDir);
 | 
					        return new JdbcAttachmentRepository(getConnection(), contentDir);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @Override
 | 
					    @Override
 | 
				
			||||||
    public AccountHistoryItemRepository getAccountHistoryItemRepository() {
 | 
					    public HistoryRepository getHistoryRepository() {
 | 
				
			||||||
        return new JdbcAccountHistoryItemRepository(getConnection());
 | 
					        return new JdbcHistoryRepository(getConnection());
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,16 @@
 | 
				
			||||||
package com.andrewlalis.perfin.data.impl;
 | 
					package com.andrewlalis.perfin.data.impl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.data.DataSource;
 | 
					import com.andrewlalis.perfin.data.DataSource;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.DataSourceFactory;
 | 
				
			||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
					import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
				
			||||||
import com.andrewlalis.perfin.data.impl.migration.Migration;
 | 
					import com.andrewlalis.perfin.data.impl.migration.Migration;
 | 
				
			||||||
import com.andrewlalis.perfin.data.impl.migration.Migrations;
 | 
					import com.andrewlalis.perfin.data.impl.migration.Migrations;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
					import com.andrewlalis.perfin.data.util.FileUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.Profile;
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import com.fasterxml.jackson.databind.JsonNode;
 | 
				
			||||||
 | 
					import com.fasterxml.jackson.databind.ObjectMapper;
 | 
				
			||||||
 | 
					import com.fasterxml.jackson.databind.node.ArrayNode;
 | 
				
			||||||
import org.slf4j.Logger;
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
import org.slf4j.LoggerFactory;
 | 
					import org.slf4j.LoggerFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,16 +19,14 @@ import java.io.InputStream;
 | 
				
			||||||
import java.nio.charset.StandardCharsets;
 | 
					import java.nio.charset.StandardCharsets;
 | 
				
			||||||
import java.nio.file.Files;
 | 
					import java.nio.file.Files;
 | 
				
			||||||
import java.nio.file.Path;
 | 
					import java.nio.file.Path;
 | 
				
			||||||
import java.sql.Connection;
 | 
					import java.sql.*;
 | 
				
			||||||
import java.sql.SQLException;
 | 
					 | 
				
			||||||
import java.sql.Statement;
 | 
					 | 
				
			||||||
import java.util.Arrays;
 | 
					import java.util.Arrays;
 | 
				
			||||||
import java.util.List;
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Component that's responsible for obtaining a JDBC data source for a profile.
 | 
					 * Component that's responsible for obtaining a JDBC data source for a profile.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public class JdbcDataSourceFactory {
 | 
					public class JdbcDataSourceFactory implements DataSourceFactory {
 | 
				
			||||||
    private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class);
 | 
					    private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
| 
						 | 
					@ -32,7 +35,7 @@ public class JdbcDataSourceFactory {
 | 
				
			||||||
     * the profile has a newer schema version, we'll exit and prompt the user
 | 
					     * the profile has a newer schema version, we'll exit and prompt the user
 | 
				
			||||||
     * to update their app.
 | 
					     * to update their app.
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    public static final int SCHEMA_VERSION = 1;
 | 
					    public static final int SCHEMA_VERSION = 3;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public DataSource getDataSource(String profileName) throws ProfileLoadException {
 | 
					    public DataSource getDataSource(String profileName) throws ProfileLoadException {
 | 
				
			||||||
        final boolean dbExists = Files.exists(getDatabaseFile(profileName));
 | 
					        final boolean dbExists = Files.exists(getDatabaseFile(profileName));
 | 
				
			||||||
| 
						 | 
					@ -59,6 +62,13 @@ public class JdbcDataSourceFactory {
 | 
				
			||||||
        return new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
 | 
					        return new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public SchemaStatus getSchemaStatus(String profileName) throws IOException {
 | 
				
			||||||
 | 
					        int existingSchemaVersion = getSchemaVersion(profileName);
 | 
				
			||||||
 | 
					        if (existingSchemaVersion == SCHEMA_VERSION) return SchemaStatus.UP_TO_DATE;
 | 
				
			||||||
 | 
					        if (existingSchemaVersion < SCHEMA_VERSION) return SchemaStatus.NEEDS_MIGRATION;
 | 
				
			||||||
 | 
					        return SchemaStatus.INCOMPATIBLE;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private void createNewDatabase(String profileName) throws ProfileLoadException {
 | 
					    private void createNewDatabase(String profileName) throws ProfileLoadException {
 | 
				
			||||||
        log.info("Creating new database for profile {}.", profileName);
 | 
					        log.info("Creating new database for profile {}.", profileName);
 | 
				
			||||||
        JdbcDataSource dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
 | 
					        JdbcDataSource dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
 | 
				
			||||||
| 
						 | 
					@ -69,6 +79,7 @@ public class JdbcDataSourceFactory {
 | 
				
			||||||
            if (in == null) throw new IOException("Could not load database schema SQL file.");
 | 
					            if (in == null) throw new IOException("Could not load database schema SQL file.");
 | 
				
			||||||
            String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
 | 
					            String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
 | 
				
			||||||
            executeSqlScript(schemaStr, conn);
 | 
					            executeSqlScript(schemaStr, conn);
 | 
				
			||||||
 | 
					            insertDefaultData(conn);
 | 
				
			||||||
            try {
 | 
					            try {
 | 
				
			||||||
                writeCurrentSchemaVersion(profileName);
 | 
					                writeCurrentSchemaVersion(profileName);
 | 
				
			||||||
            } catch (IOException e) {
 | 
					            } catch (IOException e) {
 | 
				
			||||||
| 
						 | 
					@ -89,6 +100,53 @@ public class JdbcDataSourceFactory {
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Inserts all default data into the database, using static content found in
 | 
				
			||||||
 | 
					     * various locations on the classpath.
 | 
				
			||||||
 | 
					     * @param conn The connection to use to insert data.
 | 
				
			||||||
 | 
					     * @throws IOException If resources couldn't be read.
 | 
				
			||||||
 | 
					     * @throws SQLException If SQL fails.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    public void insertDefaultData(Connection conn) throws IOException, SQLException {
 | 
				
			||||||
 | 
					        insertDefaultCategories(conn);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public void insertDefaultCategories(Connection conn) throws IOException, SQLException {
 | 
				
			||||||
 | 
					        try (
 | 
				
			||||||
 | 
					                var categoriesIn = JdbcDataSourceFactory.class.getResourceAsStream("/sql/data/default-categories.json");
 | 
				
			||||||
 | 
					                var stmt = conn.prepareStatement(
 | 
				
			||||||
 | 
					                        "INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
 | 
				
			||||||
 | 
					                        Statement.RETURN_GENERATED_KEYS
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					        ) {
 | 
				
			||||||
 | 
					            if (categoriesIn == null) throw new IOException("Couldn't load default categories file.");
 | 
				
			||||||
 | 
					            ObjectMapper mapper = new ObjectMapper();
 | 
				
			||||||
 | 
					            ArrayNode categoriesArray = mapper.readValue(categoriesIn, ArrayNode.class);
 | 
				
			||||||
 | 
					            insertCategoriesRecursive(stmt, categoriesArray, null, "#FFFFFF");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void insertCategoriesRecursive(PreparedStatement stmt, ArrayNode categoriesArray, Long parentId, String parentColorHex) throws SQLException {
 | 
				
			||||||
 | 
					        for (JsonNode obj : categoriesArray) {
 | 
				
			||||||
 | 
					            String name = obj.get("name").asText();
 | 
				
			||||||
 | 
					            String colorHex = parentColorHex;
 | 
				
			||||||
 | 
					            if (obj.hasNonNull("color")) colorHex = obj.get("color").asText(parentColorHex);
 | 
				
			||||||
 | 
					            if (parentId == null) {
 | 
				
			||||||
 | 
					                stmt.setNull(1, Types.BIGINT);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                stmt.setLong(1, parentId);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            stmt.setString(2, name);
 | 
				
			||||||
 | 
					            stmt.setString(3, colorHex.substring(1));
 | 
				
			||||||
 | 
					            int result = stmt.executeUpdate();
 | 
				
			||||||
 | 
					            if (result != 1) throw new SQLException("Failed to insert category.");
 | 
				
			||||||
 | 
					            long id = DbUtil.getGeneratedId(stmt);
 | 
				
			||||||
 | 
					            if (obj.hasNonNull("children") && obj.get("children").isArray()) {
 | 
				
			||||||
 | 
					                insertCategoriesRecursive(stmt, obj.withArray("children"), id, colorHex);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private boolean testConnection(JdbcDataSource dataSource) {
 | 
					    private boolean testConnection(JdbcDataSource dataSource) {
 | 
				
			||||||
        try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
 | 
					        try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
 | 
				
			||||||
            return stmt.execute("SELECT 1;");
 | 
					            return stmt.execute("SELECT 1;");
 | 
				
			||||||
| 
						 | 
					@ -168,7 +226,7 @@ public class JdbcDataSourceFactory {
 | 
				
			||||||
        return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt");
 | 
					        return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt");
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static int getSchemaVersion(String profileName) throws IOException {
 | 
					    public int getSchemaVersion(String profileName) throws IOException {
 | 
				
			||||||
        if (Files.exists(getSchemaVersionFile(profileName))) {
 | 
					        if (Files.exists(getSchemaVersionFile(profileName))) {
 | 
				
			||||||
            try {
 | 
					            try {
 | 
				
			||||||
                return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip());
 | 
					                return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip());
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,125 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data.impl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.HistoryRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.pagination.Page;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.DateUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.history.HistoryItem;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.history.HistoryTextItem;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.sql.Connection;
 | 
				
			||||||
 | 
					import java.sql.ResultSet;
 | 
				
			||||||
 | 
					import java.sql.SQLException;
 | 
				
			||||||
 | 
					import java.time.LocalDateTime;
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public record JdbcHistoryRepository(Connection conn) implements HistoryRepository {
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public long getOrCreateHistoryForAccount(long accountId) {
 | 
				
			||||||
 | 
					        return getOrCreateHistoryForEntity(accountId, "history_account", "account_id");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public long getOrCreateHistoryForTransaction(long transactionId) {
 | 
				
			||||||
 | 
					        return getOrCreateHistoryForEntity(transactionId, "history_transaction", "transaction_id");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private long getOrCreateHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
 | 
				
			||||||
 | 
					        String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
 | 
				
			||||||
 | 
					        var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
 | 
				
			||||||
 | 
					        if (optionalHistoryId.isPresent()) return optionalHistoryId.get();
 | 
				
			||||||
 | 
					        long historyId = DbUtil.insertOne(conn, "INSERT INTO history () VALUES ()");
 | 
				
			||||||
 | 
					        String insertQuery = "INSERT INTO " + joinTableName + " (" + joinColumn + ", history_id) VALUES (?, ?)";
 | 
				
			||||||
 | 
					        DbUtil.updateOne(conn, insertQuery, entityId, historyId);
 | 
				
			||||||
 | 
					        return historyId;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void deleteHistoryForAccount(long accountId) {
 | 
				
			||||||
 | 
					        deleteHistoryForEntity(accountId, "history_account", "account_id");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void deleteHistoryForTransaction(long transactionId) {
 | 
				
			||||||
 | 
					        deleteHistoryForEntity(transactionId, "history_transaction", "transaction_id");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void deleteHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
 | 
				
			||||||
 | 
					        String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
 | 
				
			||||||
 | 
					        var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
 | 
				
			||||||
 | 
					        if (optionalHistoryId.isPresent()) {
 | 
				
			||||||
 | 
					            long historyId = optionalHistoryId.get();
 | 
				
			||||||
 | 
					            DbUtil.updateOne(conn, "DELETE FROM history WHERE id = ?", historyId);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) {
 | 
				
			||||||
 | 
					        long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.TYPE_TEXT);
 | 
				
			||||||
 | 
					        DbUtil.updateOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "INSERT INTO history_item_text (id, description) VALUES (?, ?)",
 | 
				
			||||||
 | 
					                itemId,
 | 
				
			||||||
 | 
					                description
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return new HistoryTextItem(itemId, historyId, utcTimestamp, description);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) {
 | 
				
			||||||
 | 
					        return DbUtil.insertOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)",
 | 
				
			||||||
 | 
					                historyId,
 | 
				
			||||||
 | 
					                DbUtil.timestampFromUtcLDT(timestamp),
 | 
				
			||||||
 | 
					                type
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public Page<HistoryItem> getItems(long historyId, PageRequest pagination) {
 | 
				
			||||||
 | 
					        return DbUtil.findAll(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM history_item WHERE history_id = ?",
 | 
				
			||||||
 | 
					                pagination,
 | 
				
			||||||
 | 
					                List.of(historyId),
 | 
				
			||||||
 | 
					                JdbcHistoryRepository::parseItem
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp) {
 | 
				
			||||||
 | 
					        return DbUtil.findAll(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                """
 | 
				
			||||||
 | 
					                        SELECT *
 | 
				
			||||||
 | 
					                        FROM history_item
 | 
				
			||||||
 | 
					                        WHERE history_id = ? AND timestamp <= ?
 | 
				
			||||||
 | 
					                        ORDER BY timestamp DESC""",
 | 
				
			||||||
 | 
					                List.of(historyId, DbUtil.timestampFromUtcLDT(timestamp)),
 | 
				
			||||||
 | 
					                JdbcHistoryRepository::parseItem
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void close() throws Exception {
 | 
				
			||||||
 | 
					        conn.close();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static HistoryItem parseItem(ResultSet rs) throws SQLException {
 | 
				
			||||||
 | 
					        long id = rs.getLong(1);
 | 
				
			||||||
 | 
					        long historyId = rs.getLong(2);
 | 
				
			||||||
 | 
					        LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3));
 | 
				
			||||||
 | 
					        String type = rs.getString(4);
 | 
				
			||||||
 | 
					        if (type.equalsIgnoreCase(HistoryItem.TYPE_TEXT)) {
 | 
				
			||||||
 | 
					            String description = DbUtil.findOne(
 | 
				
			||||||
 | 
					                    rs.getStatement().getConnection(),
 | 
				
			||||||
 | 
					                    "SELECT description FROM history_item_text WHERE id = ?",
 | 
				
			||||||
 | 
					                    List.of(id),
 | 
				
			||||||
 | 
					                    r -> r.getString(1)
 | 
				
			||||||
 | 
					            ).orElseThrow();
 | 
				
			||||||
 | 
					            return new HistoryTextItem(id, historyId, timestamp, description);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        throw new SQLException("Unknown history item type: " + type);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,142 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data.impl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.ColorUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.TransactionCategory;
 | 
				
			||||||
 | 
					import javafx.scene.paint.Color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.sql.Connection;
 | 
				
			||||||
 | 
					import java.sql.ResultSet;
 | 
				
			||||||
 | 
					import java.sql.SQLException;
 | 
				
			||||||
 | 
					import java.util.ArrayList;
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					import java.util.Optional;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository {
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public Optional<TransactionCategory> findById(long id) {
 | 
				
			||||||
 | 
					        return DbUtil.findById(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM transaction_category WHERE id = ?",
 | 
				
			||||||
 | 
					                id,
 | 
				
			||||||
 | 
					                JdbcTransactionCategoryRepository::parseCategory
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public Optional<TransactionCategory> findByName(String name) {
 | 
				
			||||||
 | 
					        return DbUtil.findOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM transaction_category WHERE name = ?",
 | 
				
			||||||
 | 
					                List.of(name),
 | 
				
			||||||
 | 
					                JdbcTransactionCategoryRepository::parseCategory
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public List<TransactionCategory> findAllBaseCategories() {
 | 
				
			||||||
 | 
					        return DbUtil.findAll(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
 | 
				
			||||||
 | 
					                JdbcTransactionCategoryRepository::parseCategory
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public List<TransactionCategory> findAll() {
 | 
				
			||||||
 | 
					        return DbUtil.findAll(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM transaction_category ORDER BY parent_id ASC, name ASC",
 | 
				
			||||||
 | 
					                JdbcTransactionCategoryRepository::parseCategory
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public long insert(long parentId, String name, Color color) {
 | 
				
			||||||
 | 
					        return DbUtil.insertOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
 | 
				
			||||||
 | 
					                List.of(parentId, name, ColorUtil.toHex(color))
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public long insert(String name, Color color) {
 | 
				
			||||||
 | 
					        return DbUtil.insertOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "INSERT INTO transaction_category (name, color) VALUES (?, ?)",
 | 
				
			||||||
 | 
					                List.of(name, ColorUtil.toHex(color))
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void update(long id, String name, Color color) {
 | 
				
			||||||
 | 
					        DbUtil.doTransaction(conn, () -> {
 | 
				
			||||||
 | 
					            TransactionCategory category = findById(id).orElseThrow();
 | 
				
			||||||
 | 
					            if (!category.getName().equals(name)) {
 | 
				
			||||||
 | 
					                DbUtil.updateOne(
 | 
				
			||||||
 | 
					                        conn,
 | 
				
			||||||
 | 
					                        "UPDATE transaction_category SET name = ? WHERE id = ?",
 | 
				
			||||||
 | 
					                        name,
 | 
				
			||||||
 | 
					                        id
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            if (!category.getColor().equals(color)) {
 | 
				
			||||||
 | 
					                DbUtil.updateOne(
 | 
				
			||||||
 | 
					                        conn,
 | 
				
			||||||
 | 
					                        "UPDATE transaction_category SET color = ? WHERE id = ?",
 | 
				
			||||||
 | 
					                        ColorUtil.toHex(color),
 | 
				
			||||||
 | 
					                        id
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void deleteById(long id) {
 | 
				
			||||||
 | 
					        DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public List<CategoryTreeNode> findTree() {
 | 
				
			||||||
 | 
					        List<TransactionCategory> rootCategories = DbUtil.findAll(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
 | 
				
			||||||
 | 
					                JdbcTransactionCategoryRepository::parseCategory
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        List<CategoryTreeNode> rootNodes = new ArrayList<>(rootCategories.size());
 | 
				
			||||||
 | 
					        for (var category : rootCategories) {
 | 
				
			||||||
 | 
					            rootNodes.add(findTreeRecursive(category));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return rootNodes;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
 | 
				
			||||||
 | 
					        CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
 | 
				
			||||||
 | 
					        List<TransactionCategory> childCategories = DbUtil.findAll(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
 | 
				
			||||||
 | 
					                List.of(root.id),
 | 
				
			||||||
 | 
					                JdbcTransactionCategoryRepository::parseCategory
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        for (var childCategory : childCategories) {
 | 
				
			||||||
 | 
					            node.children().add(findTreeRecursive(childCategory));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return node;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void close() throws Exception {
 | 
				
			||||||
 | 
					        conn.close();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
 | 
				
			||||||
 | 
					        return new TransactionCategory(
 | 
				
			||||||
 | 
					                rs.getLong("id"),
 | 
				
			||||||
 | 
					                rs.getObject("parent_id", Long.class),
 | 
				
			||||||
 | 
					                rs.getString("name"),
 | 
				
			||||||
 | 
					                Color.valueOf("#" + rs.getString("color"))
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -2,20 +2,21 @@ package com.andrewlalis.perfin.data.impl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
 | 
					import com.andrewlalis.perfin.data.AccountEntryRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.AttachmentRepository;
 | 
					import com.andrewlalis.perfin.data.AttachmentRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.HistoryRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.TransactionRepository;
 | 
					import com.andrewlalis.perfin.data.TransactionRepository;
 | 
				
			||||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
					import com.andrewlalis.perfin.data.pagination.Page;
 | 
				
			||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
					import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
					import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
					import com.andrewlalis.perfin.data.util.DateUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.UncheckedSqlException;
 | 
				
			||||||
import com.andrewlalis.perfin.model.*;
 | 
					import com.andrewlalis.perfin.model.*;
 | 
				
			||||||
 | 
					import javafx.scene.paint.Color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import java.math.BigDecimal;
 | 
					import java.math.BigDecimal;
 | 
				
			||||||
import java.math.RoundingMode;
 | 
					import java.math.RoundingMode;
 | 
				
			||||||
import java.nio.file.Path;
 | 
					import java.nio.file.Path;
 | 
				
			||||||
import java.sql.Connection;
 | 
					import java.sql.*;
 | 
				
			||||||
import java.sql.ResultSet;
 | 
					 | 
				
			||||||
import java.sql.SQLException;
 | 
					 | 
				
			||||||
import java.time.LocalDateTime;
 | 
					import java.time.LocalDateTime;
 | 
				
			||||||
import java.util.*;
 | 
					import java.util.*;
 | 
				
			||||||
import java.util.stream.Collectors;
 | 
					import java.util.stream.Collectors;
 | 
				
			||||||
| 
						 | 
					@ -28,29 +29,104 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
				
			||||||
            Currency currency,
 | 
					            Currency currency,
 | 
				
			||||||
            String description,
 | 
					            String description,
 | 
				
			||||||
            CreditAndDebitAccounts linkedAccounts,
 | 
					            CreditAndDebitAccounts linkedAccounts,
 | 
				
			||||||
 | 
					            String vendor,
 | 
				
			||||||
 | 
					            String category,
 | 
				
			||||||
 | 
					            Set<String> tags,
 | 
				
			||||||
            List<Path> attachments
 | 
					            List<Path> attachments
 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
        return DbUtil.doTransaction(conn, () -> {
 | 
					        return DbUtil.doTransaction(conn, () -> {
 | 
				
			||||||
            // 1. Insert the transaction.
 | 
					            Long vendorId = null;
 | 
				
			||||||
            long txId = DbUtil.insertOne(
 | 
					            if (vendor != null && !vendor.isBlank()) {
 | 
				
			||||||
                    conn,
 | 
					                vendorId = getOrCreateVendorId(vendor.strip());
 | 
				
			||||||
                    "INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
 | 
					            }
 | 
				
			||||||
                    List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description)
 | 
					            Long categoryId = null;
 | 
				
			||||||
            );
 | 
					            if (category != null && !category.isBlank()) {
 | 
				
			||||||
            // 2. Insert linked account entries.
 | 
					                categoryId = getOrCreateCategoryId(category.strip());
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            // Insert the transaction, using a custom JDBC statement to deal with nullables.
 | 
				
			||||||
 | 
					            long txId;
 | 
				
			||||||
 | 
					            try (var stmt = conn.prepareStatement(
 | 
				
			||||||
 | 
					                    "INSERT INTO transaction (timestamp, amount, currency, description, vendor_id, category_id) VALUES (?, ?, ?, ?, ?, ?)",
 | 
				
			||||||
 | 
					                    Statement.RETURN_GENERATED_KEYS
 | 
				
			||||||
 | 
					            )) {
 | 
				
			||||||
 | 
					                stmt.setTimestamp(1, DbUtil.timestampFromUtcLDT(utcTimestamp));
 | 
				
			||||||
 | 
					                stmt.setBigDecimal(2, amount);
 | 
				
			||||||
 | 
					                stmt.setString(3, currency.getCurrencyCode());
 | 
				
			||||||
 | 
					                if (description != null && !description.isBlank()) {
 | 
				
			||||||
 | 
					                    stmt.setString(4, description.strip());
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    stmt.setNull(4, Types.VARCHAR);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                if (vendorId != null) {
 | 
				
			||||||
 | 
					                    stmt.setLong(5, vendorId);
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    stmt.setNull(5, Types.BIGINT);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                if (categoryId != null) {
 | 
				
			||||||
 | 
					                    stmt.setLong(6, categoryId);
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    stmt.setNull(6, Types.BIGINT);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                int result = stmt.executeUpdate();
 | 
				
			||||||
 | 
					                if (result != 1) throw new UncheckedSqlException("Transaction insert returned " + result);
 | 
				
			||||||
 | 
					                var rs = stmt.getGeneratedKeys();
 | 
				
			||||||
 | 
					                if (!rs.next()) throw new UncheckedSqlException("Transaction insert didn't generate any keys.");
 | 
				
			||||||
 | 
					                txId = rs.getLong(1);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            // Insert linked account entries.
 | 
				
			||||||
            AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
 | 
					            AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
 | 
				
			||||||
            linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
 | 
					            linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
 | 
				
			||||||
            linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
 | 
					            linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
 | 
				
			||||||
            // 3. Add attachments.
 | 
					            // Add attachments.
 | 
				
			||||||
            AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
 | 
					            AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
 | 
				
			||||||
            for (Path attachmentPath : attachments) {
 | 
					            for (Path attachmentPath : attachments) {
 | 
				
			||||||
                Attachment attachment = attachmentRepo.insert(attachmentPath);
 | 
					                Attachment attachment = attachmentRepo.insert(attachmentPath);
 | 
				
			||||||
                insertAttachmentLink(txId, attachment.id);
 | 
					                insertAttachmentLink(txId, attachment.id);
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					            // Add tags.
 | 
				
			||||||
 | 
					            for (String tag : tags) {
 | 
				
			||||||
 | 
					                try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)")) {
 | 
				
			||||||
 | 
					                    long tagId = getOrCreateTagId(tag.toLowerCase().strip());
 | 
				
			||||||
 | 
					                    stmt.setLong(1, txId);
 | 
				
			||||||
 | 
					                    stmt.setLong(2, tagId);
 | 
				
			||||||
 | 
					                    stmt.executeUpdate();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
            return txId;
 | 
					            return txId;
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private long getOrCreateVendorId(String name) {
 | 
				
			||||||
 | 
					        var repo = new JdbcTransactionVendorRepository(conn);
 | 
				
			||||||
 | 
					        TransactionVendor vendor = repo.findByName(name).orElse(null);
 | 
				
			||||||
 | 
					        if (vendor != null) {
 | 
				
			||||||
 | 
					            return vendor.id;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return repo.insert(name);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private long getOrCreateCategoryId(String name) {
 | 
				
			||||||
 | 
					        var repo = new JdbcTransactionCategoryRepository(conn);
 | 
				
			||||||
 | 
					        TransactionCategory category = repo.findByName(name).orElse(null);
 | 
				
			||||||
 | 
					        if (category != null) {
 | 
				
			||||||
 | 
					            return category.id;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return repo.insert(name, Color.WHITE);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private long getOrCreateTagId(String name) {
 | 
				
			||||||
 | 
					        Optional<Long> optionalId = DbUtil.findOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT id FROM transaction_tag WHERE name = ?",
 | 
				
			||||||
 | 
					                List.of(name),
 | 
				
			||||||
 | 
					                rs -> rs.getLong(1)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return optionalId.orElseGet(() ->
 | 
				
			||||||
 | 
					                DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name))
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @Override
 | 
					    @Override
 | 
				
			||||||
    public Optional<Transaction> findById(long id) {
 | 
					    public Optional<Transaction> findById(long id) {
 | 
				
			||||||
        return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
 | 
					        return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
 | 
				
			||||||
| 
						 | 
					@ -147,6 +223,51 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public List<String> findTags(long transactionId) {
 | 
				
			||||||
 | 
					        return DbUtil.findAll(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                """
 | 
				
			||||||
 | 
					                        SELECT tt.name
 | 
				
			||||||
 | 
					                        FROM transaction_tag tt
 | 
				
			||||||
 | 
					                        LEFT JOIN transaction_tag_join ttj ON ttj.tag_id = tt.id
 | 
				
			||||||
 | 
					                        WHERE ttj.transaction_id = ?
 | 
				
			||||||
 | 
					                        ORDER BY tt.name ASC""",
 | 
				
			||||||
 | 
					                List.of(transactionId),
 | 
				
			||||||
 | 
					                rs -> rs.getString(1)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public List<String> findAllTags() {
 | 
				
			||||||
 | 
					        return DbUtil.findAll(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT name FROM transaction_tag ORDER BY name ASC",
 | 
				
			||||||
 | 
					                rs -> rs.getString(1)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void deleteTag(String name) {
 | 
				
			||||||
 | 
					        DbUtil.update(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "DELETE FROM transaction_tag WHERE name = ?",
 | 
				
			||||||
 | 
					                name
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public long countTagUsages(String name) {
 | 
				
			||||||
 | 
					        return DbUtil.count(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                """
 | 
				
			||||||
 | 
					                    SELECT COUNT(transaction_id)
 | 
				
			||||||
 | 
					                    FROM transaction_tag_join
 | 
				
			||||||
 | 
					                    WHERE tag_id = (SELECT id FROM transaction_tag WHERE name = ?)""",
 | 
				
			||||||
 | 
					                name
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @Override
 | 
					    @Override
 | 
				
			||||||
    public void delete(long transactionId) {
 | 
					    public void delete(long transactionId) {
 | 
				
			||||||
        DbUtil.doTransaction(conn, () -> {
 | 
					        DbUtil.doTransaction(conn, () -> {
 | 
				
			||||||
| 
						 | 
					@ -164,44 +285,93 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
				
			||||||
            Currency currency,
 | 
					            Currency currency,
 | 
				
			||||||
            String description,
 | 
					            String description,
 | 
				
			||||||
            CreditAndDebitAccounts linkedAccounts,
 | 
					            CreditAndDebitAccounts linkedAccounts,
 | 
				
			||||||
 | 
					            String vendor,
 | 
				
			||||||
 | 
					            String category,
 | 
				
			||||||
 | 
					            Set<String> tags,
 | 
				
			||||||
            List<Attachment> existingAttachments,
 | 
					            List<Attachment> existingAttachments,
 | 
				
			||||||
            List<Path> newAttachmentPaths
 | 
					            List<Path> newAttachmentPaths
 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
        DbUtil.doTransaction(conn, () -> {
 | 
					        DbUtil.doTransaction(conn, () -> {
 | 
				
			||||||
            Transaction tx = findById(id).orElseThrow();
 | 
					 | 
				
			||||||
            CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
 | 
					 | 
				
			||||||
            List<Attachment> currentAttachments = findAttachments(id);
 | 
					 | 
				
			||||||
            var entryRepo = new JdbcAccountEntryRepository(conn);
 | 
					            var entryRepo = new JdbcAccountEntryRepository(conn);
 | 
				
			||||||
            var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
 | 
					            var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
 | 
				
			||||||
 | 
					            var vendorRepo = new JdbcTransactionVendorRepository(conn);
 | 
				
			||||||
 | 
					            var categoryRepo = new JdbcTransactionCategoryRepository(conn);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Transaction tx = findById(id).orElseThrow();
 | 
				
			||||||
 | 
					            CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
 | 
				
			||||||
 | 
					            TransactionVendor currentVendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElseThrow();
 | 
				
			||||||
 | 
					            String currentVendorName = currentVendor == null ? null : currentVendor.getName();
 | 
				
			||||||
 | 
					            TransactionCategory currentCategory = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElseThrow();
 | 
				
			||||||
 | 
					            String currentCategoryName = currentCategory == null ? null : currentCategory.getName();
 | 
				
			||||||
 | 
					            Set<String> currentTags = new HashSet<>(findTags(id));
 | 
				
			||||||
 | 
					            List<Attachment> currentAttachments = findAttachments(id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            List<String> updateMessages = new ArrayList<>();
 | 
					            List<String> updateMessages = new ArrayList<>();
 | 
				
			||||||
            if (!tx.getTimestamp().equals(utcTimestamp)) {
 | 
					            if (!tx.getTimestamp().equals(utcTimestamp)) {
 | 
				
			||||||
                DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), id));
 | 
					                DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", DbUtil.timestampFromUtcLDT(utcTimestamp), id);
 | 
				
			||||||
                updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
 | 
					                updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
 | 
					            BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
 | 
				
			||||||
            if (!tx.getAmount().equals(scaledAmount)) {
 | 
					            if (!tx.getAmount().equals(scaledAmount)) {
 | 
				
			||||||
                DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", List.of(scaledAmount, id));
 | 
					                DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", scaledAmount, id);
 | 
				
			||||||
                updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
 | 
					                updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            if (!tx.getCurrency().equals(currency)) {
 | 
					            if (!tx.getCurrency().equals(currency)) {
 | 
				
			||||||
                DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", List.of(currency.getCurrencyCode(), id));
 | 
					                DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", currency.getCurrencyCode(), id);
 | 
				
			||||||
                updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
 | 
					                updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            if (!Objects.equals(tx.getDescription(), description)) {
 | 
					            if (!Objects.equals(tx.getDescription(), description)) {
 | 
				
			||||||
                DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", List.of(description, id));
 | 
					                DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", description, id);
 | 
				
			||||||
                updateMessages.add("Updated description.");
 | 
					                updateMessages.add("Updated description.");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
 | 
					            boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
 | 
				
			||||||
                    !tx.getCurrency().equals(currency) ||
 | 
					                    !tx.getCurrency().equals(currency) ||
 | 
				
			||||||
                    !tx.getTimestamp().equals(utcTimestamp) ||
 | 
					                    !tx.getTimestamp().equals(utcTimestamp) ||
 | 
				
			||||||
                    !currentLinkedAccounts.equals(linkedAccounts);
 | 
					                    !currentLinkedAccounts.equals(linkedAccounts);
 | 
				
			||||||
            if (updateAccountEntries) {
 | 
					            if (shouldUpdateAccountEntries) {
 | 
				
			||||||
                // Delete all entries and re-write them correctly?
 | 
					                // Delete all entries and re-write them correctly.
 | 
				
			||||||
                DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id));
 | 
					                DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", id);
 | 
				
			||||||
                linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
 | 
					                linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
 | 
				
			||||||
                linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
 | 
					                linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
 | 
				
			||||||
                updateMessages.add("Updated linked accounts.");
 | 
					                updateMessages.add("Updated linked accounts.");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					            // Manage vendor change.
 | 
				
			||||||
 | 
					            if (!Objects.equals(vendor, currentVendorName)) {
 | 
				
			||||||
 | 
					                if (vendor == null || vendor.isBlank()) {
 | 
				
			||||||
 | 
					                    DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = NULL WHERE id = ?", id);
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    long newVendorId = getOrCreateVendorId(vendor);
 | 
				
			||||||
 | 
					                    DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = ? WHERE id = ?", newVendorId, id);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                updateMessages.add("Updated vendor name to \"" + vendor + "\".");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            // Manage category change.
 | 
				
			||||||
 | 
					            if (!Objects.equals(category, currentCategoryName)) {
 | 
				
			||||||
 | 
					                if (category == null || category.isBlank()) {
 | 
				
			||||||
 | 
					                    DbUtil.updateOne(conn, "UPDATE transaction SET category_id = NULL WHERE id = ?", id);
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    long newCategoryId = getOrCreateCategoryId(category);
 | 
				
			||||||
 | 
					                    DbUtil.updateOne(conn, "UPDATE transaction SET category_id = ? WHERE id = ?", newCategoryId, id);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                updateMessages.add("Updated category name to \"" + category + "\".");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            // Manage tags changes.
 | 
				
			||||||
 | 
					            if (!currentTags.equals(tags)) {
 | 
				
			||||||
 | 
					                Set<String> tagsAdded = new HashSet<>(tags);
 | 
				
			||||||
 | 
					                tagsAdded.removeAll(currentTags);
 | 
				
			||||||
 | 
					                Set<String> tagsRemoved = new HashSet<>(currentTags);
 | 
				
			||||||
 | 
					                tagsRemoved.removeAll(tags);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                for (var t : tagsRemoved) removeTag(id, t);
 | 
				
			||||||
 | 
					                for (var t : tagsAdded) addTag(id, t);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (!tagsAdded.isEmpty()) {
 | 
				
			||||||
 | 
					                    updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded));
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                if (!tagsRemoved.isEmpty()) {
 | 
				
			||||||
 | 
					                    updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved));
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
            // Manage attachments changes.
 | 
					            // Manage attachments changes.
 | 
				
			||||||
            List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
 | 
					            List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
 | 
				
			||||||
            removedAttachments.removeAll(existingAttachments);
 | 
					            removedAttachments.removeAll(existingAttachments);
 | 
				
			||||||
| 
						 | 
					@ -214,10 +384,12 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
				
			||||||
                insertAttachmentLink(tx.id, attachment.id);
 | 
					                insertAttachmentLink(tx.id, attachment.id);
 | 
				
			||||||
                updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
 | 
					                updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Add a text history item to any linked accounts detailing the changes.
 | 
				
			||||||
            String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
 | 
					            String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
 | 
				
			||||||
            var historyRepo = new JdbcAccountHistoryItemRepository(conn);
 | 
					            HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
 | 
				
			||||||
            linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
 | 
					            long historyId = historyRepo.getOrCreateHistoryForTransaction(id);
 | 
				
			||||||
            linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
 | 
					            historyRepo.addTextItem(historyId, updateMessageStr);
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -226,16 +398,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
				
			||||||
        conn.close();
 | 
					        conn.close();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static Transaction parseTransaction(ResultSet rs) throws SQLException {
 | 
					 | 
				
			||||||
        return new Transaction(
 | 
					 | 
				
			||||||
                rs.getLong("id"),
 | 
					 | 
				
			||||||
                DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
 | 
					 | 
				
			||||||
                rs.getBigDecimal("amount"),
 | 
					 | 
				
			||||||
                Currency.getInstance(rs.getString("currency")),
 | 
					 | 
				
			||||||
                rs.getString("description")
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private void insertAttachmentLink(long transactionId, long attachmentId) {
 | 
					    private void insertAttachmentLink(long transactionId, long attachmentId) {
 | 
				
			||||||
        DbUtil.insertOne(
 | 
					        DbUtil.insertOne(
 | 
				
			||||||
                conn,
 | 
					                conn,
 | 
				
			||||||
| 
						 | 
					@ -243,4 +405,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
 | 
				
			||||||
                List.of(transactionId, attachmentId)
 | 
					                List.of(transactionId, attachmentId)
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private long getTagId(String name) {
 | 
				
			||||||
 | 
					        return DbUtil.findOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT id FROM transaction_tag WHERE name = ?",
 | 
				
			||||||
 | 
					                List.of(name),
 | 
				
			||||||
 | 
					                rs -> rs.getLong(1)
 | 
				
			||||||
 | 
					        ).orElse(-1L);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void removeTag(long transactionId, String tag) {
 | 
				
			||||||
 | 
					        long id = getTagId(tag);
 | 
				
			||||||
 | 
					        if (id != -1) {
 | 
				
			||||||
 | 
					            DbUtil.update(conn, "DELETE FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", transactionId, id);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void addTag(long transactionId, String tag) {
 | 
				
			||||||
 | 
					        long id = getOrCreateTagId(tag);
 | 
				
			||||||
 | 
					        boolean exists = DbUtil.count(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT COUNT(tag_id) FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?",
 | 
				
			||||||
 | 
					                transactionId,
 | 
				
			||||||
 | 
					                id
 | 
				
			||||||
 | 
					        ) > 0;
 | 
				
			||||||
 | 
					        if (!exists) {
 | 
				
			||||||
 | 
					            DbUtil.insertOne(
 | 
				
			||||||
 | 
					                    conn,
 | 
				
			||||||
 | 
					                    "INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)",
 | 
				
			||||||
 | 
					                    transactionId,
 | 
				
			||||||
 | 
					                    id
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static Transaction parseTransaction(ResultSet rs) throws SQLException {
 | 
				
			||||||
 | 
					        return new Transaction(
 | 
				
			||||||
 | 
					                rs.getLong("id"),
 | 
				
			||||||
 | 
					                DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
 | 
				
			||||||
 | 
					                rs.getBigDecimal("amount"),
 | 
				
			||||||
 | 
					                Currency.getInstance(rs.getString("currency")),
 | 
				
			||||||
 | 
					                rs.getString("description"),
 | 
				
			||||||
 | 
					                rs.getObject("vendor_id", Long.class),
 | 
				
			||||||
 | 
					                rs.getObject("category_id", Long.class)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,102 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data.impl;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.TransactionVendor;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.sql.Connection;
 | 
				
			||||||
 | 
					import java.sql.ResultSet;
 | 
				
			||||||
 | 
					import java.sql.SQLException;
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					import java.util.Objects;
 | 
				
			||||||
 | 
					import java.util.Optional;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository {
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public Optional<TransactionVendor> findById(long id) {
 | 
				
			||||||
 | 
					        return DbUtil.findById(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM transaction_vendor WHERE id = ?",
 | 
				
			||||||
 | 
					                id,
 | 
				
			||||||
 | 
					                JdbcTransactionVendorRepository::parseVendor
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public Optional<TransactionVendor> findByName(String name) {
 | 
				
			||||||
 | 
					        return DbUtil.findOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM transaction_vendor WHERE name = ?",
 | 
				
			||||||
 | 
					                List.of(name),
 | 
				
			||||||
 | 
					                JdbcTransactionVendorRepository::parseVendor
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public List<TransactionVendor> findAll() {
 | 
				
			||||||
 | 
					        return DbUtil.findAll(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT * FROM transaction_vendor ORDER BY name ASC",
 | 
				
			||||||
 | 
					                JdbcTransactionVendorRepository::parseVendor
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public long insert(String name, String description) {
 | 
				
			||||||
 | 
					        return DbUtil.insertOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
 | 
				
			||||||
 | 
					                List.of(name, description)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public long insert(String name) {
 | 
				
			||||||
 | 
					        return DbUtil.insertOne(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "INSERT INTO transaction_vendor (name) VALUES (?)",
 | 
				
			||||||
 | 
					                List.of(name)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void update(long id, String name, String description) {
 | 
				
			||||||
 | 
					        DbUtil.doTransaction(conn, () -> {
 | 
				
			||||||
 | 
					            TransactionVendor vendor = findById(id).orElseThrow();
 | 
				
			||||||
 | 
					            if (!vendor.getName().equals(name)) {
 | 
				
			||||||
 | 
					                DbUtil.updateOne(
 | 
				
			||||||
 | 
					                        conn,
 | 
				
			||||||
 | 
					                        "UPDATE transaction_vendor SET name = ? WHERE id = ?",
 | 
				
			||||||
 | 
					                        name,
 | 
				
			||||||
 | 
					                        id
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            if (!Objects.equals(vendor.getDescription(), description)) {
 | 
				
			||||||
 | 
					                DbUtil.updateOne(
 | 
				
			||||||
 | 
					                        conn,
 | 
				
			||||||
 | 
					                        "UPDATE transaction_vendor SET description = ? WHERE id = ?",
 | 
				
			||||||
 | 
					                        description,
 | 
				
			||||||
 | 
					                        id
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void deleteById(long id) {
 | 
				
			||||||
 | 
					        DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public void close() throws Exception {
 | 
				
			||||||
 | 
					        conn.close();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static TransactionVendor parseVendor(ResultSet rs) throws SQLException {
 | 
				
			||||||
 | 
					        return new TransactionVendor(
 | 
				
			||||||
 | 
					                rs.getLong("id"),
 | 
				
			||||||
 | 
					                rs.getString("name"),
 | 
				
			||||||
 | 
					                rs.getString("description")
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -4,10 +4,20 @@ import java.util.HashMap;
 | 
				
			||||||
import java.util.List;
 | 
					import java.util.List;
 | 
				
			||||||
import java.util.Map;
 | 
					import java.util.Map;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Utility class for defining and using all known migrations.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
public class Migrations {
 | 
					public class Migrations {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Gets a list of migrations, as a map with the key being the version to
 | 
				
			||||||
 | 
					     * migrate from. For example, a migration that takes us from version 42 to
 | 
				
			||||||
 | 
					     * 43 would exist in the map with key 42.
 | 
				
			||||||
 | 
					     * @return The map of all migrations.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
    public static Map<Integer, Migration> getMigrations() {
 | 
					    public static Map<Integer, Migration> getMigrations() {
 | 
				
			||||||
        final Map<Integer, Migration> migrations = new HashMap<>();
 | 
					        final Map<Integer, Migration> migrations = new HashMap<>();
 | 
				
			||||||
        migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql"));
 | 
					        migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
 | 
				
			||||||
 | 
					        migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
 | 
				
			||||||
        return migrations;
 | 
					        return migrations;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -25,4 +35,14 @@ public class Migrations {
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        return selectedMigration;
 | 
					        return selectedMigration;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static Map<Integer, String> getSchemaVersionCompatibility() {
 | 
				
			||||||
 | 
					        final Map<Integer, String> compatibilities = new HashMap<>();
 | 
				
			||||||
 | 
					        compatibilities.put(1, "1.4.0");
 | 
				
			||||||
 | 
					        return compatibilities;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static String getLatestCompatibleVersion(int schemaVersion) {
 | 
				
			||||||
 | 
					        return getSchemaVersionCompatibility().get(schemaVersion);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,28 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data.search;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.pagination.Page;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * An entity searcher will search for entities matching a list of filters.
 | 
				
			||||||
 | 
					 * @param <T> The entity type to search over.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public interface EntitySearcher<T> {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Gets a page of results that match the given filters.
 | 
				
			||||||
 | 
					     * @param pageRequest The page request.
 | 
				
			||||||
 | 
					     * @param filters The filters to apply.
 | 
				
			||||||
 | 
					     * @return A page of results.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    Page<T> search(PageRequest pageRequest, List<SearchFilter> filters);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Gets the number of results that would be returned for a given set of
 | 
				
			||||||
 | 
					     * filters.
 | 
				
			||||||
 | 
					     * @param filters The filters to apply.
 | 
				
			||||||
 | 
					     * @return The number of entities that match.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    long resultCount(List<SearchFilter> filters);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,118 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data.search;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.pagination.Page;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.Pair;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.ResultSetMapper;
 | 
				
			||||||
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
 | 
					import org.slf4j.LoggerFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.sql.Connection;
 | 
				
			||||||
 | 
					import java.sql.PreparedStatement;
 | 
				
			||||||
 | 
					import java.sql.ResultSet;
 | 
				
			||||||
 | 
					import java.sql.SQLException;
 | 
				
			||||||
 | 
					import java.util.ArrayList;
 | 
				
			||||||
 | 
					import java.util.Collections;
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					import java.util.stream.Collectors;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class JdbcEntitySearcher<T> implements EntitySearcher<T> {
 | 
				
			||||||
 | 
					    private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private final Connection conn;
 | 
				
			||||||
 | 
					    private final String countExpression;
 | 
				
			||||||
 | 
					    private final String selectExpression;
 | 
				
			||||||
 | 
					    private final ResultSetMapper<T> resultSetMapper;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper<T> resultSetMapper) {
 | 
				
			||||||
 | 
					        this.conn = conn;
 | 
				
			||||||
 | 
					        this.countExpression = countExpression;
 | 
				
			||||||
 | 
					        this.selectExpression = selectExpression;
 | 
				
			||||||
 | 
					        this.resultSetMapper = resultSetMapper;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private Pair<String, List<Pair<Integer, Object>>> buildSearchQuery(List<SearchFilter> filters) {
 | 
				
			||||||
 | 
					        if (filters.isEmpty()) return new Pair<>("", Collections.emptyList());
 | 
				
			||||||
 | 
					        StringBuilder sb = new StringBuilder();
 | 
				
			||||||
 | 
					        List<Pair<Integer, Object>> args = new ArrayList<>();
 | 
				
			||||||
 | 
					        for (var filter : filters) {
 | 
				
			||||||
 | 
					            args.addAll(filter.args());
 | 
				
			||||||
 | 
					            for (var joinClause : filter.joinClauses()) {
 | 
				
			||||||
 | 
					                sb.append(joinClause).append('\n');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        sb.append("WHERE\n");
 | 
				
			||||||
 | 
					        for (int i = 0; i < filters.size(); i++) {
 | 
				
			||||||
 | 
					            sb.append(filters.get(i).whereClause());
 | 
				
			||||||
 | 
					            if (i < filters.size() - 1) {
 | 
				
			||||||
 | 
					                sb.append(" AND");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            sb.append('\n');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return new Pair<>(sb.toString(), args);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void applyArgs(PreparedStatement stmt, List<Pair<Integer, Object>> args) throws SQLException {
 | 
				
			||||||
 | 
					        for (int i = 1; i <= args.size(); i++) {
 | 
				
			||||||
 | 
					            Pair<Integer, Object> arg = args.get(i - 1);
 | 
				
			||||||
 | 
					            if (arg.second() == null) {
 | 
				
			||||||
 | 
					                stmt.setNull(i, arg.first());
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                stmt.setObject(i, arg.second(), arg.first());
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public Page<T> search(PageRequest pageRequest, List<SearchFilter> filters) {
 | 
				
			||||||
 | 
					        var baseQueryAndArgs = buildSearchQuery(filters);
 | 
				
			||||||
 | 
					        StringBuilder sqlBuilder = new StringBuilder(selectExpression);
 | 
				
			||||||
 | 
					        if (baseQueryAndArgs.first() != null && !baseQueryAndArgs.first().isBlank()) {
 | 
				
			||||||
 | 
					            sqlBuilder.append('\n').append(baseQueryAndArgs.first());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        String pagingSql = pageRequest.toSQL();
 | 
				
			||||||
 | 
					        if (pagingSql != null && !pagingSql.isBlank()) {
 | 
				
			||||||
 | 
					            sqlBuilder.append('\n').append(pagingSql);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        String sql = sqlBuilder.toString();
 | 
				
			||||||
 | 
					        logger.debug(
 | 
				
			||||||
 | 
					                "Searching with query:\n{}\nWith arguments: {}",
 | 
				
			||||||
 | 
					                sql,
 | 
				
			||||||
 | 
					                baseQueryAndArgs.second().stream()
 | 
				
			||||||
 | 
					                        .map(Pair::second)
 | 
				
			||||||
 | 
					                        .map(Object::toString)
 | 
				
			||||||
 | 
					                        .collect(Collectors.joining(", "))
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        try (var stmt = conn.prepareStatement(sql)) {
 | 
				
			||||||
 | 
					            applyArgs(stmt, baseQueryAndArgs.second());
 | 
				
			||||||
 | 
					            ResultSet rs = stmt.executeQuery();
 | 
				
			||||||
 | 
					            List<T> results = new ArrayList<>(pageRequest.size());
 | 
				
			||||||
 | 
					            while (rs.next() && results.size() < pageRequest.size()) {
 | 
				
			||||||
 | 
					                results.add(resultSetMapper.map(rs));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            return new Page<>(results, pageRequest);
 | 
				
			||||||
 | 
					        } catch (SQLException e) {
 | 
				
			||||||
 | 
					            logger.error("Search failed.", e);
 | 
				
			||||||
 | 
					            return new Page<>(Collections.emptyList(), pageRequest);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public long resultCount(List<SearchFilter> filters) {
 | 
				
			||||||
 | 
					        var baseQueryAndArgs = buildSearchQuery(filters);
 | 
				
			||||||
 | 
					        String sql = countExpression + "\n" + baseQueryAndArgs.first();
 | 
				
			||||||
 | 
					        try (var stmt = conn.prepareStatement(sql)) {
 | 
				
			||||||
 | 
					            applyArgs(stmt, baseQueryAndArgs.second());
 | 
				
			||||||
 | 
					            ResultSet rs = stmt.executeQuery();
 | 
				
			||||||
 | 
					            if (!rs.next()) throw new SQLException("No count result.");
 | 
				
			||||||
 | 
					            return rs.getLong(1);
 | 
				
			||||||
 | 
					        } catch (SQLException e) {
 | 
				
			||||||
 | 
					            logger.error("Failed to get search result count.", e);
 | 
				
			||||||
 | 
					            return 0L;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static class Builder {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,35 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data.search;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.Transaction;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.math.BigDecimal;
 | 
				
			||||||
 | 
					import java.sql.Connection;
 | 
				
			||||||
 | 
					import java.sql.ResultSet;
 | 
				
			||||||
 | 
					import java.sql.SQLException;
 | 
				
			||||||
 | 
					import java.time.LocalDateTime;
 | 
				
			||||||
 | 
					import java.util.Currency;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
 | 
				
			||||||
 | 
					    public JdbcTransactionSearcher(Connection conn) {
 | 
				
			||||||
 | 
					        super(
 | 
				
			||||||
 | 
					                conn,
 | 
				
			||||||
 | 
					                "SELECT COUNT(transaction.id) FROM transaction",
 | 
				
			||||||
 | 
					                "SELECT transaction.* FROM transaction",
 | 
				
			||||||
 | 
					                JdbcTransactionSearcher::parseResultSet
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static Transaction parseResultSet(ResultSet rs) throws SQLException {
 | 
				
			||||||
 | 
					        long id = rs.getLong(1);
 | 
				
			||||||
 | 
					        LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2));
 | 
				
			||||||
 | 
					        BigDecimal amount = rs.getBigDecimal(3);
 | 
				
			||||||
 | 
					        Currency currency = Currency.getInstance(rs.getString(4));
 | 
				
			||||||
 | 
					        String description = rs.getString(5);
 | 
				
			||||||
 | 
					        Long vendorId = rs.getLong(6);
 | 
				
			||||||
 | 
					        if (rs.wasNull()) vendorId = null;
 | 
				
			||||||
 | 
					        Long categoryId = rs.getLong(7);
 | 
				
			||||||
 | 
					        if (rs.wasNull()) categoryId = null;
 | 
				
			||||||
 | 
					        return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,61 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data.search;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.DbUtil;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.Pair;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.sql.Types;
 | 
				
			||||||
 | 
					import java.time.LocalDateTime;
 | 
				
			||||||
 | 
					import java.util.ArrayList;
 | 
				
			||||||
 | 
					import java.util.Collections;
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public interface SearchFilter {
 | 
				
			||||||
 | 
					    String whereClause();
 | 
				
			||||||
 | 
					    List<Pair<Integer, Object>> args();
 | 
				
			||||||
 | 
					    default List<String> joinClauses() {
 | 
				
			||||||
 | 
					        return Collections.emptyList();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    record Impl(String whereClause, List<Pair<Integer, Object>> args, List<String> joinClauses) implements SearchFilter {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Builder {
 | 
				
			||||||
 | 
					        private String whereClause;
 | 
				
			||||||
 | 
					        private List<Pair<Integer, Object>> args = new ArrayList<>();
 | 
				
			||||||
 | 
					        private List<String> joinClauses = new ArrayList<>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public Builder where(String clause) {
 | 
				
			||||||
 | 
					            this.whereClause = clause;
 | 
				
			||||||
 | 
					            return this;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public Builder withArg(int sqlType, Object value) {
 | 
				
			||||||
 | 
					            args.add(new Pair<>(sqlType, value));
 | 
				
			||||||
 | 
					            return this;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public Builder withArg(int value) {
 | 
				
			||||||
 | 
					            return withArg(Types.INTEGER, value);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public Builder withArg(long value) {
 | 
				
			||||||
 | 
					            return withArg(Types.BIGINT, value);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public Builder withArg(String value) {
 | 
				
			||||||
 | 
					            return withArg(Types.VARCHAR, value);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public Builder withArg(LocalDateTime utcTimestamp) {
 | 
				
			||||||
 | 
					            return withArg(Types.TIMESTAMP, DbUtil.timestampFromUtcLDT(utcTimestamp));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public Builder withJoin(String joinClause) {
 | 
				
			||||||
 | 
					            joinClauses.add(joinClause);
 | 
				
			||||||
 | 
					            return this;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public SearchFilter build() {
 | 
				
			||||||
 | 
					            return new Impl(whereClause, args, joinClauses);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,14 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.data.util;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import javafx.scene.paint.Color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class ColorUtil {
 | 
				
			||||||
 | 
					    public static String toHex(Color color) {
 | 
				
			||||||
 | 
					        return formatColorDouble(color.getRed()) + formatColorDouble(color.getGreen()) + formatColorDouble(color.getBlue());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static String formatColorDouble(double val) {
 | 
				
			||||||
 | 
					        String in = Integer.toHexString((int) Math.round(val * 255));
 | 
				
			||||||
 | 
					        return in.length() == 1 ? "0" + in : in;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -31,6 +31,15 @@ public final class DbUtil {
 | 
				
			||||||
        setArgs(stmt, List.of(args));
 | 
					        setArgs(stmt, List.of(args));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static long getGeneratedId(PreparedStatement stmt) {
 | 
				
			||||||
 | 
					        try (ResultSet rs = stmt.getGeneratedKeys()) {
 | 
				
			||||||
 | 
					            if (!rs.next()) throw new SQLException("No generated keys available.");
 | 
				
			||||||
 | 
					            return rs.getLong(1);
 | 
				
			||||||
 | 
					        } catch (SQLException e) {
 | 
				
			||||||
 | 
					            throw new UncheckedSqlException(e);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
 | 
					    public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
 | 
				
			||||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
					        try (var stmt = conn.prepareStatement(query)) {
 | 
				
			||||||
            setArgs(stmt, args);
 | 
					            setArgs(stmt, args);
 | 
				
			||||||
| 
						 | 
					@ -58,6 +67,17 @@ public final class DbUtil {
 | 
				
			||||||
        return findAll(conn, query, pagination, Collections.emptyList(), mapper);
 | 
					        return findAll(conn, query, pagination, Collections.emptyList(), mapper);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static long count(Connection conn, String query, Object... args) {
 | 
				
			||||||
 | 
					        try (var stmt = conn.prepareStatement(query)) {
 | 
				
			||||||
 | 
					            setArgs(stmt, args);
 | 
				
			||||||
 | 
					            var rs = stmt.executeQuery();
 | 
				
			||||||
 | 
					            if (!rs.next()) throw new UncheckedSqlException("No count result available.");
 | 
				
			||||||
 | 
					            return rs.getLong(1);
 | 
				
			||||||
 | 
					        } catch (SQLException e) {
 | 
				
			||||||
 | 
					            throw new UncheckedSqlException(e);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
 | 
					    public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
 | 
				
			||||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
					        try (var stmt = conn.prepareStatement(query)) {
 | 
				
			||||||
            setArgs(stmt, args);
 | 
					            setArgs(stmt, args);
 | 
				
			||||||
| 
						 | 
					@ -82,6 +102,10 @@ public final class DbUtil {
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static int update(Connection conn, String query, Object... args) {
 | 
				
			||||||
 | 
					        return update(conn, query, List.of(args));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static void updateOne(Connection conn, String query, List<Object> args) {
 | 
					    public static void updateOne(Connection conn, String query, List<Object> args) {
 | 
				
			||||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
					        try (var stmt = conn.prepareStatement(query)) {
 | 
				
			||||||
            setArgs(stmt, args);
 | 
					            setArgs(stmt, args);
 | 
				
			||||||
| 
						 | 
					@ -92,19 +116,25 @@ public final class DbUtil {
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void updateOne(Connection conn, String query, Object... args) {
 | 
				
			||||||
 | 
					        updateOne(conn, query, List.of(args));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static long insertOne(Connection conn, String query, List<Object> args) {
 | 
					    public static long insertOne(Connection conn, String query, List<Object> args) {
 | 
				
			||||||
        try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
 | 
					        try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
 | 
				
			||||||
            setArgs(stmt, args);
 | 
					            setArgs(stmt, args);
 | 
				
			||||||
            int result = stmt.executeUpdate();
 | 
					            int result = stmt.executeUpdate();
 | 
				
			||||||
            if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
 | 
					            if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
 | 
				
			||||||
            var rs = stmt.getGeneratedKeys();
 | 
					            return getGeneratedId(stmt);
 | 
				
			||||||
            rs.next();
 | 
					 | 
				
			||||||
            return rs.getLong(1);
 | 
					 | 
				
			||||||
        } catch (SQLException e) {
 | 
					        } catch (SQLException e) {
 | 
				
			||||||
            throw new UncheckedSqlException(e);
 | 
					            throw new UncheckedSqlException(e);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static long insertOne(Connection conn, String query, Object... args) {
 | 
				
			||||||
 | 
					        return insertOne(conn, query, List.of(args));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
 | 
					    public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
 | 
				
			||||||
        return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
 | 
					        return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -132,7 +162,9 @@ public final class DbUtil {
 | 
				
			||||||
    public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
 | 
					    public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
            conn.setAutoCommit(false);
 | 
					            conn.setAutoCommit(false);
 | 
				
			||||||
            return supplier.offer();
 | 
					            T result = supplier.offer();
 | 
				
			||||||
 | 
					            conn.commit();
 | 
				
			||||||
 | 
					            return result;
 | 
				
			||||||
        } catch (Exception e) {
 | 
					        } catch (Exception e) {
 | 
				
			||||||
            try {
 | 
					            try {
 | 
				
			||||||
                conn.rollback();
 | 
					                conn.rollback();
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
package com.andrewlalis.perfin.data.util;
 | 
					package com.andrewlalis.perfin.data.util;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
import javafx.stage.FileChooser;
 | 
					import javafx.stage.FileChooser;
 | 
				
			||||||
import org.slf4j.Logger;
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
import org.slf4j.LoggerFactory;
 | 
					import org.slf4j.LoggerFactory;
 | 
				
			||||||
| 
						 | 
					@ -103,4 +104,14 @@ public class FileUtil {
 | 
				
			||||||
            throw new RuntimeException(e);
 | 
					            throw new RuntimeException(e);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void copyResourceFile(String resource, Path dest) throws IOException {
 | 
				
			||||||
 | 
					        try (
 | 
				
			||||||
 | 
					                var in = Profile.class.getResourceAsStream(resource);
 | 
				
			||||||
 | 
					                var out = Files.newOutputStream(dest)
 | 
				
			||||||
 | 
					        ) {
 | 
				
			||||||
 | 
					            if (in == null) throw new IOException("Could not load resource " + resource);
 | 
				
			||||||
 | 
					            in.transferTo(out);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,19 +2,14 @@ package com.andrewlalis.perfin.model;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.PerfinApp;
 | 
					import com.andrewlalis.perfin.PerfinApp;
 | 
				
			||||||
import com.andrewlalis.perfin.data.DataSource;
 | 
					import com.andrewlalis.perfin.data.DataSource;
 | 
				
			||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
					 | 
				
			||||||
import org.slf4j.Logger;
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
import org.slf4j.LoggerFactory;
 | 
					import org.slf4j.LoggerFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import java.io.IOException;
 | 
					import java.lang.ref.WeakReference;
 | 
				
			||||||
import java.nio.file.Files;
 | 
					 | 
				
			||||||
import java.nio.file.Path;
 | 
					import java.nio.file.Path;
 | 
				
			||||||
import java.util.ArrayList;
 | 
					import java.util.HashSet;
 | 
				
			||||||
import java.util.Collections;
 | 
					 | 
				
			||||||
import java.util.List;
 | 
					 | 
				
			||||||
import java.util.Properties;
 | 
					import java.util.Properties;
 | 
				
			||||||
 | 
					import java.util.Set;
 | 
				
			||||||
import java.util.function.Consumer;
 | 
					import java.util.function.Consumer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
| 
						 | 
					@ -33,35 +28,22 @@ import java.util.function.Consumer;
 | 
				
			||||||
 *     class maintains a static <em>current</em> profile that can be loaded and
 | 
					 *     class maintains a static <em>current</em> profile that can be loaded and
 | 
				
			||||||
 *     unloaded.
 | 
					 *     unloaded.
 | 
				
			||||||
 * </p>
 | 
					 * </p>
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @param name The name of the profile.
 | 
				
			||||||
 | 
					 * @param settings The profile's settings.
 | 
				
			||||||
 | 
					 * @param dataSource The profile's data source.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public class Profile {
 | 
					public record Profile(String name, Properties settings, DataSource dataSource) {
 | 
				
			||||||
    private static final Logger log = LoggerFactory.getLogger(Profile.class);
 | 
					    private static final Logger log = LoggerFactory.getLogger(Profile.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static Profile current;
 | 
					    private static Profile current;
 | 
				
			||||||
    private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>();
 | 
					    private static final Set<WeakReference<Consumer<Profile>>> currentProfileListeners = new HashSet<>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private final String name;
 | 
					    @Override
 | 
				
			||||||
    private final Properties settings;
 | 
					    public String toString() {
 | 
				
			||||||
    private final DataSource dataSource;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private Profile(String name, Properties settings, DataSource dataSource) {
 | 
					 | 
				
			||||||
        this.name = name;
 | 
					 | 
				
			||||||
        this.settings = settings;
 | 
					 | 
				
			||||||
        this.dataSource = dataSource;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public String getName() {
 | 
					 | 
				
			||||||
        return name;
 | 
					        return name;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Properties getSettings() {
 | 
					 | 
				
			||||||
        return settings;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public DataSource getDataSource() {
 | 
					 | 
				
			||||||
        return dataSource;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public static Path getDir(String name) {
 | 
					    public static Path getDir(String name) {
 | 
				
			||||||
        return PerfinApp.APP_DIR.resolve(name);
 | 
					        return PerfinApp.APP_DIR.resolve(name);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -78,89 +60,23 @@ public class Profile {
 | 
				
			||||||
        return current;
 | 
					        return current;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void setCurrent(Profile profile) {
 | 
				
			||||||
 | 
					        current = profile;
 | 
				
			||||||
 | 
					        for (var ref : currentProfileListeners) {
 | 
				
			||||||
 | 
					            Consumer<Profile> consumer = ref.get();
 | 
				
			||||||
 | 
					            if (consumer != null) {
 | 
				
			||||||
 | 
					                consumer.accept(profile);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        currentProfileListeners.removeIf(ref -> ref.get() == null);
 | 
				
			||||||
 | 
					        log.debug("Current profile set to {}.", current.name());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static void whenLoaded(Consumer<Profile> consumer) {
 | 
					    public static void whenLoaded(Consumer<Profile> consumer) {
 | 
				
			||||||
        if (current != null) {
 | 
					        if (current != null) {
 | 
				
			||||||
            consumer.accept(current);
 | 
					            consumer.accept(current);
 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
            profileLoadListeners.add(consumer);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public static List<String> getAvailableProfiles() {
 | 
					 | 
				
			||||||
        try (var files = Files.list(PerfinApp.APP_DIR)) {
 | 
					 | 
				
			||||||
            return files.filter(Files::isDirectory)
 | 
					 | 
				
			||||||
                    .map(path -> path.getFileName().toString())
 | 
					 | 
				
			||||||
                    .sorted().toList();
 | 
					 | 
				
			||||||
        } catch (IOException e) {
 | 
					 | 
				
			||||||
            log.error("Failed to get a list of available profiles.", e);
 | 
					 | 
				
			||||||
            return Collections.emptyList();
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public static String getLastProfile() {
 | 
					 | 
				
			||||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
					 | 
				
			||||||
        if (Files.exists(lastProfileFile)) {
 | 
					 | 
				
			||||||
            try {
 | 
					 | 
				
			||||||
                String s = Files.readString(lastProfileFile).strip().toLowerCase();
 | 
					 | 
				
			||||||
                if (!s.isBlank()) return s;
 | 
					 | 
				
			||||||
            } catch (IOException e) {
 | 
					 | 
				
			||||||
                log.error("Failed to read " + lastProfileFile, e);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        return "default";
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public static void saveLastProfile(String name) {
 | 
					 | 
				
			||||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
            Files.writeString(lastProfileFile, name);
 | 
					 | 
				
			||||||
        } catch (IOException e) {
 | 
					 | 
				
			||||||
            log.error("Failed to write " + lastProfileFile, e);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public static void loadLast() throws ProfileLoadException {
 | 
					 | 
				
			||||||
        load(getLastProfile());
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public static void load(String name) throws ProfileLoadException {
 | 
					 | 
				
			||||||
        if (Files.notExists(getDir(name))) {
 | 
					 | 
				
			||||||
            try {
 | 
					 | 
				
			||||||
                initProfileDir(name);
 | 
					 | 
				
			||||||
            } catch (IOException e) {
 | 
					 | 
				
			||||||
                FileUtil.deleteIfPossible(getDir(name));
 | 
					 | 
				
			||||||
                throw new ProfileLoadException("Failed to initialize new profile directory.", e);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        Properties settings = new Properties();
 | 
					 | 
				
			||||||
        try (var in = Files.newInputStream(getSettingsFile(name))) {
 | 
					 | 
				
			||||||
            settings.load(in);
 | 
					 | 
				
			||||||
        } catch (IOException e) {
 | 
					 | 
				
			||||||
            throw new ProfileLoadException("Failed to load profile settings.", e);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        current = new Profile(name, settings, new JdbcDataSourceFactory().getDataSource(name));
 | 
					 | 
				
			||||||
        saveLastProfile(current.getName());
 | 
					 | 
				
			||||||
        for (var c : profileLoadListeners) {
 | 
					 | 
				
			||||||
            c.accept(current);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private static void initProfileDir(String name) throws IOException {
 | 
					 | 
				
			||||||
        Files.createDirectory(getDir(name));
 | 
					 | 
				
			||||||
        copyResourceFile("/text/profileDirReadme.txt", getDir(name).resolve("README.txt"));
 | 
					 | 
				
			||||||
        copyResourceFile("/text/defaultProfileSettings.properties", getSettingsFile(name));
 | 
					 | 
				
			||||||
        Files.createDirectory(getContentDir(name));
 | 
					 | 
				
			||||||
        copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt"));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private static void copyResourceFile(String resource, Path dest) throws IOException {
 | 
					 | 
				
			||||||
        try (
 | 
					 | 
				
			||||||
                var in = Profile.class.getResourceAsStream(resource);
 | 
					 | 
				
			||||||
                var out = Files.newOutputStream(dest)
 | 
					 | 
				
			||||||
        ) {
 | 
					 | 
				
			||||||
            if (in == null) throw new IOException("Could not load resource " + resource);
 | 
					 | 
				
			||||||
            in.transferTo(out);
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					        currentProfileListeners.add(new WeakReference<>(consumer));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static boolean validateName(String name) {
 | 
					    public static boolean validateName(String name) {
 | 
				
			||||||
| 
						 | 
					@ -168,9 +84,4 @@ public class Profile {
 | 
				
			||||||
                name.matches("\\w+") &&
 | 
					                name.matches("\\w+") &&
 | 
				
			||||||
                name.toLowerCase().equals(name);
 | 
					                name.toLowerCase().equals(name);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
    @Override
 | 
					 | 
				
			||||||
    public String toString() {
 | 
					 | 
				
			||||||
        return name;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,116 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.model;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.PerfinApp;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.control.Popups;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.DataSourceFactory;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.impl.migration.Migrations;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.util.FileUtil;
 | 
				
			||||||
 | 
					import javafx.stage.Window;
 | 
				
			||||||
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
 | 
					import org.slf4j.LoggerFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.io.IOException;
 | 
				
			||||||
 | 
					import java.nio.file.Files;
 | 
				
			||||||
 | 
					import java.nio.file.Path;
 | 
				
			||||||
 | 
					import java.util.Collections;
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					import java.util.Properties;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Component responsible for loading a profile from storage, as well as some
 | 
				
			||||||
 | 
					 * other basic tasks concerning the set of stored profiles.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public class ProfileLoader {
 | 
				
			||||||
 | 
					    private static final Logger log = LoggerFactory.getLogger(ProfileLoader.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private final Window window;
 | 
				
			||||||
 | 
					    private final DataSourceFactory dataSourceFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public ProfileLoader(Window window, DataSourceFactory dataSourceFactory) {
 | 
				
			||||||
 | 
					        this.window = window;
 | 
				
			||||||
 | 
					        this.dataSourceFactory = dataSourceFactory;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Profile load(String name) throws ProfileLoadException {
 | 
				
			||||||
 | 
					        if (Files.notExists(Profile.getDir(name))) {
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                initProfileDir(name);
 | 
				
			||||||
 | 
					            } catch (IOException e) {
 | 
				
			||||||
 | 
					                FileUtil.deleteIfPossible(Profile.getDir(name));
 | 
				
			||||||
 | 
					                throw new ProfileLoadException("Failed to initialize new profile directory.", e);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        Properties settings = new Properties();
 | 
				
			||||||
 | 
					        try (var in = Files.newInputStream(Profile.getSettingsFile(name))) {
 | 
				
			||||||
 | 
					            settings.load(in);
 | 
				
			||||||
 | 
					        } catch (IOException e) {
 | 
				
			||||||
 | 
					            throw new ProfileLoadException("Failed to load profile settings.", e);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            DataSourceFactory.SchemaStatus status = dataSourceFactory.getSchemaStatus(name);
 | 
				
			||||||
 | 
					            if (status == DataSourceFactory.SchemaStatus.NEEDS_MIGRATION) {
 | 
				
			||||||
 | 
					                boolean confirm = Popups.confirm(window, "The profile \"" + name + "\" has an outdated data schema and needs to be migrated to the latest version. Is this okay?");
 | 
				
			||||||
 | 
					                if (!confirm) {
 | 
				
			||||||
 | 
					                    int existingSchemaVersion = dataSourceFactory.getSchemaVersion(name);
 | 
				
			||||||
 | 
					                    String compatibleVersion = Migrations.getLatestCompatibleVersion(existingSchemaVersion);
 | 
				
			||||||
 | 
					                    Popups.message(
 | 
				
			||||||
 | 
					                            window,
 | 
				
			||||||
 | 
					                            "The profile \"" + name + "\" is using schema version " + existingSchemaVersion + ", which is compatible with Perfin version " + compatibleVersion + ". Consider downgrading Perfin to access this profile safely."
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                    throw new ProfileLoadException("User rejected the migration.");
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            } else if (status == DataSourceFactory.SchemaStatus.INCOMPATIBLE) {
 | 
				
			||||||
 | 
					                Popups.error(window, "The profile \"" + name + "\" has a data schema that's incompatible with this app. Update Perfin to access this profile safely.");
 | 
				
			||||||
 | 
					                throw new ProfileLoadException("Incompatible schema version.");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        } catch (IOException e) {
 | 
				
			||||||
 | 
					            throw new ProfileLoadException("Failed to get profile's schema status.", e);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return new Profile(name, settings, dataSourceFactory.getDataSource(name));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static List<String> getAvailableProfiles() {
 | 
				
			||||||
 | 
					        try (var files = Files.list(PerfinApp.APP_DIR)) {
 | 
				
			||||||
 | 
					            return files.filter(Files::isDirectory)
 | 
				
			||||||
 | 
					                    .map(path -> path.getFileName().toString())
 | 
				
			||||||
 | 
					                    .sorted().toList();
 | 
				
			||||||
 | 
					        } catch (IOException e) {
 | 
				
			||||||
 | 
					            log.error("Failed to get a list of available profiles.", e);
 | 
				
			||||||
 | 
					            return Collections.emptyList();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static String getLastProfile() {
 | 
				
			||||||
 | 
					        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
				
			||||||
 | 
					        if (Files.exists(lastProfileFile)) {
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                String s = Files.readString(lastProfileFile).strip().toLowerCase();
 | 
				
			||||||
 | 
					                if (!s.isBlank()) return s;
 | 
				
			||||||
 | 
					            } catch (IOException e) {
 | 
				
			||||||
 | 
					                log.error("Failed to read " + lastProfileFile, e);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return "default";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void saveLastProfile(String name) {
 | 
				
			||||||
 | 
					        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            Files.writeString(lastProfileFile, name);
 | 
				
			||||||
 | 
					        } catch (IOException e) {
 | 
				
			||||||
 | 
					            log.error("Failed to write " + lastProfileFile, e);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Deprecated
 | 
				
			||||||
 | 
					    private static void initProfileDir(String name) throws IOException {
 | 
				
			||||||
 | 
					        Files.createDirectory(Profile.getDir(name));
 | 
				
			||||||
 | 
					        copyResourceFile("/text/profileDirReadme.txt", Profile.getDir(name).resolve("README.txt"));
 | 
				
			||||||
 | 
					        copyResourceFile("/text/defaultProfileSettings.properties", Profile.getSettingsFile(name));
 | 
				
			||||||
 | 
					        Files.createDirectory(Profile.getContentDir(name));
 | 
				
			||||||
 | 
					        copyResourceFile("/text/contentDirReadme.txt", Profile.getContentDir(name).resolve("README.txt"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -17,13 +17,17 @@ public class Transaction extends IdEntity {
 | 
				
			||||||
    private final BigDecimal amount;
 | 
					    private final BigDecimal amount;
 | 
				
			||||||
    private final Currency currency;
 | 
					    private final Currency currency;
 | 
				
			||||||
    private final String description;
 | 
					    private final String description;
 | 
				
			||||||
 | 
					    private final Long vendorId;
 | 
				
			||||||
 | 
					    private final Long categoryId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
 | 
					    public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) {
 | 
				
			||||||
        super(id);
 | 
					        super(id);
 | 
				
			||||||
        this.timestamp = timestamp;
 | 
					        this.timestamp = timestamp;
 | 
				
			||||||
        this.amount = amount;
 | 
					        this.amount = amount;
 | 
				
			||||||
        this.currency = currency;
 | 
					        this.currency = currency;
 | 
				
			||||||
        this.description = description;
 | 
					        this.description = description;
 | 
				
			||||||
 | 
					        this.vendorId = vendorId;
 | 
				
			||||||
 | 
					        this.categoryId = categoryId;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public LocalDateTime getTimestamp() {
 | 
					    public LocalDateTime getTimestamp() {
 | 
				
			||||||
| 
						 | 
					@ -42,6 +46,14 @@ public class Transaction extends IdEntity {
 | 
				
			||||||
        return description;
 | 
					        return description;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Long getVendorId() {
 | 
				
			||||||
 | 
					        return vendorId;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Long getCategoryId() {
 | 
				
			||||||
 | 
					        return categoryId;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public MoneyValue getMoneyAmount() {
 | 
					    public MoneyValue getMoneyAmount() {
 | 
				
			||||||
        return new MoneyValue(amount, currency);
 | 
					        return new MoneyValue(amount, currency);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,35 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.model;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import javafx.scene.paint.Color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class TransactionCategory extends IdEntity {
 | 
				
			||||||
 | 
					    public static final int NAME_MAX_LENGTH = 63;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private final Long parentId;
 | 
				
			||||||
 | 
					    private final String name;
 | 
				
			||||||
 | 
					    private final Color color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public TransactionCategory(long id, Long parentId, String name, Color color) {
 | 
				
			||||||
 | 
					        super(id);
 | 
				
			||||||
 | 
					        this.parentId = parentId;
 | 
				
			||||||
 | 
					        this.name = name;
 | 
				
			||||||
 | 
					        this.color = color;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Long getParentId() {
 | 
				
			||||||
 | 
					        return parentId;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public String getName() {
 | 
				
			||||||
 | 
					        return name;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public Color getColor() {
 | 
				
			||||||
 | 
					        return color;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public String toString() {
 | 
				
			||||||
 | 
					        return name;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,65 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.model;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.math.BigDecimal;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * A line item that comprises part of a transaction. Its total value (value per
 | 
				
			||||||
 | 
					 * item * quantity) is part of the transaction's total value. It can be used to
 | 
				
			||||||
 | 
					 * record some transactions, like purchases and invoices, in more granular
 | 
				
			||||||
 | 
					 * detail.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public class TransactionLineItem extends IdEntity {
 | 
				
			||||||
 | 
					    public static final int DESCRIPTION_MAX_LENGTH = 255;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private final long transactionId;
 | 
				
			||||||
 | 
					    private final BigDecimal valuePerItem;
 | 
				
			||||||
 | 
					    private final int quantity;
 | 
				
			||||||
 | 
					    private final int idx;
 | 
				
			||||||
 | 
					    private final String description;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description) {
 | 
				
			||||||
 | 
					        super(id);
 | 
				
			||||||
 | 
					        this.transactionId = transactionId;
 | 
				
			||||||
 | 
					        this.valuePerItem = valuePerItem;
 | 
				
			||||||
 | 
					        this.quantity = quantity;
 | 
				
			||||||
 | 
					        this.idx = idx;
 | 
				
			||||||
 | 
					        this.description = description;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public long getTransactionId() {
 | 
				
			||||||
 | 
					        return transactionId;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public BigDecimal getValuePerItem() {
 | 
				
			||||||
 | 
					        return valuePerItem;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public int getQuantity() {
 | 
				
			||||||
 | 
					        return quantity;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public int getIdx() {
 | 
				
			||||||
 | 
					        return idx;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public String getDescription() {
 | 
				
			||||||
 | 
					        return description;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public BigDecimal getTotalValue() {
 | 
				
			||||||
 | 
					        return valuePerItem.multiply(new BigDecimal(quantity));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public String toString() {
 | 
				
			||||||
 | 
					        return String.format(
 | 
				
			||||||
 | 
					                "TransactionLineItem(id=%d, transactionId=%d, valuePerItem=%s, quantity=%d, idx=%d, description=\"%s\")",
 | 
				
			||||||
 | 
					                id,
 | 
				
			||||||
 | 
					                transactionId,
 | 
				
			||||||
 | 
					                valuePerItem.toPlainString(),
 | 
				
			||||||
 | 
					                quantity,
 | 
				
			||||||
 | 
					                idx,
 | 
				
			||||||
 | 
					                description
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,19 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.model;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * A tag that can be applied to a transaction to add some user-defined semantic
 | 
				
			||||||
 | 
					 * meaning to it.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public class TransactionTag extends IdEntity {
 | 
				
			||||||
 | 
					    public static final int NAME_MAX_LENGTH = 63;
 | 
				
			||||||
 | 
					    private final String name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public TransactionTag(long id, String name) {
 | 
				
			||||||
 | 
					        super(id);
 | 
				
			||||||
 | 
					        this.name = name;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public String getName() {
 | 
				
			||||||
 | 
					        return name;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,32 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.model;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * A vendor is a business establishment that can be linked to a transaction, to
 | 
				
			||||||
 | 
					 * denote the business that the transaction took place with.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public class TransactionVendor extends IdEntity {
 | 
				
			||||||
 | 
					    public static final int NAME_MAX_LENGTH = 255;
 | 
				
			||||||
 | 
					    public static final int DESCRIPTION_MAX_LENGTH = 255;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private final String name;
 | 
				
			||||||
 | 
					    private final String description;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public TransactionVendor(long id, String name, String description) {
 | 
				
			||||||
 | 
					        super(id);
 | 
				
			||||||
 | 
					        this.name = name;
 | 
				
			||||||
 | 
					        this.description = description;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public String getName() {
 | 
				
			||||||
 | 
					        return name;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public String getDescription() {
 | 
				
			||||||
 | 
					        return description;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Override
 | 
				
			||||||
 | 
					    public String toString() {
 | 
				
			||||||
 | 
					        return name;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,36 +0,0 @@
 | 
				
			||||||
package com.andrewlalis.perfin.model.history;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.IdEntity;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import java.time.LocalDateTime;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * The base class representing account history items, a read-only record of an
 | 
					 | 
				
			||||||
 * account's data and changes over time. The type of history item determines
 | 
					 | 
				
			||||||
 * what exactly it means, and could be something like an account entry, balance
 | 
					 | 
				
			||||||
 * record, or modifications to the account's properties.
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
public class AccountHistoryItem extends IdEntity {
 | 
					 | 
				
			||||||
    private final LocalDateTime timestamp;
 | 
					 | 
				
			||||||
    private final long accountId;
 | 
					 | 
				
			||||||
    private final AccountHistoryItemType type;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
 | 
					 | 
				
			||||||
        super(id);
 | 
					 | 
				
			||||||
        this.timestamp = timestamp;
 | 
					 | 
				
			||||||
        this.accountId = accountId;
 | 
					 | 
				
			||||||
        this.type = type;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public LocalDateTime getTimestamp() {
 | 
					 | 
				
			||||||
        return timestamp;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public long getAccountId() {
 | 
					 | 
				
			||||||
        return accountId;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public AccountHistoryItemType getType() {
 | 
					 | 
				
			||||||
        return type;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,7 +0,0 @@
 | 
				
			||||||
package com.andrewlalis.perfin.model.history;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public enum AccountHistoryItemType {
 | 
					 | 
				
			||||||
    TEXT,
 | 
					 | 
				
			||||||
    ACCOUNT_ENTRY,
 | 
					 | 
				
			||||||
    BALANCE_RECORD
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,36 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.model.history;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.IdEntity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.time.LocalDateTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Represents a single polymorphic history item. The item's "type" attribute
 | 
				
			||||||
 | 
					 * tells where to find additional type-specific data.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public abstract class HistoryItem extends IdEntity {
 | 
				
			||||||
 | 
					    public static final String TYPE_TEXT = "TEXT";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private final long historyId;
 | 
				
			||||||
 | 
					    private final LocalDateTime timestamp;
 | 
				
			||||||
 | 
					    private final String type;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public HistoryItem(long id, long historyId, LocalDateTime timestamp, String type) {
 | 
				
			||||||
 | 
					        super(id);
 | 
				
			||||||
 | 
					        this.historyId = historyId;
 | 
				
			||||||
 | 
					        this.timestamp = timestamp;
 | 
				
			||||||
 | 
					        this.type = type;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public long getHistoryId() {
 | 
				
			||||||
 | 
					        return historyId;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public LocalDateTime getTimestamp() {
 | 
				
			||||||
 | 
					        return timestamp;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public String getType() {
 | 
				
			||||||
 | 
					        return type;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,16 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.model.history;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.time.LocalDateTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class HistoryTextItem extends HistoryItem {
 | 
				
			||||||
 | 
					    private final String description;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) {
 | 
				
			||||||
 | 
					        super(id, historyId, timestamp, HistoryItem.TYPE_TEXT);
 | 
				
			||||||
 | 
					        this.description = description;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public String getDescription() {
 | 
				
			||||||
 | 
					        return description;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,8 +1,10 @@
 | 
				
			||||||
package com.andrewlalis.perfin.view;
 | 
					package com.andrewlalis.perfin.view;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import javafx.beans.WeakListener;
 | 
					import javafx.beans.WeakListener;
 | 
				
			||||||
 | 
					import javafx.beans.value.ObservableValue;
 | 
				
			||||||
import javafx.collections.ListChangeListener;
 | 
					import javafx.collections.ListChangeListener;
 | 
				
			||||||
import javafx.collections.ObservableList;
 | 
					import javafx.collections.ObservableList;
 | 
				
			||||||
 | 
					import javafx.scene.Node;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import java.lang.ref.WeakReference;
 | 
					import java.lang.ref.WeakReference;
 | 
				
			||||||
import java.util.List;
 | 
					import java.util.List;
 | 
				
			||||||
| 
						 | 
					@ -86,4 +88,9 @@ public class BindingUtil {
 | 
				
			||||||
            return false;
 | 
					            return false;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static void bindManagedAndVisible(Node node, ObservableValue<? extends Boolean> value) {
 | 
				
			||||||
 | 
					        node.managedProperty().bind(node.visibleProperty());
 | 
				
			||||||
 | 
					        node.visibleProperty().bind(value);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,6 +9,7 @@ import javafx.stage.Stage;
 | 
				
			||||||
import javafx.stage.StageStyle;
 | 
					import javafx.stage.StageStyle;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import java.util.List;
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletableFuture;
 | 
				
			||||||
import java.util.function.Consumer;
 | 
					import java.util.function.Consumer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
| 
						 | 
					@ -17,12 +18,14 @@ import java.util.function.Consumer;
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public class StartupSplashScreen extends Stage implements Consumer<String> {
 | 
					public class StartupSplashScreen extends Stage implements Consumer<String> {
 | 
				
			||||||
    private final List<ThrowableConsumer<Consumer<String>>> tasks;
 | 
					    private final List<ThrowableConsumer<Consumer<String>>> tasks;
 | 
				
			||||||
 | 
					    private final boolean delayTasks;
 | 
				
			||||||
    private boolean startupSuccessful = false;
 | 
					    private boolean startupSuccessful = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private final TextArea textArea = new TextArea();
 | 
					    private final TextArea textArea = new TextArea();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks) {
 | 
					    public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks, boolean delayTasks) {
 | 
				
			||||||
        this.tasks = tasks;
 | 
					        this.tasks = tasks;
 | 
				
			||||||
 | 
					        this.delayTasks = delayTasks;
 | 
				
			||||||
        setTitle("Starting Perfin...");
 | 
					        setTitle("Starting Perfin...");
 | 
				
			||||||
        setResizable(false);
 | 
					        setResizable(false);
 | 
				
			||||||
        initStyle(StageStyle.UNDECORATED);
 | 
					        initStyle(StageStyle.UNDECORATED);
 | 
				
			||||||
| 
						 | 
					@ -60,37 +63,50 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
 | 
				
			||||||
        return scene;
 | 
					        return scene;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Runs all tasks sequentially, invoking each one on the JavaFX main thread,
 | 
				
			||||||
 | 
					     * and quitting if there's any exception thrown.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
    private void runTasks() {
 | 
					    private void runTasks() {
 | 
				
			||||||
        Thread.ofVirtual().start(() -> {
 | 
					        Thread.ofVirtual().start(() -> {
 | 
				
			||||||
            try {
 | 
					            if (delayTasks) sleepOrThrowRE(1000);
 | 
				
			||||||
                Thread.sleep(1000);
 | 
					 | 
				
			||||||
            } catch (InterruptedException e) {
 | 
					 | 
				
			||||||
                throw new RuntimeException(e);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            for (var task : tasks) {
 | 
					            for (var task : tasks) {
 | 
				
			||||||
                try {
 | 
					                try {
 | 
				
			||||||
                    task.accept(this);
 | 
					                    CompletableFuture<Void> future = new CompletableFuture<>();
 | 
				
			||||||
                    Thread.sleep(500);
 | 
					                    Platform.runLater(() -> {
 | 
				
			||||||
 | 
					                        try {
 | 
				
			||||||
 | 
					                            task.accept(this);
 | 
				
			||||||
 | 
					                            future.complete(null);
 | 
				
			||||||
 | 
					                        } catch (Exception e) {
 | 
				
			||||||
 | 
					                            future.completeExceptionally(e);
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    });
 | 
				
			||||||
 | 
					                    future.join();
 | 
				
			||||||
 | 
					                    if (delayTasks) sleepOrThrowRE(500);
 | 
				
			||||||
                } catch (Exception e) {
 | 
					                } catch (Exception e) {
 | 
				
			||||||
                    accept("Startup failed: " + e.getMessage());
 | 
					                    accept("Startup failed: " + e.getMessage());
 | 
				
			||||||
                    e.printStackTrace(System.err);
 | 
					                    e.printStackTrace(System.err);
 | 
				
			||||||
                    try {
 | 
					                    sleepOrThrowRE(5000);
 | 
				
			||||||
                        Thread.sleep(5000);
 | 
					 | 
				
			||||||
                    } catch (InterruptedException ex) {
 | 
					 | 
				
			||||||
                        throw new RuntimeException(ex);
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                    Platform.runLater(this::close);
 | 
					                    Platform.runLater(this::close);
 | 
				
			||||||
                    return;
 | 
					                    return;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            accept("Startup successful!");
 | 
					            accept("Startup successful!");
 | 
				
			||||||
            try {
 | 
					            if (delayTasks) sleepOrThrowRE(1000);
 | 
				
			||||||
                Thread.sleep(1000);
 | 
					 | 
				
			||||||
            } catch (InterruptedException e) {
 | 
					 | 
				
			||||||
                throw new RuntimeException(e);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            startupSuccessful = true;
 | 
					            startupSuccessful = true;
 | 
				
			||||||
            Platform.runLater(this::close);
 | 
					            Platform.runLater(this::close);
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Helper method to sleep the current thread or throw a runtime exception.
 | 
				
			||||||
 | 
					     * @param ms The number of milliseconds to sleep for.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    private static void sleepOrThrowRE(long ms) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            Thread.sleep(ms);
 | 
				
			||||||
 | 
					        } catch (InterruptedException e) {
 | 
				
			||||||
 | 
					            throw new RuntimeException(e);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,36 +0,0 @@
 | 
				
			||||||
package com.andrewlalis.perfin.view.component;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.control.TransactionsViewController;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.AccountEntry;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
					 | 
				
			||||||
import javafx.scene.control.Hyperlink;
 | 
					 | 
				
			||||||
import javafx.scene.text.Text;
 | 
					 | 
				
			||||||
import javafx.scene.text.TextFlow;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public class AccountHistoryAccountEntryTile extends AccountHistoryItemTile {
 | 
					 | 
				
			||||||
    public AccountHistoryAccountEntryTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
 | 
					 | 
				
			||||||
        super(item);
 | 
					 | 
				
			||||||
        AccountEntry entry = repo.getAccountEntryItem(item.id);
 | 
					 | 
				
			||||||
        if (entry == null) {
 | 
					 | 
				
			||||||
            setCenter(new TextFlow(new Text("Deleted account entry because of deleted transaction.")));
 | 
					 | 
				
			||||||
            return;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue()));
 | 
					 | 
				
			||||||
        Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId());
 | 
					 | 
				
			||||||
        transactionLink.setOnAction(event -> router.navigate(
 | 
					 | 
				
			||||||
                "transactions",
 | 
					 | 
				
			||||||
                new TransactionsViewController.RouteContext(entry.getTransactionId())
 | 
					 | 
				
			||||||
        ));
 | 
					 | 
				
			||||||
        var text = new TextFlow(
 | 
					 | 
				
			||||||
                transactionLink,
 | 
					 | 
				
			||||||
                new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of "),
 | 
					 | 
				
			||||||
                amountText
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        setCenter(text);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,31 +0,0 @@
 | 
				
			||||||
package com.andrewlalis.perfin.view.component;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.control.AccountViewController;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.BalanceRecord;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
					 | 
				
			||||||
import javafx.scene.control.Hyperlink;
 | 
					 | 
				
			||||||
import javafx.scene.text.Text;
 | 
					 | 
				
			||||||
import javafx.scene.text.TextFlow;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile {
 | 
					 | 
				
			||||||
    public AccountHistoryBalanceRecordTile(AccountHistoryItem item, AccountHistoryItemRepository repo, AccountViewController controller) {
 | 
					 | 
				
			||||||
        super(item);
 | 
					 | 
				
			||||||
        BalanceRecord balanceRecord = repo.getBalanceRecordItem(item.id);
 | 
					 | 
				
			||||||
        if (balanceRecord == null) {
 | 
					 | 
				
			||||||
            setCenter(new TextFlow(new Text("Deleted balance record was added.")));
 | 
					 | 
				
			||||||
            return;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount()));
 | 
					 | 
				
			||||||
        var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText);
 | 
					 | 
				
			||||||
        setCenter(text);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Hyperlink viewLink = new Hyperlink("View this balance record");
 | 
					 | 
				
			||||||
        viewLink.setOnAction(event -> router.navigate("balance-record", balanceRecord));
 | 
					 | 
				
			||||||
        setBottom(viewLink);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,9 +1,8 @@
 | 
				
			||||||
package com.andrewlalis.perfin.view.component;
 | 
					package com.andrewlalis.perfin.view.component;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.control.AccountViewController;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
					 | 
				
			||||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
					import com.andrewlalis.perfin.data.util.DateUtil;
 | 
				
			||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
					import com.andrewlalis.perfin.model.history.HistoryItem;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.history.HistoryTextItem;
 | 
				
			||||||
import javafx.scene.control.Label;
 | 
					import javafx.scene.control.Label;
 | 
				
			||||||
import javafx.scene.layout.BorderPane;
 | 
					import javafx.scene.layout.BorderPane;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,7 +10,7 @@ import javafx.scene.layout.BorderPane;
 | 
				
			||||||
 * A tile that shows a brief bit of information about an account history item.
 | 
					 * A tile that shows a brief bit of information about an account history item.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public abstract class AccountHistoryItemTile extends BorderPane {
 | 
					public abstract class AccountHistoryItemTile extends BorderPane {
 | 
				
			||||||
    public AccountHistoryItemTile(AccountHistoryItem item) {
 | 
					    public AccountHistoryItemTile(HistoryItem item) {
 | 
				
			||||||
        getStyleClass().add("tile");
 | 
					        getStyleClass().add("tile");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp()));
 | 
					        Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp()));
 | 
				
			||||||
| 
						 | 
					@ -20,14 +19,11 @@ public abstract class AccountHistoryItemTile extends BorderPane {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static AccountHistoryItemTile forItem(
 | 
					    public static AccountHistoryItemTile forItem(
 | 
				
			||||||
            AccountHistoryItem item,
 | 
					            HistoryItem item
 | 
				
			||||||
            AccountHistoryItemRepository repo,
 | 
					 | 
				
			||||||
            AccountViewController controller
 | 
					 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
        return switch (item.getType()) {
 | 
					        if (item instanceof HistoryTextItem t) {
 | 
				
			||||||
            case TEXT -> new AccountHistoryTextTile(item, repo);
 | 
					            return new AccountHistoryTextTile(t);
 | 
				
			||||||
            case ACCOUNT_ENTRY -> new AccountHistoryAccountEntryTile(item, repo);
 | 
					        }
 | 
				
			||||||
            case BALANCE_RECORD -> new AccountHistoryBalanceRecordTile(item, repo, controller);
 | 
					        throw new RuntimeException("Unsupported history item type: " + item.getType());
 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,14 +1,12 @@
 | 
				
			||||||
package com.andrewlalis.perfin.view.component;
 | 
					package com.andrewlalis.perfin.view.component;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
					import com.andrewlalis.perfin.model.history.HistoryTextItem;
 | 
				
			||||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
					 | 
				
			||||||
import javafx.scene.text.Text;
 | 
					import javafx.scene.text.Text;
 | 
				
			||||||
import javafx.scene.text.TextFlow;
 | 
					import javafx.scene.text.TextFlow;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class AccountHistoryTextTile extends AccountHistoryItemTile {
 | 
					public class AccountHistoryTextTile extends AccountHistoryItemTile {
 | 
				
			||||||
    public AccountHistoryTextTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
 | 
					    public AccountHistoryTextTile(HistoryTextItem item) {
 | 
				
			||||||
        super(item);
 | 
					        super(item);
 | 
				
			||||||
        String text = repo.getTextItem(item.id);
 | 
					        setCenter(new TextFlow(new Text(item.getDescription())));
 | 
				
			||||||
        setCenter(new TextFlow(new Text(text)));
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox<Account> {
 | 
				
			||||||
        showBalanceProperty.set(value);
 | 
					        showBalanceProperty.set(value);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static class CellFactory implements Callback<ListView<Account>, ListCell<Account>> {
 | 
					    /**
 | 
				
			||||||
        private final BooleanProperty showBalanceProp;
 | 
					     * A simple cell factory that just returns instances of {@link AccountListCell}.
 | 
				
			||||||
 | 
					     * @param showBalanceProp Whether to show the account's balance.
 | 
				
			||||||
        private CellFactory(BooleanProperty showBalanceProp) {
 | 
					     */
 | 
				
			||||||
            this.showBalanceProp = showBalanceProp;
 | 
					    private record CellFactory(BooleanProperty showBalanceProp) implements Callback<ListView<Account>, ListCell<Account>> {
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        @Override
 | 
					        @Override
 | 
				
			||||||
        public ListCell<Account> call(ListView<Account> param) {
 | 
					        public ListCell<Account> call(ListView<Account> param) {
 | 
				
			||||||
            return new AccountListCell(showBalanceProp);
 | 
					            return new AccountListCell(showBalanceProp);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * A list cell implementation which shows an account's name, and optionally,
 | 
				
			||||||
 | 
					     * its current derived balance underneath.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
    private static class AccountListCell extends ListCell<Account> {
 | 
					    private static class AccountListCell extends ListCell<Account> {
 | 
				
			||||||
        private final BooleanProperty showBalanceProp;
 | 
					        private final BooleanProperty showBalanceProp;
 | 
				
			||||||
        private final Label nameLabel = new Label();
 | 
					        private final Label nameLabel = new Label();
 | 
				
			||||||
| 
						 | 
					@ -110,7 +112,7 @@ public class AccountSelectionBox extends ComboBox<Account> {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
 | 
					            nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
 | 
				
			||||||
            if (showBalanceProp.get()) {
 | 
					            if (showBalanceProp.get()) {
 | 
				
			||||||
                Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
					                Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
				
			||||||
                    BigDecimal balance = repo.deriveCurrentBalance(item.id);
 | 
					                    BigDecimal balance = repo.deriveCurrentBalance(item.id);
 | 
				
			||||||
                    Platform.runLater(() -> {
 | 
					                    Platform.runLater(() -> {
 | 
				
			||||||
                        balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));
 | 
					                        balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -81,7 +81,7 @@ public class AccountTile extends BorderPane {
 | 
				
			||||||
        Label balanceLabel = new Label("Computing balance...");
 | 
					        Label balanceLabel = new Label("Computing balance...");
 | 
				
			||||||
        balanceLabel.getStyleClass().addAll("mono-font");
 | 
					        balanceLabel.getStyleClass().addAll("mono-font");
 | 
				
			||||||
        balanceLabel.setDisable(true);
 | 
					        balanceLabel.setDisable(true);
 | 
				
			||||||
        Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
					        Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
				
			||||||
            BigDecimal balance = repo.deriveCurrentBalance(account.id);
 | 
					            BigDecimal balance = repo.deriveCurrentBalance(account.id);
 | 
				
			||||||
            String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()));
 | 
					            String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()));
 | 
				
			||||||
            Platform.runLater(() -> {
 | 
					            Platform.runLater(() -> {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -46,7 +46,7 @@ public class AttachmentPreview extends BorderPane {
 | 
				
			||||||
        boolean showDocIcon = true;
 | 
					        boolean showDocIcon = true;
 | 
				
			||||||
        Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp");
 | 
					        Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp");
 | 
				
			||||||
        if (imageTypes.contains(attachment.getContentType())) {
 | 
					        if (imageTypes.contains(attachment.getContentType())) {
 | 
				
			||||||
            try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName())))) {
 | 
					            try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().name())))) {
 | 
				
			||||||
                Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
 | 
					                Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
 | 
				
			||||||
                contentContainer.setCenter(new ImageView(img));
 | 
					                contentContainer.setCenter(new ImageView(img));
 | 
				
			||||||
                showDocIcon = false;
 | 
					                showDocIcon = false;
 | 
				
			||||||
| 
						 | 
					@ -69,7 +69,7 @@ public class AttachmentPreview extends BorderPane {
 | 
				
			||||||
        this.setCenter(stackPane);
 | 
					        this.setCenter(stackPane);
 | 
				
			||||||
        this.setOnMouseClicked(event -> {
 | 
					        this.setOnMouseClicked(event -> {
 | 
				
			||||||
            if (this.isHover()) {
 | 
					            if (this.isHover()) {
 | 
				
			||||||
                Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName()));
 | 
					                Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().name()));
 | 
				
			||||||
                PerfinApp.instance.getHostServices().showDocument(filePath.toAbsolutePath().toUri().toString());
 | 
					                PerfinApp.instance.getHostServices().showDocument(filePath.toAbsolutePath().toUri().toString());
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,81 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.view.component;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.TransactionCategory;
 | 
				
			||||||
 | 
					import javafx.geometry.Insets;
 | 
				
			||||||
 | 
					import javafx.scene.control.ComboBox;
 | 
				
			||||||
 | 
					import javafx.scene.control.Label;
 | 
				
			||||||
 | 
					import javafx.scene.control.ListCell;
 | 
				
			||||||
 | 
					import javafx.scene.layout.HBox;
 | 
				
			||||||
 | 
					import javafx.scene.shape.Circle;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.HashMap;
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					import java.util.Map;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class CategorySelectionBox extends ComboBox<TransactionCategory> {
 | 
				
			||||||
 | 
					    private final Map<TransactionCategory, Integer> categoryIndentationLevels = new HashMap<>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public CategorySelectionBox() {
 | 
				
			||||||
 | 
					        setCellFactory(view -> new CategoryListCell(categoryIndentationLevels));
 | 
				
			||||||
 | 
					        setButtonCell(new CategoryListCell(null));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public void loadCategories(List<TransactionCategoryRepository.CategoryTreeNode> treeNodes) {
 | 
				
			||||||
 | 
					        categoryIndentationLevels.clear();
 | 
				
			||||||
 | 
					        getItems().clear();
 | 
				
			||||||
 | 
					        populateCategories(treeNodes, 0);
 | 
				
			||||||
 | 
					        getItems().add(null);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private void populateCategories(
 | 
				
			||||||
 | 
					            List<TransactionCategoryRepository.CategoryTreeNode> treeNodes,
 | 
				
			||||||
 | 
					            int depth
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					        for (var node : treeNodes) {
 | 
				
			||||||
 | 
					            getItems().add(node.category());
 | 
				
			||||||
 | 
					            categoryIndentationLevels.put(node.category(), depth);
 | 
				
			||||||
 | 
					            populateCategories(node.children(), depth + 1);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public void select(TransactionCategory category) {
 | 
				
			||||||
 | 
					        setButtonCell(new CategoryListCell(null));
 | 
				
			||||||
 | 
					        getSelectionModel().select(category);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static class CategoryListCell extends ListCell<TransactionCategory> {
 | 
				
			||||||
 | 
					        private final Label nameLabel = new Label();
 | 
				
			||||||
 | 
					        private final Circle colorIndicator = new Circle(8);
 | 
				
			||||||
 | 
					        private final Map<TransactionCategory, Integer> categoryIndentationLevels;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public CategoryListCell(Map<TransactionCategory, Integer> categoryIndentationLevels) {
 | 
				
			||||||
 | 
					            this.categoryIndentationLevels = categoryIndentationLevels;
 | 
				
			||||||
 | 
					            nameLabel.getStyleClass().add("normal-color-text-fill");
 | 
				
			||||||
 | 
					            colorIndicator.managedProperty().bind(colorIndicator.visibleProperty());
 | 
				
			||||||
 | 
					            HBox container = new HBox(colorIndicator, nameLabel);
 | 
				
			||||||
 | 
					            container.getStyleClass().add("std-spacing");
 | 
				
			||||||
 | 
					            setGraphic(container);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        @Override
 | 
				
			||||||
 | 
					        protected void updateItem(TransactionCategory item, boolean empty) {
 | 
				
			||||||
 | 
					            super.updateItem(item, empty);
 | 
				
			||||||
 | 
					            if (item == null || empty) {
 | 
				
			||||||
 | 
					                nameLabel.setText("None");
 | 
				
			||||||
 | 
					                colorIndicator.setVisible(false);
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            nameLabel.setText(item.getName());
 | 
				
			||||||
 | 
					            if (categoryIndentationLevels != null) {
 | 
				
			||||||
 | 
					                HBox.setMargin(
 | 
				
			||||||
 | 
					                        colorIndicator,
 | 
				
			||||||
 | 
					                        new Insets(0, 0, 0, 10 * categoryIndentationLevels.getOrDefault(item, 0))
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            colorIndicator.setVisible(true);
 | 
				
			||||||
 | 
					            colorIndicator.setFill(item.getColor());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,65 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.view.component;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.control.EditCategoryController;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.control.Popups;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import javafx.scene.control.Button;
 | 
				
			||||||
 | 
					import javafx.scene.control.Label;
 | 
				
			||||||
 | 
					import javafx.scene.layout.BorderPane;
 | 
				
			||||||
 | 
					import javafx.scene.layout.HBox;
 | 
				
			||||||
 | 
					import javafx.scene.layout.VBox;
 | 
				
			||||||
 | 
					import javafx.scene.shape.Circle;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import static com.andrewlalis.perfin.PerfinApp.router;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class CategoryTile extends VBox {
 | 
				
			||||||
 | 
					    public CategoryTile(
 | 
				
			||||||
 | 
					            TransactionCategoryRepository.CategoryTreeNode treeNode,
 | 
				
			||||||
 | 
					            Runnable categoriesRefresh
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					        this.getStyleClass().addAll("tile", "spacing-extra", "hand-cursor");
 | 
				
			||||||
 | 
					        this.setStyle("-fx-border-width: 1px; -fx-border-color: grey;");
 | 
				
			||||||
 | 
					        this.setOnMouseClicked(event -> {
 | 
				
			||||||
 | 
					            event.consume();
 | 
				
			||||||
 | 
					            router.navigate(
 | 
				
			||||||
 | 
					                    "edit-category",
 | 
				
			||||||
 | 
					                    new EditCategoryController.CategoryRouteContext(treeNode.category())
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        BorderPane borderPane = new BorderPane();
 | 
				
			||||||
 | 
					        borderPane.getStyleClass().addAll("std-padding");
 | 
				
			||||||
 | 
					        Label nameLabel = new Label(treeNode.category().getName());
 | 
				
			||||||
 | 
					        nameLabel.getStyleClass().addAll("bold-text");
 | 
				
			||||||
 | 
					        Circle colorCircle = new Circle(10, treeNode.category().getColor());
 | 
				
			||||||
 | 
					        HBox contentBox = new HBox(colorCircle, nameLabel);
 | 
				
			||||||
 | 
					        contentBox.getStyleClass().addAll("std-spacing");
 | 
				
			||||||
 | 
					        borderPane.setLeft(contentBox);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Button addChildButton = new Button("Add Subcategory");
 | 
				
			||||||
 | 
					        addChildButton.setOnAction(event -> router.navigate(
 | 
				
			||||||
 | 
					                "edit-category",
 | 
				
			||||||
 | 
					                new EditCategoryController.AddSubcategoryRouteContext(treeNode.category())
 | 
				
			||||||
 | 
					        ));
 | 
				
			||||||
 | 
					        Button removeButton = new Button("Remove");
 | 
				
			||||||
 | 
					        removeButton.setOnAction(event -> {
 | 
				
			||||||
 | 
					            boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this category? It will permanently remove the category from all linked transactions, and all subcategories will also be removed. This cannot be undone.");
 | 
				
			||||||
 | 
					            if (confirm) {
 | 
				
			||||||
 | 
					                Profile.getCurrent().dataSource().useRepo(
 | 
				
			||||||
 | 
					                        TransactionCategoryRepository.class,
 | 
				
			||||||
 | 
					                        repo -> repo.deleteById(treeNode.category().id)
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					                categoriesRefresh.run();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        HBox buttonsBox = new HBox(addChildButton, removeButton);
 | 
				
			||||||
 | 
					        buttonsBox.getStyleClass().addAll("std-spacing");
 | 
				
			||||||
 | 
					        borderPane.setRight(buttonsBox);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.getChildren().add(borderPane);
 | 
				
			||||||
 | 
					        for (var child : treeNode.children()) {
 | 
				
			||||||
 | 
					            this.getChildren().add(new CategoryTile(child, categoriesRefresh));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -100,7 +100,7 @@ public class TransactionTile extends BorderPane {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private CompletableFuture<CreditAndDebitAccounts> getCreditAndDebitAccounts(Transaction transaction) {
 | 
					    private CompletableFuture<CreditAndDebitAccounts> getCreditAndDebitAccounts(Transaction transaction) {
 | 
				
			||||||
        return Profile.getCurrent().getDataSource().mapRepoAsync(
 | 
					        return Profile.getCurrent().dataSource().mapRepoAsync(
 | 
				
			||||||
                TransactionRepository.class,
 | 
					                TransactionRepository.class,
 | 
				
			||||||
                repo -> repo.findLinkedAccounts(transaction.id)
 | 
					                repo -> repo.findLinkedAccounts(transaction.id)
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,46 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.view.component;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.control.Popups;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.Profile;
 | 
				
			||||||
 | 
					import com.andrewlalis.perfin.model.TransactionVendor;
 | 
				
			||||||
 | 
					import javafx.geometry.Pos;
 | 
				
			||||||
 | 
					import javafx.scene.control.Button;
 | 
				
			||||||
 | 
					import javafx.scene.control.Label;
 | 
				
			||||||
 | 
					import javafx.scene.layout.BorderPane;
 | 
				
			||||||
 | 
					import javafx.scene.layout.VBox;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import static com.andrewlalis.perfin.PerfinApp.router;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class VendorTile extends BorderPane {
 | 
				
			||||||
 | 
					    public VendorTile(TransactionVendor vendor, Runnable vendorRefresh) {
 | 
				
			||||||
 | 
					        this.getStyleClass().addAll("tile", "std-spacing", "hand-cursor");
 | 
				
			||||||
 | 
					        this.setOnMouseClicked(event -> router.navigate("edit-vendor", vendor));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Label nameLabel = new Label(vendor.getName());
 | 
				
			||||||
 | 
					        nameLabel.getStyleClass().addAll("bold-text");
 | 
				
			||||||
 | 
					        Label descriptionLabel = new Label(vendor.getDescription());
 | 
				
			||||||
 | 
					        descriptionLabel.setWrapText(true);
 | 
				
			||||||
 | 
					        VBox contentVBox = new VBox(nameLabel, descriptionLabel);
 | 
				
			||||||
 | 
					        contentVBox.getStyleClass().addAll("std-spacing");
 | 
				
			||||||
 | 
					        this.setCenter(contentVBox);
 | 
				
			||||||
 | 
					        BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.setRight(getRemoveButton(vendor, vendorRefresh));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private Button getRemoveButton(TransactionVendor transactionVendor, Runnable vendorRefresh) {
 | 
				
			||||||
 | 
					        Button removeButton = new Button("Remove");
 | 
				
			||||||
 | 
					        removeButton.setOnAction(event -> {
 | 
				
			||||||
 | 
					            boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions assigned to this vendor will have their vendor field cleared. This cannot be undone.");
 | 
				
			||||||
 | 
					            if (confirm) {
 | 
				
			||||||
 | 
					                Profile.getCurrent().dataSource().useRepo(
 | 
				
			||||||
 | 
					                        TransactionVendorRepository.class,
 | 
				
			||||||
 | 
					                        repo -> repo.deleteById(transactionVendor.id)
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					                vendorRefresh.run();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        return removeButton;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,7 @@
 | 
				
			||||||
 | 
					package com.andrewlalis.perfin.view.component.validation;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletableFuture;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public interface AsyncValidationFunction<T> {
 | 
				
			||||||
 | 
					    CompletableFuture<ValidationResult> validate(T input);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,24 +1,40 @@
 | 
				
			||||||
package com.andrewlalis.perfin.view.component.validation;
 | 
					package com.andrewlalis.perfin.view.component.validation;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator;
 | 
					import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator;
 | 
				
			||||||
 | 
					import javafx.application.Platform;
 | 
				
			||||||
import javafx.beans.binding.BooleanExpression;
 | 
					import javafx.beans.binding.BooleanExpression;
 | 
				
			||||||
import javafx.beans.property.Property;
 | 
					import javafx.beans.property.Property;
 | 
				
			||||||
 | 
					import javafx.beans.property.SimpleBooleanProperty;
 | 
				
			||||||
import javafx.scene.Node;
 | 
					import javafx.scene.Node;
 | 
				
			||||||
import javafx.scene.control.TextField;
 | 
					import javafx.scene.control.TextField;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletableFuture;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Fluent interface for applying a validator to one or more controls.
 | 
					 * Fluent interface for applying a validator to one or more controls.
 | 
				
			||||||
 * @param <T> The value type.
 | 
					 * @param <T> The value type.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public class ValidationApplier<T> {
 | 
					public class ValidationApplier<T> {
 | 
				
			||||||
    private final ValidationFunction<T> validator;
 | 
					    private final AsyncValidationFunction<T> validator;
 | 
				
			||||||
    private ValidationDecorator decorator = new FieldSubtextDecorator();
 | 
					    private ValidationDecorator decorator = new FieldSubtextDecorator();
 | 
				
			||||||
    private boolean validateInitially = false;
 | 
					    private boolean validateInitially = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public ValidationApplier(ValidationFunction<T> validator) {
 | 
					    public ValidationApplier(ValidationFunction<T> validator) {
 | 
				
			||||||
 | 
					        this.validator = input -> CompletableFuture.completedFuture(validator.validate(input));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public ValidationApplier(AsyncValidationFunction<T> validator) {
 | 
				
			||||||
        this.validator = validator;
 | 
					        this.validator = validator;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static <T> ValidationApplier<T> of(ValidationFunction<T> validator) {
 | 
				
			||||||
 | 
					        return new ValidationApplier<>(validator);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public static <T> ValidationApplier<T> ofAsync(AsyncValidationFunction<T> validator) {
 | 
				
			||||||
 | 
					        return new ValidationApplier<>(validator);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public ValidationApplier<T> decoratedWith(ValidationDecorator decorator) {
 | 
					    public ValidationApplier<T> decoratedWith(ValidationDecorator decorator) {
 | 
				
			||||||
        this.decorator = decorator;
 | 
					        this.decorator = decorator;
 | 
				
			||||||
        return this;
 | 
					        return this;
 | 
				
			||||||
| 
						 | 
					@ -29,24 +45,47 @@ public class ValidationApplier<T> {
 | 
				
			||||||
        return this;
 | 
					        return this;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Attaches the configured validator and decorator to a node, so that when
 | 
				
			||||||
 | 
					     * the node's specified valueProperty changes, the validator will be called
 | 
				
			||||||
 | 
					     * and if the new value is invalid, the decorator will update the UI to
 | 
				
			||||||
 | 
					     * show the message(s) to the user.
 | 
				
			||||||
 | 
					     * @param node The node to attach to.
 | 
				
			||||||
 | 
					     * @param valueProperty The property to listen for changes and validate on.
 | 
				
			||||||
 | 
					     * @param triggerProperties Additional properties that, when changed, can
 | 
				
			||||||
 | 
					     *                          trigger validation.
 | 
				
			||||||
 | 
					     * @return A boolean expression that tells whether the given valueProperty
 | 
				
			||||||
 | 
					     * is valid at any given time.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
    public BooleanExpression attach(Node node, Property<T> valueProperty, Property<?>... triggerProperties) {
 | 
					    public BooleanExpression attach(Node node, Property<T> valueProperty, Property<?>... triggerProperties) {
 | 
				
			||||||
        BooleanExpression validProperty = BooleanExpression.booleanExpression(
 | 
					        final SimpleBooleanProperty validProperty = new SimpleBooleanProperty();
 | 
				
			||||||
                valueProperty.map(value -> validator.validate(value).isValid())
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        valueProperty.addListener((observable, oldValue, newValue) -> {
 | 
					        valueProperty.addListener((observable, oldValue, newValue) -> {
 | 
				
			||||||
            ValidationResult result = validator.validate(newValue);
 | 
					            validProperty.set(false); // Always set valid to false before we start validation.
 | 
				
			||||||
            decorator.decorate(node, result);
 | 
					            validator.validate(newValue)
 | 
				
			||||||
 | 
					                .thenAccept(result -> Platform.runLater(() -> {
 | 
				
			||||||
 | 
					                    validProperty.set(result.isValid());
 | 
				
			||||||
 | 
					                    decorator.decorate(node, result);
 | 
				
			||||||
 | 
					                }));
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
        for (Property<?> influencingProperty : triggerProperties) {
 | 
					        for (Property<?> influencingProperty : triggerProperties) {
 | 
				
			||||||
            influencingProperty.addListener((observable, oldValue, newValue) -> {
 | 
					            influencingProperty.addListener((observable, oldValue, newValue) -> {
 | 
				
			||||||
                ValidationResult result = validator.validate(valueProperty.getValue());
 | 
					                validProperty.set(false); // Always set valid to false before we start validation.
 | 
				
			||||||
                decorator.decorate(node, result);
 | 
					                validator.validate(valueProperty.getValue())
 | 
				
			||||||
 | 
					                    .thenAccept(result -> Platform.runLater(() -> {
 | 
				
			||||||
 | 
					                        validProperty.set(result.isValid());
 | 
				
			||||||
 | 
					                        decorator.decorate(node, result);
 | 
				
			||||||
 | 
					                    }));
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (validateInitially) {
 | 
					        if (validateInitially) {
 | 
				
			||||||
            // Call the decorator once to perform validation right away.
 | 
					            // Call the decorator once to perform validation right away.
 | 
				
			||||||
            decorator.decorate(node, validator.validate(valueProperty.getValue()));
 | 
					            validProperty.set(false); // Always set valid to false before we start validation.
 | 
				
			||||||
 | 
					            validator.validate(valueProperty.getValue())
 | 
				
			||||||
 | 
					                .thenAccept(result -> Platform.runLater(() -> {
 | 
				
			||||||
 | 
					                    validProperty.set(result.isValid());
 | 
				
			||||||
 | 
					                    decorator.decorate(node, result);
 | 
				
			||||||
 | 
					                }));
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        return validProperty;
 | 
					        return validProperty;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,7 @@ import com.andrewlalis.perfin.view.component.validation.ValidationDecorator;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
 | 
					import com.andrewlalis.perfin.view.component.validation.ValidationResult;
 | 
				
			||||||
import javafx.scene.Node;
 | 
					import javafx.scene.Node;
 | 
				
			||||||
import javafx.scene.control.Label;
 | 
					import javafx.scene.control.Label;
 | 
				
			||||||
 | 
					import javafx.scene.layout.HBox;
 | 
				
			||||||
import javafx.scene.layout.Pane;
 | 
					import javafx.scene.layout.Pane;
 | 
				
			||||||
import javafx.scene.layout.VBox;
 | 
					import javafx.scene.layout.VBox;
 | 
				
			||||||
import org.slf4j.Logger;
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
| 
						 | 
					@ -55,6 +56,9 @@ public class FieldSubtextDecorator implements ValidationDecorator {
 | 
				
			||||||
        errorLabel.getStyleClass().addAll("small-font", "negative-color-text-fill");
 | 
					        errorLabel.getStyleClass().addAll("small-font", "negative-color-text-fill");
 | 
				
			||||||
        errorLabel.setWrapText(true);
 | 
					        errorLabel.setWrapText(true);
 | 
				
			||||||
        VBox validationContainer = new VBox(node, errorLabel);
 | 
					        VBox validationContainer = new VBox(node, errorLabel);
 | 
				
			||||||
 | 
					        if (trueParent instanceof HBox) {
 | 
				
			||||||
 | 
					            HBox.setHgrow(validationContainer, HBox.getHgrow(node));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        validationContainer.setUserData(WRAP_KEY);
 | 
					        validationContainer.setUserData(WRAP_KEY);
 | 
				
			||||||
        trueParent.getChildren().add(idx, validationContainer);
 | 
					        trueParent.getChildren().add(idx, validationContainer);
 | 
				
			||||||
        return errorLabel;
 | 
					        return errorLabel;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,10 +1,14 @@
 | 
				
			||||||
package com.andrewlalis.perfin.view.component.validation.validators;
 | 
					package com.andrewlalis.perfin.view.component.validation.validators;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
 | 
					import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction;
 | 
				
			||||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
 | 
					import com.andrewlalis.perfin.view.component.validation.ValidationResult;
 | 
				
			||||||
 | 
					import org.slf4j.Logger;
 | 
				
			||||||
 | 
					import org.slf4j.LoggerFactory;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import java.util.ArrayList;
 | 
					import java.util.ArrayList;
 | 
				
			||||||
import java.util.List;
 | 
					import java.util.List;
 | 
				
			||||||
 | 
					import java.util.concurrent.CompletableFuture;
 | 
				
			||||||
 | 
					import java.util.concurrent.ExecutionException;
 | 
				
			||||||
import java.util.function.Function;
 | 
					import java.util.function.Function;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
| 
						 | 
					@ -12,32 +16,73 @@ import java.util.function.Function;
 | 
				
			||||||
 * determine if it's valid. If invalid, a message is added.
 | 
					 * determine if it's valid. If invalid, a message is added.
 | 
				
			||||||
 * @param <T> The value type.
 | 
					 * @param <T> The value type.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public class PredicateValidator<T> implements ValidationFunction<T> {
 | 
					public class PredicateValidator<T> implements AsyncValidationFunction<T> {
 | 
				
			||||||
    private record ValidationStep<T>(Function<T, Boolean> predicate, String message, boolean terminal) {}
 | 
					    private static final Logger logger = LoggerFactory.getLogger(PredicateValidator.class);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private record ValidationStep<T>(Function<T, CompletableFuture<Boolean>> predicate, String message, boolean terminal) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private final List<ValidationStep<T>> steps = new ArrayList<>();
 | 
					    private final List<ValidationStep<T>> steps = new ArrayList<>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
					    private PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage, boolean terminal) {
 | 
				
			||||||
        steps.add(new ValidationStep<>(predicate, errorMessage, false));
 | 
					        steps.add(new ValidationStep<>(
 | 
				
			||||||
 | 
					                v -> CompletableFuture.completedFuture(predicate.apply(v)),
 | 
				
			||||||
 | 
					                errorMessage,
 | 
				
			||||||
 | 
					                terminal
 | 
				
			||||||
 | 
					        ));
 | 
				
			||||||
        return this;
 | 
					        return this;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
					    private PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage, boolean terminal) {
 | 
				
			||||||
        steps.add(new ValidationStep<>(predicate, errorMessage, true));
 | 
					        steps.add(new ValidationStep<>(asyncPredicate, errorMessage, terminal));
 | 
				
			||||||
        return this;
 | 
					        return this;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
				
			||||||
 | 
					        return addPredicate(predicate, errorMessage, false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
 | 
				
			||||||
 | 
					        return addAsyncPredicate(asyncPredicate, errorMessage, false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Adds a terminal predicate, that is, if the given boolean function
 | 
				
			||||||
 | 
					     * evaluates to false, then no further predicates are evaluated.
 | 
				
			||||||
 | 
					     * @param predicate The predicate function.
 | 
				
			||||||
 | 
					     * @param errorMessage The error message to display if the predicate
 | 
				
			||||||
 | 
					     *                     evaluates to false for a given value.
 | 
				
			||||||
 | 
					     * @return A reference to the validator, for method chaining.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
				
			||||||
 | 
					        return addPredicate(predicate, errorMessage, true);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public PredicateValidator<T> addTerminalAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
 | 
				
			||||||
 | 
					        return addAsyncPredicate(asyncPredicate, errorMessage);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @Override
 | 
					    @Override
 | 
				
			||||||
    public ValidationResult validate(T input) {
 | 
					    public CompletableFuture<ValidationResult> validate(T input) {
 | 
				
			||||||
        List<String> messages = new ArrayList<>();
 | 
					        CompletableFuture<ValidationResult> cf = new CompletableFuture<>();
 | 
				
			||||||
        for (var step : steps) {
 | 
					        Thread.ofVirtual().start(() -> {
 | 
				
			||||||
            if (!step.predicate().apply(input)) {
 | 
					            List<String> messages = new ArrayList<>();
 | 
				
			||||||
                messages.add(step.message());
 | 
					            for (var step : steps) {
 | 
				
			||||||
                if (step.terminal()) {
 | 
					                try {
 | 
				
			||||||
                    return new ValidationResult(messages);
 | 
					                    boolean success = step.predicate().apply(input).get();
 | 
				
			||||||
 | 
					                    if (!success) {
 | 
				
			||||||
 | 
					                        messages.add(step.message());
 | 
				
			||||||
 | 
					                        if (step.terminal()) {
 | 
				
			||||||
 | 
					                            cf.complete(new ValidationResult(messages));
 | 
				
			||||||
 | 
					                            return; // Exit if this is a terminal step and it failed.
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                } catch (InterruptedException | ExecutionException e) {
 | 
				
			||||||
 | 
					                    logger.error("Applying a predicate to input failed.", e);
 | 
				
			||||||
 | 
					                    cf.completeExceptionally(e);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					            cf.complete(new ValidationResult(messages));
 | 
				
			||||||
        return new ValidationResult(messages);
 | 
					        });
 | 
				
			||||||
 | 
					        return cf;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -19,4 +19,5 @@ module com.andrewlalis.perfin {
 | 
				
			||||||
    opens com.andrewlalis.perfin.view to javafx.fxml;
 | 
					    opens com.andrewlalis.perfin.view to javafx.fxml;
 | 
				
			||||||
    opens com.andrewlalis.perfin.view.component to javafx.fxml;
 | 
					    opens com.andrewlalis.perfin.view.component to javafx.fxml;
 | 
				
			||||||
    opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
 | 
					    opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
 | 
				
			||||||
 | 
					    exports com.andrewlalis.perfin.model.history to javafx.graphics;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,34 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<?import javafx.scene.layout.BorderPane?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.Label?>
 | 
				
			||||||
 | 
					<?import javafx.scene.layout.VBox?>
 | 
				
			||||||
 | 
					<?import javafx.scene.layout.HBox?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.Button?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.ScrollPane?>
 | 
				
			||||||
 | 
					<?import com.andrewlalis.perfin.view.component.StyledText?>
 | 
				
			||||||
 | 
					<BorderPane xmlns="http://javafx.com/javafx"
 | 
				
			||||||
 | 
					            xmlns:fx="http://javafx.com/fxml"
 | 
				
			||||||
 | 
					            fx:controller="com.andrewlalis.perfin.control.CategoriesViewController"
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					    <top>
 | 
				
			||||||
 | 
					        <Label text="Transaction Categories" styleClass="large-font,bold-text,std-padding"/>
 | 
				
			||||||
 | 
					    </top>
 | 
				
			||||||
 | 
					    <center>
 | 
				
			||||||
 | 
					        <VBox>
 | 
				
			||||||
 | 
					            <StyledText maxWidth="500" styleClass="std-padding">
 | 
				
			||||||
 | 
					                Categories are used to group your transactions based on their
 | 
				
			||||||
 | 
					                purpose. It's helpful to categorize transactions in order to get
 | 
				
			||||||
 | 
					                a better view of your spending habits, and it makes it easier to
 | 
				
			||||||
 | 
					                lookup transactions later.
 | 
				
			||||||
 | 
					            </StyledText>
 | 
				
			||||||
 | 
					            <HBox styleClass="std-padding, std-spacing" VBox.vgrow="NEVER">
 | 
				
			||||||
 | 
					                <Button text="Add Category" onAction="#addCategory"/>
 | 
				
			||||||
 | 
					                <Button text="Add Default Categories" onAction="#addDefaultCategories"/>
 | 
				
			||||||
 | 
					            </HBox>
 | 
				
			||||||
 | 
					            <ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
 | 
				
			||||||
 | 
					                <VBox fx:id="categoriesVBox" styleClass="tile-container"/>
 | 
				
			||||||
 | 
					            </ScrollPane>
 | 
				
			||||||
 | 
					        </VBox>
 | 
				
			||||||
 | 
					    </center>
 | 
				
			||||||
 | 
					</BorderPane>
 | 
				
			||||||
| 
						 | 
					@ -55,7 +55,7 @@
 | 
				
			||||||
            </PropertiesPane>
 | 
					            </PropertiesPane>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <Separator/>
 | 
					            <Separator/>
 | 
				
			||||||
            <HBox styleClass="std-padding,std-spacing">
 | 
					            <HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
 | 
				
			||||||
                <Button text="Save" fx:id="saveButton" onAction="#save"/>
 | 
					                <Button text="Save" fx:id="saveButton" onAction="#save"/>
 | 
				
			||||||
                <Button text="Cancel" onAction="#cancel"/>
 | 
					                <Button text="Cancel" onAction="#cancel"/>
 | 
				
			||||||
            </HBox>
 | 
					            </HBox>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,34 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.*?>
 | 
				
			||||||
 | 
					<?import javafx.scene.layout.*?>
 | 
				
			||||||
 | 
					<BorderPane xmlns="http://javafx.com/javafx"
 | 
				
			||||||
 | 
					            xmlns:fx="http://javafx.com/fxml"
 | 
				
			||||||
 | 
					            fx:controller="com.andrewlalis.perfin.control.EditCategoryController"
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					    <top>
 | 
				
			||||||
 | 
					        <Label text="Edit Transaction Category" styleClass="bold-text,large-font,std-padding"/>
 | 
				
			||||||
 | 
					    </top>
 | 
				
			||||||
 | 
					    <center>
 | 
				
			||||||
 | 
					        <VBox>
 | 
				
			||||||
 | 
					            <PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
 | 
				
			||||||
 | 
					                <columnConstraints>
 | 
				
			||||||
 | 
					                    <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
 | 
				
			||||||
 | 
					                    <ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
 | 
				
			||||||
 | 
					                </columnConstraints>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <Label text="Name" labelFor="${nameField}"/>
 | 
				
			||||||
 | 
					                <TextField fx:id="nameField"/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <Label text="Color" labelFor="${colorPicker}"/>
 | 
				
			||||||
 | 
					                <ColorPicker fx:id="colorPicker"/>
 | 
				
			||||||
 | 
					            </PropertiesPane>
 | 
				
			||||||
 | 
					            <Separator/>
 | 
				
			||||||
 | 
					            <HBox styleClass="std-padding, std-spacing" alignment="CENTER_RIGHT">
 | 
				
			||||||
 | 
					                <Button text="Save" fx:id="saveButton" onAction="#save"/>
 | 
				
			||||||
 | 
					                <Button text="Cancel" onAction="#cancel"/>
 | 
				
			||||||
 | 
					            </HBox>
 | 
				
			||||||
 | 
					        </VBox>
 | 
				
			||||||
 | 
					    </center>
 | 
				
			||||||
 | 
					</BorderPane>
 | 
				
			||||||
| 
						 | 
					@ -5,6 +5,8 @@
 | 
				
			||||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
 | 
					<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
 | 
				
			||||||
<?import javafx.scene.control.*?>
 | 
					<?import javafx.scene.control.*?>
 | 
				
			||||||
<?import javafx.scene.layout.*?>
 | 
					<?import javafx.scene.layout.*?>
 | 
				
			||||||
 | 
					<?import com.andrewlalis.perfin.view.component.CategorySelectionBox?>
 | 
				
			||||||
 | 
					<?import com.andrewlalis.perfin.view.component.StyledText?>
 | 
				
			||||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
					<BorderPane xmlns="http://javafx.com/javafx"
 | 
				
			||||||
            xmlns:fx="http://javafx.com/fxml"
 | 
					            xmlns:fx="http://javafx.com/fxml"
 | 
				
			||||||
            fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
 | 
					            fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
 | 
				
			||||||
| 
						 | 
					@ -27,10 +29,10 @@
 | 
				
			||||||
                    <TextField fx:id="timestampField" styleClass="mono-font"/>
 | 
					                    <TextField fx:id="timestampField" styleClass="mono-font"/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    <Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/>
 | 
					                    <Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/>
 | 
				
			||||||
                    <TextField fx:id="amountField" styleClass="mono-font"/>
 | 
					                    <HBox styleClass="std-spacing">
 | 
				
			||||||
 | 
					                        <TextField fx:id="amountField" styleClass="mono-font" HBox.hgrow="ALWAYS"/>
 | 
				
			||||||
                    <Label text="Currency" labelFor="${currencyChoiceBox}" styleClass="bold-text"/>
 | 
					                        <ChoiceBox fx:id="currencyChoiceBox"/>
 | 
				
			||||||
                    <ChoiceBox fx:id="currencyChoiceBox"/>
 | 
					                    </HBox>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    <Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
 | 
					                    <Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
 | 
				
			||||||
                    <TextArea
 | 
					                    <TextArea
 | 
				
			||||||
| 
						 | 
					@ -43,15 +45,78 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <!-- Container for linked accounts -->
 | 
					                <!-- Container for linked accounts -->
 | 
				
			||||||
                <HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
 | 
					                <HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
 | 
				
			||||||
                    <VBox>
 | 
					                    <VBox HBox.hgrow="ALWAYS">
 | 
				
			||||||
                        <Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
 | 
					                        <Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
 | 
				
			||||||
                        <AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
 | 
					                        <AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
 | 
				
			||||||
                    </VBox>
 | 
					                    </VBox>
 | 
				
			||||||
                    <VBox>
 | 
					                    <VBox HBox.hgrow="ALWAYS">
 | 
				
			||||||
                        <Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
 | 
					                        <Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
 | 
				
			||||||
                        <AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
 | 
					                        <AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
 | 
				
			||||||
                    </VBox>
 | 
					                    </VBox>
 | 
				
			||||||
                </HBox>
 | 
					                </HBox>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <!-- Additional, mostly optional properties -->
 | 
				
			||||||
 | 
					                <PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
 | 
				
			||||||
 | 
					                    <columnConstraints>
 | 
				
			||||||
 | 
					                        <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
 | 
				
			||||||
 | 
					                        <ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
 | 
				
			||||||
 | 
					                    </columnConstraints>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    <VBox>
 | 
				
			||||||
 | 
					                        <Label text="Vendor" labelFor="${vendorComboBox}" styleClass="bold-text"/>
 | 
				
			||||||
 | 
					                        <Hyperlink fx:id="vendorsHyperlink" text="Manage vendors" styleClass="small-font"/>
 | 
				
			||||||
 | 
					                    </VBox>
 | 
				
			||||||
 | 
					                    <ComboBox fx:id="vendorComboBox" editable="true" maxWidth="Infinity"/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    <VBox>
 | 
				
			||||||
 | 
					                        <Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
 | 
				
			||||||
 | 
					                        <Hyperlink fx:id="categoriesHyperlink" text="Manage categories" styleClass="small-font"/>
 | 
				
			||||||
 | 
					                    </VBox>
 | 
				
			||||||
 | 
					                    <CategorySelectionBox fx:id="categoryComboBox" maxWidth="Infinity"/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    <VBox>
 | 
				
			||||||
 | 
					                        <Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
 | 
				
			||||||
 | 
					                        <Hyperlink fx:id="tagsHyperlink" text="Manage tags" styleClass="small-font"/>
 | 
				
			||||||
 | 
					                    </VBox>
 | 
				
			||||||
 | 
					                    <VBox maxWidth="Infinity">
 | 
				
			||||||
 | 
					                        <HBox styleClass="std-spacing">
 | 
				
			||||||
 | 
					                            <ComboBox fx:id="tagsComboBox" editable="true" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
 | 
				
			||||||
 | 
					                            <Button fx:id="addTagButton" text="Add" HBox.hgrow="NEVER"/>
 | 
				
			||||||
 | 
					                        </HBox>
 | 
				
			||||||
 | 
					                        <VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
 | 
				
			||||||
 | 
					                    </VBox>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    <Label text="Line Items" styleClass="bold-text"/>
 | 
				
			||||||
 | 
					                    <VBox maxWidth="Infinity">
 | 
				
			||||||
 | 
					                        <Button text="Add Line Item" fx:id="addLineItemButton" disable="true"/>
 | 
				
			||||||
 | 
					                        <StyledText styleClass="small-font">
 | 
				
			||||||
 | 
					                            Line items aren't yet supported. I'm working on it!
 | 
				
			||||||
 | 
					                        </StyledText>
 | 
				
			||||||
 | 
					                        <VBox styleClass="std-spacing" fx:id="addLineItemForm">
 | 
				
			||||||
 | 
					                            <HBox styleClass="std-spacing">
 | 
				
			||||||
 | 
					                                <VBox>
 | 
				
			||||||
 | 
					                                    <Label text="Quantity" styleClass="bold-text,small-font"/>
 | 
				
			||||||
 | 
					                                    <Spinner fx:id="lineItemQuantitySpinner" minWidth="60" maxWidth="60"/>
 | 
				
			||||||
 | 
					                                </VBox>
 | 
				
			||||||
 | 
					                                <VBox HBox.hgrow="ALWAYS">
 | 
				
			||||||
 | 
					                                    <Label text="Value per Item" styleClass="bold-text,small-font"/>
 | 
				
			||||||
 | 
					                                    <TextField fx:id="lineItemValueField"/>
 | 
				
			||||||
 | 
					                                </VBox>
 | 
				
			||||||
 | 
					                            </HBox>
 | 
				
			||||||
 | 
					                            <VBox>
 | 
				
			||||||
 | 
					                                <Label text="Description" styleClass="bold-text,small-font"/>
 | 
				
			||||||
 | 
					                                <TextField fx:id="lineItemDescriptionField"/>
 | 
				
			||||||
 | 
					                            </VBox>
 | 
				
			||||||
 | 
					                            <HBox styleClass="std-spacing" alignment="CENTER_RIGHT">
 | 
				
			||||||
 | 
					                                <Button text="Add" fx:id="addLineItemAddButton"/>
 | 
				
			||||||
 | 
					                                <Button text="Cancel" fx:id="addLineItemCancelButton"/>
 | 
				
			||||||
 | 
					                            </HBox>
 | 
				
			||||||
 | 
					                        </VBox>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        <VBox fx:id="lineItemsVBox"/>
 | 
				
			||||||
 | 
					                    </VBox>
 | 
				
			||||||
 | 
					                </PropertiesPane>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <!-- Container for attachments -->
 | 
					                <!-- Container for attachments -->
 | 
				
			||||||
                <VBox styleClass="std-padding">
 | 
					                <VBox styleClass="std-padding">
 | 
				
			||||||
                    <Label text="Attachments" styleClass="bold-text"/>
 | 
					                    <Label text="Attachments" styleClass="bold-text"/>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,34 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.*?>
 | 
				
			||||||
 | 
					<?import javafx.scene.layout.*?>
 | 
				
			||||||
 | 
					<BorderPane xmlns="http://javafx.com/javafx"
 | 
				
			||||||
 | 
					            xmlns:fx="http://javafx.com/fxml"
 | 
				
			||||||
 | 
					            fx:controller="com.andrewlalis.perfin.control.EditVendorController"
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					    <top>
 | 
				
			||||||
 | 
					        <Label text="Edit Vendor" styleClass="bold-text,large-font,std-padding"/>
 | 
				
			||||||
 | 
					    </top>
 | 
				
			||||||
 | 
					    <center>
 | 
				
			||||||
 | 
					        <VBox>
 | 
				
			||||||
 | 
					            <PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
 | 
				
			||||||
 | 
					                <columnConstraints>
 | 
				
			||||||
 | 
					                    <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
 | 
				
			||||||
 | 
					                    <ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
 | 
				
			||||||
 | 
					                </columnConstraints>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <Label text="Name" labelFor="${nameField}"/>
 | 
				
			||||||
 | 
					                <TextField fx:id="nameField"/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <Label text="Description" labelFor="${descriptionField}"/>
 | 
				
			||||||
 | 
					                <TextArea fx:id="descriptionField" wrapText="true"/>
 | 
				
			||||||
 | 
					            </PropertiesPane>
 | 
				
			||||||
 | 
					            <Separator/>
 | 
				
			||||||
 | 
					            <HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
 | 
				
			||||||
 | 
					                <Button text="Save" fx:id="saveButton" onAction="#save"/>
 | 
				
			||||||
 | 
					                <Button text="Cancel" onAction="#cancel"/>
 | 
				
			||||||
 | 
					            </HBox>
 | 
				
			||||||
 | 
					        </VBox>
 | 
				
			||||||
 | 
					    </center>
 | 
				
			||||||
 | 
					</BorderPane>
 | 
				
			||||||
| 
						 | 
					@ -28,7 +28,7 @@
 | 
				
			||||||
            <!-- App footer -->
 | 
					            <!-- App footer -->
 | 
				
			||||||
            <bottom>
 | 
					            <bottom>
 | 
				
			||||||
                <HBox styleClass="std-padding,std-spacing">
 | 
					                <HBox styleClass="std-padding,std-spacing">
 | 
				
			||||||
                    <Label text="Perfin Version 1.4.0"/>
 | 
					                    <Label text="Perfin Version 1.5.0"/>
 | 
				
			||||||
                    <AnchorPane>
 | 
					                    <AnchorPane>
 | 
				
			||||||
                        <Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
 | 
					                        <Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
 | 
				
			||||||
                    </AnchorPane>
 | 
					                    </AnchorPane>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,82 @@
 | 
				
			||||||
 | 
					[
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Food & Drink",
 | 
				
			||||||
 | 
					    "color": "#10C600",
 | 
				
			||||||
 | 
					    "children": [
 | 
				
			||||||
 | 
					      {"name": "Groceries"},
 | 
				
			||||||
 | 
					      {"name": "Restaurants"},
 | 
				
			||||||
 | 
					      {"name": "Alcohol & Bars"}
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Transportation",
 | 
				
			||||||
 | 
					    "color": "#4688FF",
 | 
				
			||||||
 | 
					    "children": [
 | 
				
			||||||
 | 
					      {"name": "Car & Fuel"},
 | 
				
			||||||
 | 
					      {"name": "Public Transport"},
 | 
				
			||||||
 | 
					      {"name": "Air Travel"},
 | 
				
			||||||
 | 
					      {"name": "Taxi & Rideshare"}
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Household",
 | 
				
			||||||
 | 
					    "color": "#E5DF00",
 | 
				
			||||||
 | 
					    "children": [
 | 
				
			||||||
 | 
					      {"name": "Rent & Mortgage"},
 | 
				
			||||||
 | 
					      {"name": "Utilities"},
 | 
				
			||||||
 | 
					      {"name": "Insurance & Fees"},
 | 
				
			||||||
 | 
					      {"name": "Home Improvements & Renovation"},
 | 
				
			||||||
 | 
					      {"name": "Household Supplies"},
 | 
				
			||||||
 | 
					      {"name": "Pets"},
 | 
				
			||||||
 | 
					      {"name": "Garden"}
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Shopping",
 | 
				
			||||||
 | 
					    "color": "#BF2484",
 | 
				
			||||||
 | 
					    "children": [
 | 
				
			||||||
 | 
					      {"name":  "Clothes & Accessories"},
 | 
				
			||||||
 | 
					      {"name": "Electronics"},
 | 
				
			||||||
 | 
					      {"name": "Hobbies & Crafts"},
 | 
				
			||||||
 | 
					      {"name": "Gifts"}
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Leisure",
 | 
				
			||||||
 | 
					    "color": "#C7271C",
 | 
				
			||||||
 | 
					    "children": [
 | 
				
			||||||
 | 
					      {"name": "Culture & Events"},
 | 
				
			||||||
 | 
					      {"name": "Movies & Media"},
 | 
				
			||||||
 | 
					      {"name": "Vacation"}
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Health & Wellness",
 | 
				
			||||||
 | 
					    "color": "#BE11D7",
 | 
				
			||||||
 | 
					    "children": [
 | 
				
			||||||
 | 
					      {"name": "Healthcare"},
 | 
				
			||||||
 | 
					      {"name": "Medication"},
 | 
				
			||||||
 | 
					      {"name": "Vision"},
 | 
				
			||||||
 | 
					      {"name": "Dental"},
 | 
				
			||||||
 | 
					      {"name": "Fitness & Sports"}
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Income",
 | 
				
			||||||
 | 
					    "color": "#83F25C",
 | 
				
			||||||
 | 
					    "children": [
 | 
				
			||||||
 | 
					      {"name": "Cash Deposits"},
 | 
				
			||||||
 | 
					      {"name": "Salary"},
 | 
				
			||||||
 | 
					      {"name": "Pension"},
 | 
				
			||||||
 | 
					      {"name": "Investments"}
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Transfers",
 | 
				
			||||||
 | 
					    "color": "#F7AE39"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Charity",
 | 
				
			||||||
 | 
					    "color": "#9AE5FF"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,73 @@
 | 
				
			||||||
 | 
					/*
 | 
				
			||||||
 | 
					Migration to add additional properties to transactions as per this GitHub issue:
 | 
				
			||||||
 | 
					https://github.com/andrewlalis/perfin/issues/10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Adds the following:
 | 
				
			||||||
 | 
					- An optional "vendor" field and associated vendor entity.
 | 
				
			||||||
 | 
					- An optional "category" field and associated category entity.
 | 
				
			||||||
 | 
					- An optional set of "tags" that are user-defined strings.
 | 
				
			||||||
 | 
					- An optional set of "line items" that comprise some subtotal of the transaction
 | 
				
			||||||
 | 
					  and can be used to specify that X amount of the total was spent on some
 | 
				
			||||||
 | 
					  specific item.
 | 
				
			||||||
 | 
					- An optional address of the purchase.
 | 
				
			||||||
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_vendor (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    name VARCHAR(255) NOT NULL UNIQUE,
 | 
				
			||||||
 | 
					    description VARCHAR(255)
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_category (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    parent_id BIGINT DEFAULT NULL,
 | 
				
			||||||
 | 
					    name VARCHAR(63) NOT NULL UNIQUE,
 | 
				
			||||||
 | 
					    color VARCHAR(6) NOT NULL DEFAULT 'FFFFFF',
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_category_parent
 | 
				
			||||||
 | 
					        FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_tag (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    name VARCHAR(63) NOT NULL UNIQUE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_tag_join (
 | 
				
			||||||
 | 
					    transaction_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    tag_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    PRIMARY KEY (transaction_id, tag_id),
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_tag_join_transaction
 | 
				
			||||||
 | 
					        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_tag_join_tag
 | 
				
			||||||
 | 
					        FOREIGN KEY (tag_id) REFERENCES transaction_tag(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_line_item (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    transaction_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    value_per_item NUMERIC(12, 4) NOT NULL,
 | 
				
			||||||
 | 
					    quantity INT NOT NULL DEFAULT 1,
 | 
				
			||||||
 | 
					    description VARCHAR(255) NOT NULL,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_line_item_transaction
 | 
				
			||||||
 | 
					        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
				
			||||||
 | 
					    CONSTRAINT ck_transaction_line_item_quantity_positive
 | 
				
			||||||
 | 
					        CHECK quantity > 0
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ALTER TABLE transaction
 | 
				
			||||||
 | 
					    ADD COLUMN vendor_id BIGINT DEFAULT NULL AFTER description;
 | 
				
			||||||
 | 
					ALTER TABLE transaction
 | 
				
			||||||
 | 
					    ADD COLUMN category_id BIGINT DEFAULT NULL AFTER vendor_id;
 | 
				
			||||||
 | 
					ALTER TABLE transaction
 | 
				
			||||||
 | 
					    ADD CONSTRAINT fk_transaction_vendor
 | 
				
			||||||
 | 
					        FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE SET NULL;
 | 
				
			||||||
 | 
					ALTER TABLE transaction
 | 
				
			||||||
 | 
					    ADD CONSTRAINT fk_transaction_category
 | 
				
			||||||
 | 
					        FOREIGN KEY (category_id) REFERENCES transaction_category(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE SET NULL;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,61 @@
 | 
				
			||||||
 | 
					/*
 | 
				
			||||||
 | 
					Migration to clean up history entities so that they are easier to work with, and
 | 
				
			||||||
 | 
					less prone to errors.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Removes existing account history items.
 | 
				
			||||||
 | 
					- Adds a generic history table and history items that are linked to a history.
 | 
				
			||||||
 | 
					- Adds history links to accounts and transactions.
 | 
				
			||||||
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE history (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE history_item (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    history_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    timestamp TIMESTAMP NOT NULL,
 | 
				
			||||||
 | 
					    type VARCHAR(63) NOT NULL,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_history_item_history
 | 
				
			||||||
 | 
					        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE history_item_text (
 | 
				
			||||||
 | 
					    id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    description VARCHAR(255) NOT NULL,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_history_item_text_pk
 | 
				
			||||||
 | 
					        FOREIGN KEY (id) REFERENCES history_item(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE history_account (
 | 
				
			||||||
 | 
					    account_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    history_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    PRIMARY KEY (account_id, history_id),
 | 
				
			||||||
 | 
					    CONSTRAINT fk_history_account_account
 | 
				
			||||||
 | 
					        FOREIGN KEY (account_id) REFERENCES account(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_history_account_history
 | 
				
			||||||
 | 
					        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE history_transaction (
 | 
				
			||||||
 | 
					    transaction_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    history_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    PRIMARY KEY (transaction_id, history_id),
 | 
				
			||||||
 | 
					    CONSTRAINT fk_history_transaction_transaction
 | 
				
			||||||
 | 
					        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_history_transaction_history
 | 
				
			||||||
 | 
					        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DROP TABLE IF EXISTS account_history_item_text;
 | 
				
			||||||
 | 
					DROP TABLE IF EXISTS account_history_item_account_entry;
 | 
				
			||||||
 | 
					DROP TABLE IF EXISTS account_history_item_balance_record;
 | 
				
			||||||
 | 
					DROP TABLE IF EXISTS account_history_item;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,14 +8,6 @@ CREATE TABLE account (
 | 
				
			||||||
    currency VARCHAR(3) NOT NULL
 | 
					    currency VARCHAR(3) NOT NULL
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE transaction (
 | 
					 | 
				
			||||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
					 | 
				
			||||||
    timestamp TIMESTAMP NOT NULL,
 | 
					 | 
				
			||||||
    amount NUMERIC(12, 4) NOT NULL,
 | 
					 | 
				
			||||||
    currency VARCHAR(3) NOT NULL,
 | 
					 | 
				
			||||||
    description VARCHAR(255) NULL
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CREATE TABLE attachment (
 | 
					CREATE TABLE attachment (
 | 
				
			||||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
    uploaded_at TIMESTAMP NOT NULL,
 | 
					    uploaded_at TIMESTAMP NOT NULL,
 | 
				
			||||||
| 
						 | 
					@ -24,6 +16,45 @@ CREATE TABLE attachment (
 | 
				
			||||||
    content_type VARCHAR(255) NOT NULL
 | 
					    content_type VARCHAR(255) NOT NULL
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* TRANSACTION ENTITIES */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_vendor (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    name VARCHAR(255) NOT NULL UNIQUE,
 | 
				
			||||||
 | 
					    description VARCHAR(255)
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_category (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    parent_id BIGINT DEFAULT NULL,
 | 
				
			||||||
 | 
					    name VARCHAR(63) NOT NULL UNIQUE,
 | 
				
			||||||
 | 
					    color VARCHAR(6) NOT NULL DEFAULT 'FFFFFF',
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_category_parent
 | 
				
			||||||
 | 
					        FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_tag (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    name VARCHAR(63) NOT NULL UNIQUE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    timestamp TIMESTAMP NOT NULL,
 | 
				
			||||||
 | 
					    amount NUMERIC(12, 4) NOT NULL,
 | 
				
			||||||
 | 
					    currency VARCHAR(3) NOT NULL,
 | 
				
			||||||
 | 
					    description VARCHAR(255) NULL,
 | 
				
			||||||
 | 
					    vendor_id BIGINT DEFAULT NULL,
 | 
				
			||||||
 | 
					    category_id BIGINT DEFAULT NULL,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_vendor
 | 
				
			||||||
 | 
					        FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE SET NULL,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_category
 | 
				
			||||||
 | 
					        FOREIGN KEY (category_id) REFERENCES transaction_category(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE SET NULL
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE account_entry (
 | 
					CREATE TABLE account_entry (
 | 
				
			||||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
    timestamp TIMESTAMP NOT NULL,
 | 
					    timestamp TIMESTAMP NOT NULL,
 | 
				
			||||||
| 
						 | 
					@ -52,6 +83,34 @@ CREATE TABLE transaction_attachment (
 | 
				
			||||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_tag_join (
 | 
				
			||||||
 | 
					    transaction_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    tag_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    PRIMARY KEY (transaction_id, tag_id),
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_tag_join_transaction
 | 
				
			||||||
 | 
					        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_tag_join_tag
 | 
				
			||||||
 | 
					        FOREIGN KEY (tag_id) REFERENCES transaction_tag(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE transaction_line_item (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    transaction_id BIGINT NOT NULL,
 | 
				
			||||||
 | 
					    value_per_item NUMERIC(12, 4) NOT NULL,
 | 
				
			||||||
 | 
					    quantity INT NOT NULL DEFAULT 1,
 | 
				
			||||||
 | 
					    idx INT NOT NULL DEFAULT 0,
 | 
				
			||||||
 | 
					    description VARCHAR(255) NOT NULL,
 | 
				
			||||||
 | 
					    CONSTRAINT fk_transaction_line_item_transaction
 | 
				
			||||||
 | 
					        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
				
			||||||
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
				
			||||||
 | 
					    CONSTRAINT ck_transaction_line_item_quantity_positive
 | 
				
			||||||
 | 
					        CHECK quantity > 0
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* BALANCE RECORD ENTITIES */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE balance_record (
 | 
					CREATE TABLE balance_record (
 | 
				
			||||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
    timestamp TIMESTAMP NOT NULL,
 | 
					    timestamp TIMESTAMP NOT NULL,
 | 
				
			||||||
| 
						 | 
					@ -75,42 +134,49 @@ CREATE TABLE balance_record_attachment (
 | 
				
			||||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE account_history_item (
 | 
					/* HISTORY */
 | 
				
			||||||
 | 
					CREATE TABLE history (
 | 
				
			||||||
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CREATE TABLE history_item (
 | 
				
			||||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
					    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
				
			||||||
 | 
					    history_id BIGINT NOT NULL,
 | 
				
			||||||
    timestamp TIMESTAMP NOT NULL,
 | 
					    timestamp TIMESTAMP NOT NULL,
 | 
				
			||||||
    account_id BIGINT NOT NULL,
 | 
					 | 
				
			||||||
    type VARCHAR(63) NOT NULL,
 | 
					    type VARCHAR(63) NOT NULL,
 | 
				
			||||||
    CONSTRAINT fk_account_history_item_account
 | 
					    CONSTRAINT fk_history_item_history
 | 
				
			||||||
        FOREIGN KEY (account_id) REFERENCES account(id)
 | 
					        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
				
			||||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE account_history_item_text (
 | 
					CREATE TABLE history_item_text (
 | 
				
			||||||
    item_id BIGINT NOT NULL PRIMARY KEY,
 | 
					    id BIGINT PRIMARY KEY,
 | 
				
			||||||
    description VARCHAR(255) NOT NULL,
 | 
					    description VARCHAR(255) NOT NULL,
 | 
				
			||||||
    CONSTRAINT fk_account_history_item_text_pk
 | 
					    CONSTRAINT fk_history_item_text_pk
 | 
				
			||||||
        FOREIGN KEY (item_id) REFERENCES account_history_item(id)
 | 
					        FOREIGN KEY (id) REFERENCES history_item(id)
 | 
				
			||||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE account_history_item_account_entry (
 | 
					CREATE TABLE history_account (
 | 
				
			||||||
    item_id BIGINT NOT NULL PRIMARY KEY,
 | 
					    account_id BIGINT NOT NULL,
 | 
				
			||||||
    entry_id BIGINT NOT NULL,
 | 
					    history_id BIGINT NOT NULL,
 | 
				
			||||||
    CONSTRAINT fk_account_history_item_account_entry_pk
 | 
					    PRIMARY KEY (account_id, history_id),
 | 
				
			||||||
        FOREIGN KEY (item_id) REFERENCES account_history_item(id)
 | 
					    CONSTRAINT fk_history_account_account
 | 
				
			||||||
 | 
					        FOREIGN KEY (account_id) REFERENCES account(id)
 | 
				
			||||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
				
			||||||
    CONSTRAINT fk_account_history_item_account_entry
 | 
					    CONSTRAINT fk_history_account_history
 | 
				
			||||||
        FOREIGN KEY (entry_id) REFERENCES account_entry(id)
 | 
					        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
				
			||||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
CREATE TABLE account_history_item_balance_record (
 | 
					CREATE TABLE history_transaction (
 | 
				
			||||||
    item_id BIGINT NOT NULL PRIMARY KEY,
 | 
					    transaction_id BIGINT NOT NULL,
 | 
				
			||||||
    record_id BIGINT NOT NULL,
 | 
					    history_id BIGINT NOT NULL,
 | 
				
			||||||
    CONSTRAINT fk_account_history_item_balance_record_pk
 | 
					    PRIMARY KEY (transaction_id, history_id),
 | 
				
			||||||
        FOREIGN KEY (item_id) REFERENCES account_history_item(id)
 | 
					    CONSTRAINT fk_history_transaction_transaction
 | 
				
			||||||
 | 
					        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
				
			||||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
				
			||||||
    CONSTRAINT fk_account_history_item_balance_record
 | 
					    CONSTRAINT fk_history_transaction_history
 | 
				
			||||||
        FOREIGN KEY (record_id) REFERENCES balance_record(id)
 | 
					        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
				
			||||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
					            ON UPDATE CASCADE ON DELETE CASCADE
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,32 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<?import com.andrewlalis.perfin.view.component.StyledText?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.Label?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.ScrollPane?>
 | 
				
			||||||
 | 
					<?import javafx.scene.layout.BorderPane?>
 | 
				
			||||||
 | 
					<?import javafx.scene.layout.VBox?>
 | 
				
			||||||
 | 
					<BorderPane xmlns="http://javafx.com/javafx"
 | 
				
			||||||
 | 
					            xmlns:fx="http://javafx.com/fxml"
 | 
				
			||||||
 | 
					            fx:controller="com.andrewlalis.perfin.control.TagsViewController"
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					    <top>
 | 
				
			||||||
 | 
					        <Label text="Transaction Tags" styleClass="large-font,bold-text,std-padding"/>
 | 
				
			||||||
 | 
					    </top>
 | 
				
			||||||
 | 
					    <center>
 | 
				
			||||||
 | 
					        <VBox>
 | 
				
			||||||
 | 
					            <StyledText maxWidth="500" styleClass="std-padding">
 | 
				
			||||||
 | 
					                Transaction tags are just bits of text that can be applied to a
 | 
				
			||||||
 | 
					                transaction to give it additional meaning or make searching for
 | 
				
			||||||
 | 
					                certain transactions easier.
 | 
				
			||||||
 | 
					                --
 | 
				
			||||||
 | 
					                Tags are automatically created if you add a new one to a
 | 
				
			||||||
 | 
					                transaction, and they'll show up here. When you remove a tag,
 | 
				
			||||||
 | 
					                it will be permanently removed from **all** transactions that it
 | 
				
			||||||
 | 
					                was previously associated with.
 | 
				
			||||||
 | 
					            </StyledText>
 | 
				
			||||||
 | 
					            <ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
 | 
				
			||||||
 | 
					                <VBox fx:id="tagsVBox" styleClass="tile-container"/>
 | 
				
			||||||
 | 
					            </ScrollPane>
 | 
				
			||||||
 | 
					        </VBox>
 | 
				
			||||||
 | 
					    </center>
 | 
				
			||||||
 | 
					</BorderPane>
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,7 @@
 | 
				
			||||||
<?import javafx.scene.layout.*?>
 | 
					<?import javafx.scene.layout.*?>
 | 
				
			||||||
<?import javafx.scene.text.Text?>
 | 
					<?import javafx.scene.text.Text?>
 | 
				
			||||||
<?import javafx.scene.text.TextFlow?>
 | 
					<?import javafx.scene.text.TextFlow?>
 | 
				
			||||||
 | 
					<?import javafx.scene.shape.Circle?>
 | 
				
			||||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
					<BorderPane xmlns="http://javafx.com/javafx"
 | 
				
			||||||
            xmlns:fx="http://javafx.com/fxml"
 | 
					            xmlns:fx="http://javafx.com/fxml"
 | 
				
			||||||
            fx:controller="com.andrewlalis.perfin.control.TransactionViewController"
 | 
					            fx:controller="com.andrewlalis.perfin.control.TransactionViewController"
 | 
				
			||||||
| 
						 | 
					@ -32,6 +33,37 @@
 | 
				
			||||||
                    <Label text="Description" styleClass="bold-text"/>
 | 
					                    <Label text="Description" styleClass="bold-text"/>
 | 
				
			||||||
                    <Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/>
 | 
					                    <Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/>
 | 
				
			||||||
                </PropertiesPane>
 | 
					                </PropertiesPane>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <PropertiesPane vgap="5" hgap="5">
 | 
				
			||||||
 | 
					                    <columnConstraints>
 | 
				
			||||||
 | 
					                        <ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
 | 
				
			||||||
 | 
					                        <ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
 | 
				
			||||||
 | 
					                    </columnConstraints>
 | 
				
			||||||
 | 
					                    <Label text="Vendor" styleClass="bold-text"/>
 | 
				
			||||||
 | 
					                    <Label fx:id="vendorLabel"/>
 | 
				
			||||||
 | 
					                </PropertiesPane>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <PropertiesPane vgap="5" hgap="5">
 | 
				
			||||||
 | 
					                    <columnConstraints>
 | 
				
			||||||
 | 
					                        <ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
 | 
				
			||||||
 | 
					                        <ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
 | 
				
			||||||
 | 
					                    </columnConstraints>
 | 
				
			||||||
 | 
					                    <Label text="Category" styleClass="bold-text"/>
 | 
				
			||||||
 | 
					                    <HBox styleClass="std-spacing">
 | 
				
			||||||
 | 
					                        <Circle radius="8" fx:id="categoryColorIndicator"/>
 | 
				
			||||||
 | 
					                        <Label fx:id="categoryLabel"/>
 | 
				
			||||||
 | 
					                    </HBox>
 | 
				
			||||||
 | 
					                </PropertiesPane>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <PropertiesPane vgap="5" hgap="5">
 | 
				
			||||||
 | 
					                    <columnConstraints>
 | 
				
			||||||
 | 
					                        <ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
 | 
				
			||||||
 | 
					                        <ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
 | 
				
			||||||
 | 
					                    </columnConstraints>
 | 
				
			||||||
 | 
					                    <Label text="Tags" styleClass="bold-text"/>
 | 
				
			||||||
 | 
					                    <Label fx:id="tagsLabel"/>
 | 
				
			||||||
 | 
					                </PropertiesPane>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <VBox>
 | 
					                <VBox>
 | 
				
			||||||
                    <TextFlow>
 | 
					                    <TextFlow>
 | 
				
			||||||
                        <Text text="Debited to"/>
 | 
					                        <Text text="Debited to"/>
 | 
				
			||||||
| 
						 | 
					@ -45,7 +77,7 @@
 | 
				
			||||||
                <AttachmentsViewPane fx:id="attachmentsViewPane"/>
 | 
					                <AttachmentsViewPane fx:id="attachmentsViewPane"/>
 | 
				
			||||||
                <HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
 | 
					                <HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
 | 
				
			||||||
                    <Button text="Edit" onAction="#editTransaction"/>
 | 
					                    <Button text="Edit" onAction="#editTransaction"/>
 | 
				
			||||||
                    <Button text="Delete this transaction" onAction="#deleteTransaction"/>
 | 
					                    <Button text="Delete" onAction="#deleteTransaction"/>
 | 
				
			||||||
                </HBox>
 | 
					                </HBox>
 | 
				
			||||||
            </VBox>
 | 
					            </VBox>
 | 
				
			||||||
        </ScrollPane>
 | 
					        </ScrollPane>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,7 @@
 | 
				
			||||||
<?import javafx.scene.control.Label?>
 | 
					<?import javafx.scene.control.Label?>
 | 
				
			||||||
<?import javafx.scene.control.ScrollPane?>
 | 
					<?import javafx.scene.control.ScrollPane?>
 | 
				
			||||||
<?import javafx.scene.layout.*?>
 | 
					<?import javafx.scene.layout.*?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.TextField?>
 | 
				
			||||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
					<BorderPane xmlns="http://javafx.com/javafx"
 | 
				
			||||||
            xmlns:fx="http://javafx.com/fxml"
 | 
					            xmlns:fx="http://javafx.com/fxml"
 | 
				
			||||||
            fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
 | 
					            fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
 | 
				
			||||||
| 
						 | 
					@ -20,7 +21,8 @@
 | 
				
			||||||
        <HBox>
 | 
					        <HBox>
 | 
				
			||||||
            <BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
 | 
					            <BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
 | 
				
			||||||
                <top>
 | 
					                <top>
 | 
				
			||||||
                    <HBox styleClass="std-padding,std-spacing">
 | 
					                    <HBox styleClass="padding-extra,std-spacing">
 | 
				
			||||||
 | 
					                        <TextField fx:id="searchField" promptText="Search"/>
 | 
				
			||||||
                        <PropertiesPane hgap="5" vgap="5">
 | 
					                        <PropertiesPane hgap="5" vgap="5">
 | 
				
			||||||
                            <Label text="Filter by Account"/>
 | 
					                            <Label text="Filter by Account"/>
 | 
				
			||||||
                            <AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
 | 
					                            <AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,32 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8"?>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<?import javafx.scene.control.Button?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.Label?>
 | 
				
			||||||
 | 
					<?import javafx.scene.control.ScrollPane?>
 | 
				
			||||||
 | 
					<?import javafx.scene.layout.*?>
 | 
				
			||||||
 | 
					<?import com.andrewlalis.perfin.view.component.StyledText?>
 | 
				
			||||||
 | 
					<BorderPane xmlns="http://javafx.com/javafx"
 | 
				
			||||||
 | 
					            xmlns:fx="http://javafx.com/fxml"
 | 
				
			||||||
 | 
					            fx:controller="com.andrewlalis.perfin.control.VendorsViewController"
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					    <top>
 | 
				
			||||||
 | 
					        <Label text="Vendors" styleClass="large-font,bold-text,std-padding"/>
 | 
				
			||||||
 | 
					    </top>
 | 
				
			||||||
 | 
					    <center>
 | 
				
			||||||
 | 
					        <VBox>
 | 
				
			||||||
 | 
					            <StyledText maxWidth="500" styleClass="std-padding">
 | 
				
			||||||
 | 
					                Vendors are businesses or other financial entities with which
 | 
				
			||||||
 | 
					                you do transactions. By tagging a vendor on your transactions,
 | 
				
			||||||
 | 
					                it becomes easier to find out just how much money you're
 | 
				
			||||||
 | 
					                spending at certain shops, and how often. It can also make it a
 | 
				
			||||||
 | 
					                lot easier to look up past transactions.
 | 
				
			||||||
 | 
					            </StyledText>
 | 
				
			||||||
 | 
					            <HBox styleClass="std-padding,std-spacing" VBox.vgrow="NEVER">
 | 
				
			||||||
 | 
					                <Button text="Add Vendor" onAction="#addVendor"/>
 | 
				
			||||||
 | 
					            </HBox>
 | 
				
			||||||
 | 
					            <ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
 | 
				
			||||||
 | 
					                <VBox fx:id="vendorsVBox" styleClass="tile-container"/>
 | 
				
			||||||
 | 
					            </ScrollPane>
 | 
				
			||||||
 | 
					        </VBox>
 | 
				
			||||||
 | 
					    </center>
 | 
				
			||||||
 | 
					</BorderPane>
 | 
				
			||||||
		Loading…
	
		Reference in New Issue