From b78323479498407c73880bdb43746e7cb404cf89 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Thu, 18 Jan 2024 08:53:25 -0500 Subject: [PATCH 01/18] Added M001_AddTransactionProperties.sql migration and schema updates. --- .../data/impl/JdbcDataSourceFactory.java | 2 +- .../data/impl/migration/Migrations.java | 11 ++- .../perfin/data/util/FileUtil.java | 11 +++ .../com/andrewlalis/perfin/model/Profile.java | 12 +-- .../M001_AddTransactionProperties.sql | 73 ++++++++++++++++++ src/main/resources/sql/schema.sql | 75 +++++++++++++++++-- 6 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 src/main/resources/sql/migration/M001_AddTransactionProperties.sql diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index bf18f06..e61e096 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -32,7 +32,7 @@ public class JdbcDataSourceFactory { * the profile has a newer schema version, we'll exit and prompt the user * to update their app. */ - public static final int SCHEMA_VERSION = 1; + public static final int SCHEMA_VERSION = 2; public DataSource getDataSource(String profileName) throws ProfileLoadException { final boolean dbExists = Files.exists(getDatabaseFile(profileName)); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java index 79a7d6c..bd8122d 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java @@ -4,10 +4,19 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +/** + * Utility class for defining and using all known 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 getMigrations() { final Map migrations = new HashMap<>(); - migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql")); + migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql")); return migrations; } diff --git a/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java index 0edab5c..09bb389 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java @@ -1,5 +1,6 @@ package com.andrewlalis.perfin.data.util; +import com.andrewlalis.perfin.model.Profile; import javafx.stage.FileChooser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -103,4 +104,14 @@ public class FileUtil { 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); + } + } } diff --git a/src/main/java/com/andrewlalis/perfin/model/Profile.java b/src/main/java/com/andrewlalis/perfin/model/Profile.java index 7dc8ee9..8881b3d 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Profile.java +++ b/src/main/java/com/andrewlalis/perfin/model/Profile.java @@ -17,6 +17,8 @@ import java.util.List; import java.util.Properties; import java.util.function.Consumer; +import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile; + /** * A profile is essentially a complete set of data that the application can * operate on, sort of like a save file or user account. The profile contains @@ -153,16 +155,6 @@ public class Profile { 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); - } - } - public static boolean validateName(String name) { return name != null && name.matches("\\w+") && diff --git a/src/main/resources/sql/migration/M001_AddTransactionProperties.sql b/src/main/resources/sql/migration/M001_AddTransactionProperties.sql new file mode 100644 index 0000000..da29d91 --- /dev/null +++ b/src/main/resources/sql/migration/M001_AddTransactionProperties.sql @@ -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; + diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index 71010d6..17ff5f9 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -8,14 +8,6 @@ CREATE TABLE account ( 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 ( id BIGINT PRIMARY KEY AUTO_INCREMENT, uploaded_at TIMESTAMP NOT NULL, @@ -24,6 +16,45 @@ CREATE TABLE attachment ( 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 ( id BIGINT PRIMARY KEY AUTO_INCREMENT, timestamp TIMESTAMP NOT NULL, @@ -52,6 +83,34 @@ CREATE TABLE transaction_attachment ( 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 ( id BIGINT PRIMARY KEY AUTO_INCREMENT, timestamp TIMESTAMP NOT NULL, From 4951b8720dfdf058e4e17937477bb683c2a2c6fa Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Thu, 18 Jan 2024 10:09:06 -0500 Subject: [PATCH 02/18] Refactor profile loading and turn profile into a record. --- .../com/andrewlalis/perfin/PerfinApp.java | 9 +- .../perfin/control/AccountViewController.java | 10 +- .../control/AccountsViewController.java | 4 +- .../control/BalanceRecordViewController.java | 4 +- .../CreateBalanceRecordController.java | 8 +- .../perfin/control/EditAccountController.java | 4 +- .../control/EditTransactionController.java | 8 +- .../control/ProfilesViewController.java | 15 ++- .../control/TransactionViewController.java | 4 +- .../control/TransactionsViewController.java | 10 +- .../perfin/data/DataSourceFactory.java | 19 +++ .../data/impl/JdbcDataSourceFactory.java | 10 +- .../com/andrewlalis/perfin/model/Profile.java | 124 +++--------------- .../perfin/model/ProfileLoader.java | 90 +++++++++++++ .../view/component/AccountSelectionBox.java | 2 +- .../perfin/view/component/AccountTile.java | 2 +- .../view/component/AttachmentPreview.java | 4 +- .../view/component/TransactionTile.java | 2 +- 18 files changed, 183 insertions(+), 146 deletions(-) create mode 100644 src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index 22b0fa6..13d1701 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -3,7 +3,9 @@ package com.andrewlalis.perfin; import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView; import com.andrewlalis.javafx_scene_router.SceneRouter; import com.andrewlalis.perfin.data.ProfileLoadException; +import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory; import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.ProfileLoader; import com.andrewlalis.perfin.view.ImageCache; import com.andrewlalis.perfin.view.SceneUtil; import com.andrewlalis.perfin.view.StartupSplashScreen; @@ -29,6 +31,7 @@ public class PerfinApp extends Application { private static final Logger log = LoggerFactory.getLogger(PerfinApp.class); public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin"); public static PerfinApp instance; + public static ProfileLoader profileLoader; /** * The router that's used for navigating between different "pages" in the application. @@ -48,6 +51,7 @@ public class PerfinApp extends Application { @Override public void start(Stage stage) { instance = this; + profileLoader = new ProfileLoader(stage, new JdbcDataSourceFactory()); loadFonts(); var splashScreen = new StartupSplashScreen(List.of( PerfinApp::defineRoutes, @@ -112,9 +116,10 @@ public class PerfinApp extends Application { } private static void loadLastUsedProfile(Consumer msgConsumer) throws Exception { - msgConsumer.accept("Loading the most recent profile."); + String lastProfile = ProfileLoader.getLastProfile(); + msgConsumer.accept("Loading the most recent profile: \"" + lastProfile + "\"."); try { - Profile.loadLast(); + Profile.setCurrent(profileLoader.load(lastProfile)); } catch (ProfileLoadException e) { msgConsumer.accept("Failed to load the profile: " + e.getMessage()); throw e; diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java index 2694104..411a57a 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java @@ -64,7 +64,7 @@ public class AccountViewController implements RouteSelectionListener { accountNumberLabel.setText(account.getAccountNumber()); accountCurrencyLabel.setText(account.getCurrency().getDisplayName()); accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt())); - Profile.getCurrent().getDataSource().getAccountBalanceText(account) + Profile.getCurrent().dataSource().getAccountBalanceText(account) .thenAccept(accountBalanceLabel::setText); reloadHistory(); @@ -96,7 +96,7 @@ public class AccountViewController implements RouteSelectionListener { "later if you need to." ); 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"); } } @@ -107,7 +107,7 @@ public class AccountViewController implements RouteSelectionListener { "status?" ); 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"); } } @@ -122,13 +122,13 @@ public class AccountViewController implements RouteSelectionListener { "want to hide it." ); 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"); } } @FXML public void loadMoreHistory() { - Profile.getCurrent().getDataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> { + Profile.getCurrent().dataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> { List historyItems = repo.findMostRecentForAccount( account.id, loadHistoryFrom, diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java index 7234eb6..5071b96 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java @@ -49,7 +49,7 @@ public class AccountsViewController implements RouteSelectionListener { public void refreshAccounts() { Profile.whenLoaded(profile -> { - profile.getDataSource().useRepoAsync(AccountRepository.class, repo -> { + profile.dataSource().useRepoAsync(AccountRepository.class, repo -> { List accounts = repo.findAllOrderedByRecentHistory(); Platform.runLater(() -> accountsPane.getChildren() .setAll(accounts.stream() @@ -59,7 +59,7 @@ public class AccountsViewController implements RouteSelectionListener { }); // Compute grand totals! Thread.ofVirtual().start(() -> { - var totals = profile.getDataSource().getCombinedAccountBalances(); + var totals = profile.dataSource().getCombinedAccountBalances(); StringBuilder sb = new StringBuilder("Totals: "); for (var entry : totals.entrySet()) { sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey()))); diff --git a/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java b/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java index 642294a..32334ca 100644 --- a/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java @@ -41,7 +41,7 @@ public class BalanceRecordViewController implements RouteSelectionListener { timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp())); balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount())); currencyLabel.setText(balanceRecord.getCurrency().getDisplayName()); - Profile.getCurrent().getDataSource().useRepoAsync(BalanceRecordRepository.class, repo -> { + Profile.getCurrent().dataSource().useRepoAsync(BalanceRecordRepository.class, repo -> { List attachments = repo.findAttachments(balanceRecord.id); Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments)); }); @@ -50,7 +50,7 @@ public class BalanceRecordViewController implements RouteSelectionListener { @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."); 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(); } } diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java index f70b78a..68d1657 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -60,7 +60,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { return; } 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); Platform.runLater(() -> balanceWarningLabel.visibleProperty().set( !reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance) @@ -76,7 +76,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { public void onRouteSelected(Object context) { this.account = (Account) context; 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); Platform.runLater(() -> balanceField.setText( CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency())) @@ -95,7 +95,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE) )); if (confirm && confirmIfInconsistentBalance(reportedBalance)) { - Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> { + Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> { repo.insert( DateUtil.localToUTC(localTimestamp), account.id, @@ -113,7 +113,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { } private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) { - BigDecimal currentDerivedBalance = Profile.getCurrent().getDataSource().mapRepo( + BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo( AccountRepository.class, repo -> repo.deriveCurrentBalance(account.id) ); diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java index 3074aa9..3e3142e 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java @@ -106,8 +106,8 @@ public class EditAccountController implements RouteSelectionListener { @FXML public void save() { try ( - var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository(); - var balanceRepo = Profile.getCurrent().getDataSource().getBalanceRecordRepository() + var accountRepo = Profile.getCurrent().dataSource().getAccountRepository(); + var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository() ) { if (creatingNewAccount.get()) { String name = accountNameField.getText().strip(); diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index c708fe0..6bebcd8 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -111,7 +111,7 @@ public class EditTransactionController implements RouteSelectionListener { List existingAttachments = attachmentsSelectionArea.getSelectedAttachments(); final long idToNavigate; if (transaction == null) { - idToNavigate = Profile.getCurrent().getDataSource().mapRepo( + idToNavigate = Profile.getCurrent().dataSource().mapRepo( TransactionRepository.class, repo -> repo.insert( utcTimestamp, @@ -123,7 +123,7 @@ public class EditTransactionController implements RouteSelectionListener { ) ); } else { - Profile.getCurrent().getDataSource().useRepo( + Profile.getCurrent().dataSource().useRepo( TransactionRepository.class, repo -> repo.update( transaction.id, @@ -165,8 +165,8 @@ public class EditTransactionController implements RouteSelectionListener { container.setDisable(true); Thread.ofVirtual().start(() -> { try ( - var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository(); - var transactionRepo = Profile.getCurrent().getDataSource().getTransactionRepository() + var accountRepo = Profile.getCurrent().dataSource().getAccountRepository(); + var transactionRepo = Profile.getCurrent().dataSource().getTransactionRepository() ) { // First fetch all the data. List currencies = accountRepo.findAllUsedCurrencies().stream() diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java index 89d7aca..d9bb663 100644 --- a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java @@ -4,6 +4,7 @@ import com.andrewlalis.perfin.PerfinApp; import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.ProfileLoader; import com.andrewlalis.perfin.view.ProfilesStage; import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; @@ -44,7 +45,7 @@ public class ProfilesViewController { @FXML public void addProfile() { String name = newProfileNameField.getText(); 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 + "\"?"); if (confirm) { if (openProfile(name, false)) { @@ -56,8 +57,8 @@ public class ProfilesViewController { } private void refreshAvailableProfiles() { - List profileNames = Profile.getAvailableProfiles(); - String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName(); + List profileNames = ProfileLoader.getAvailableProfiles(); + String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name(); List nodes = new ArrayList<>(profileNames.size()); for (String profileName : profileNames) { boolean isCurrent = profileName.equals(currentProfile); @@ -104,7 +105,7 @@ public class ProfilesViewController { private boolean openProfile(String name, boolean showPopup) { log.info("Opening profile \"{}\".", name); try { - Profile.load(name); + PerfinApp.profileLoader.load(name); ProfilesStage.closeView(); router.replace("accounts"); if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded."); @@ -123,11 +124,11 @@ public class ProfilesViewController { try { FileUtil.deleteDirRecursive(Profile.getDir(name)); // Reset the app's "last profile" to the default if it was the deleted profile. - if (Profile.getLastProfile().equals(name)) { - Profile.saveLastProfile("default"); + if (ProfileLoader.getLastProfile().equals(name)) { + ProfileLoader.saveLastProfile("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); } refreshAvailableProfiles(); diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index ca2181e..7315ec0 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -45,7 +45,7 @@ public class TransactionViewController { amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount())); timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp())); descriptionLabel.setText(transaction.getDescription()); - Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> { + Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> { CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id); List attachments = repo.findAttachments(transaction.id); Platform.runLater(() -> { @@ -81,7 +81,7 @@ public class TransactionViewController { "it's derived from the most recent balance-record, and transactions." ); if (confirm) { - Profile.getCurrent().getDataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id)); + Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id)); router.replace("transactions"); } } diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index bb00272..6db70a3 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -66,7 +66,7 @@ public class TransactionsViewController implements RouteSelectionListener { @Override public Page fetchPage(PageRequest pagination) throws Exception { Account accountFilter = filterByAccountComboBox.getValue(); - try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) { + try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) { Page result; if (accountFilter == null) { result = repo.findAll(pagination); @@ -80,7 +80,7 @@ public class TransactionsViewController implements RouteSelectionListener { @Override public int getTotalCount() throws Exception { Account accountFilter = filterByAccountComboBox.getValue(); - try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) { + try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) { if (accountFilter == null) { return (int) repo.countAll(); } else { @@ -124,7 +124,7 @@ public class TransactionsViewController implements RouteSelectionListener { transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially. // Refresh account filter options. - Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { + Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> { List accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); Platform.runLater(() -> { filterByAccountComboBox.setAccounts(accounts); @@ -135,7 +135,7 @@ 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 (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) { - Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> { + Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> { repo.findById(ctx.selectedTransactionId).ifPresent(tx -> { long offset = repo.countAllAfter(tx.id); int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1; @@ -161,7 +161,7 @@ public class TransactionsViewController implements RouteSelectionListener { File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow()); if (file != null) { try ( - var repo = Profile.getCurrent().getDataSource().getTransactionRepository(); + var repo = Profile.getCurrent().dataSource().getTransactionRepository(); var out = new PrintWriter(file, StandardCharsets.UTF_8) ) { out.println("id,utc-timestamp,amount,currency,description"); diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java new file mode 100644 index 0000000..44c5d3f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java @@ -0,0 +1,19 @@ +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; +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index e61e096..77ed7ab 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -1,6 +1,7 @@ package com.andrewlalis.perfin.data.impl; import com.andrewlalis.perfin.data.DataSource; +import com.andrewlalis.perfin.data.DataSourceFactory; import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.impl.migration.Migration; import com.andrewlalis.perfin.data.impl.migration.Migrations; @@ -23,7 +24,7 @@ import java.util.List; /** * 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); /** @@ -59,6 +60,13 @@ public class JdbcDataSourceFactory { 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 { log.info("Creating new database for profile {}.", profileName); JdbcDataSource dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName)); diff --git a/src/main/java/com/andrewlalis/perfin/model/Profile.java b/src/main/java/com/andrewlalis/perfin/model/Profile.java index 8881b3d..992dddc 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Profile.java +++ b/src/main/java/com/andrewlalis/perfin/model/Profile.java @@ -2,23 +2,16 @@ package com.andrewlalis.perfin.model; import com.andrewlalis.perfin.PerfinApp; 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.LoggerFactory; -import java.io.IOException; -import java.nio.file.Files; +import java.lang.ref.WeakReference; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import java.util.HashSet; import java.util.Properties; +import java.util.Set; import java.util.function.Consumer; -import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile; - /** * A profile is essentially a complete set of data that the application can * operate on, sort of like a save file or user account. The profile contains @@ -36,34 +29,17 @@ import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile; * unloaded. *

*/ -public class Profile { +public record Profile(String name, Properties settings, DataSource dataSource) { private static final Logger log = LoggerFactory.getLogger(Profile.class); private static Profile current; - private static final List> profileLoadListeners = new ArrayList<>(); + private static final Set>> currentProfileListeners = new HashSet<>(); - private final String name; - private final Properties settings; - private final DataSource dataSource; - - private Profile(String name, Properties settings, DataSource dataSource) { - this.name = name; - this.settings = settings; - this.dataSource = dataSource; - } - - public String getName() { + @Override + public String toString() { return name; } - public Properties getSettings() { - return settings; - } - - public DataSource getDataSource() { - return dataSource; - } - public static Path getDir(String name) { return PerfinApp.APP_DIR.resolve(name); } @@ -80,79 +56,22 @@ public class Profile { return current; } + public static void setCurrent(Profile profile) { + current = profile; + for (var ref : currentProfileListeners) { + Consumer consumer = ref.get(); + if (consumer != null) { + consumer.accept(profile); + } + } + currentProfileListeners.removeIf(ref -> ref.get() == null); + } + public static void whenLoaded(Consumer consumer) { if (current != null) { consumer.accept(current); - } else { - profileLoadListeners.add(consumer); } - } - - public static List 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")); + currentProfileListeners.add(new WeakReference<>(consumer)); } public static boolean validateName(String name) { @@ -160,9 +79,4 @@ public class Profile { name.matches("\\w+") && name.toLowerCase().equals(name); } - - @Override - public String toString() { - return name; - } } diff --git a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java new file mode 100644 index 0000000..046ea58 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java @@ -0,0 +1,90 @@ +package com.andrewlalis.perfin.model; + +import com.andrewlalis.perfin.PerfinApp; +import com.andrewlalis.perfin.data.DataSourceFactory; +import com.andrewlalis.perfin.data.ProfileLoadException; +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; + +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); + } + return new Profile(name, settings, dataSourceFactory.getDataSource(name)); + } + + public static List 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")); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java index 2b38968..f9cbdb9 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java @@ -110,7 +110,7 @@ public class AccountSelectionBox extends ComboBox { nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")"); if (showBalanceProp.get()) { - Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { + Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> { BigDecimal balance = repo.deriveCurrentBalance(item.id); Platform.runLater(() -> { balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency()))); diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountTile.java index ee4a415..1787078 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountTile.java @@ -81,7 +81,7 @@ public class AccountTile extends BorderPane { Label balanceLabel = new Label("Computing balance..."); balanceLabel.getStyleClass().addAll("mono-font"); balanceLabel.setDisable(true); - Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { + Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> { BigDecimal balance = repo.deriveCurrentBalance(account.id); String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency())); Platform.runLater(() -> { diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java b/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java index 2e986d0..094caeb 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java @@ -46,7 +46,7 @@ public class AttachmentPreview extends BorderPane { boolean showDocIcon = true; Set imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp"); 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); contentContainer.setCenter(new ImageView(img)); showDocIcon = false; @@ -69,7 +69,7 @@ public class AttachmentPreview extends BorderPane { this.setCenter(stackPane); this.setOnMouseClicked(event -> { 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()); } }); diff --git a/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java b/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java index fd0ff44..993fd4b 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java @@ -100,7 +100,7 @@ public class TransactionTile extends BorderPane { } private CompletableFuture getCreditAndDebitAccounts(Transaction transaction) { - return Profile.getCurrent().getDataSource().mapRepoAsync( + return Profile.getCurrent().dataSource().mapRepoAsync( TransactionRepository.class, repo -> repo.findLinkedAccounts(transaction.id) ); From da589807ef1e03046dfafe3129946ecdf002ba7b Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Thu, 18 Jan 2024 10:44:37 -0500 Subject: [PATCH 03/18] Updated popups to include owner. --- .../perfin/control/AccountViewController.java | 3 ++ .../control/BalanceRecordViewController.java | 5 ++- .../CreateBalanceRecordController.java | 4 +- .../perfin/control/EditAccountController.java | 4 +- .../control/EditTransactionController.java | 2 +- .../andrewlalis/perfin/control/Popups.java | 41 +++++++++++++++++-- .../control/ProfilesViewController.java | 12 +++--- .../control/TransactionViewController.java | 1 + .../control/TransactionsViewController.java | 2 +- .../com/andrewlalis/perfin/model/Profile.java | 5 +++ .../perfin/model/ProfileLoader.java | 16 ++++++++ .../perfin/view/StartupSplashScreen.java | 16 +++++++- 12 files changed, 94 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java index 411a57a..e89b049 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java @@ -89,6 +89,7 @@ public class AccountViewController implements RouteSelectionListener { @FXML public void archiveAccount() { boolean confirmResult = Popups.confirm( + titleLabel, "Are you sure you want to archive this account? It will no " + "longer show up in the app normally, and you won't be " + "able to add new transactions to it. You'll still be " + @@ -103,6 +104,7 @@ public class AccountViewController implements RouteSelectionListener { @FXML public void unarchiveAccount() { boolean confirm = Popups.confirm( + titleLabel, "Are you sure you want to restore this account from its archived " + "status?" ); @@ -115,6 +117,7 @@ public class AccountViewController implements RouteSelectionListener { @FXML public void deleteAccount() { boolean confirm = Popups.confirm( + titleLabel, "Are you sure you want to permanently delete this account and " + "all data directly associated with it? This cannot be " + "undone; deleted accounts are not recoverable at all. " + diff --git a/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java b/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java index 32334ca..9e2c4d4 100644 --- a/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java @@ -48,7 +48,10 @@ public class BalanceRecordViewController implements RouteSelectionListener { } @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) { Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id)); router.navigateBackAndClear(); diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java index 68d1657..a9233ff 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -89,7 +89,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); 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(), CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())), localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE) @@ -122,7 +122,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())), CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency())) ); - return Popups.confirm(msg); + return Popups.confirm(timestampField, msg); } return true; } diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java index 3e3142e..2d97e97 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java @@ -117,7 +117,7 @@ public class EditAccountController implements RouteSelectionListener { BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip()); List 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) { long id = accountRepo.insert(type, number, name, currency); balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments); @@ -138,7 +138,7 @@ public class EditAccountController implements RouteSelectionListener { } } catch (Exception 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()); } } diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index 6bebcd8..5db7345 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -203,7 +203,7 @@ public class EditTransactionController implements RouteSelectionListener { }); } catch (Exception e) { log.error("Failed to get repositories.", e); - Popups.error("Failed to fetch account-specific data: " + e.getMessage()); + Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()); } }); } diff --git a/src/main/java/com/andrewlalis/perfin/control/Popups.java b/src/main/java/com/andrewlalis/perfin/control/Popups.java index eb2c5d4..ff2af1f 100644 --- a/src/main/java/com/andrewlalis/perfin/control/Popups.java +++ b/src/main/java/com/andrewlalis/perfin/control/Popups.java @@ -1,30 +1,65 @@ package com.andrewlalis.perfin.control; +import javafx.scene.Node; +import javafx.scene.Scene; import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; import javafx.stage.Modality; +import javafx.stage.Window; /** * Helper class for standardized popups and confirmation dialogs for the app. */ 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.initOwner(owner); alert.initModality(Modality.APPLICATION_MODAL); var result = alert.showAndWait(); 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.initOwner(owner); alert.initModality(Modality.APPLICATION_MODAL); alert.getButtonTypes().setAll(ButtonType.OK); 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.initOwner(owner); alert.initModality(Modality.APPLICATION_MODAL); 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); + } + + private static Window getWindowFromNode(Node n) { + Window owner = null; + Scene scene = n.getScene(); + if (scene != null) { + owner = scene.getWindow(); + } + return owner; + } } diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java index d9bb663..c26665a 100644 --- a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java @@ -46,10 +46,10 @@ public class ProfilesViewController { String name = newProfileNameField.getText(); boolean valid = Profile.validateName(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 (openProfile(name, false)) { - Popups.message("Created new profile \"" + name + "\" and loaded it."); + Popups.message(profilesVBox, "Created new profile \"" + name + "\" and loaded it."); } newProfileNameField.clear(); } @@ -108,18 +108,18 @@ public class ProfilesViewController { PerfinApp.profileLoader.load(name); ProfilesStage.closeView(); 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; } catch (ProfileLoadException e) { - Popups.error("Failed to load the profile: " + e.getMessage()); + Popups.error(profilesVBox, "Failed to load the profile: " + e.getMessage()); return false; } } 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) { - 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) { try { FileUtil.deleteDirRecursive(Profile.getDir(name)); diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index 7315ec0..8884c8d 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -72,6 +72,7 @@ public class TransactionViewController { @FXML public void deleteTransaction() { boolean confirm = Popups.confirm( + titleLabel, "Are you sure you want to delete this transaction? This will " + "permanently remove the transaction and its effects on any linked " + "accounts, as well as remove any attachments from storage within " + diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index 6db70a3..d9a6d73 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -177,7 +177,7 @@ public class TransactionsViewController implements RouteSelectionListener { )); } } catch (Exception e) { - Popups.error("An error occurred: " + e.getMessage()); + Popups.error(transactionsListBorderPane, e); } } } diff --git a/src/main/java/com/andrewlalis/perfin/model/Profile.java b/src/main/java/com/andrewlalis/perfin/model/Profile.java index 992dddc..de4647b 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Profile.java +++ b/src/main/java/com/andrewlalis/perfin/model/Profile.java @@ -28,6 +28,10 @@ import java.util.function.Consumer; * class maintains a static current profile that can be loaded and * unloaded. *

+ * + * @param name The name of the profile. + * @param settings The profile's settings. + * @param dataSource The profile's data source. */ public record Profile(String name, Properties settings, DataSource dataSource) { private static final Logger log = LoggerFactory.getLogger(Profile.class); @@ -65,6 +69,7 @@ public record Profile(String name, Properties settings, DataSource dataSource) { } } currentProfileListeners.removeIf(ref -> ref.get() == null); + log.debug("Current profile set to {}.", current.name()); } public static void whenLoaded(Consumer consumer) { diff --git a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java index 046ea58..4b58b2d 100644 --- a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java +++ b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java @@ -1,6 +1,7 @@ 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.util.FileUtil; @@ -43,6 +44,21 @@ public class ProfileLoader { } 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) { + throw new ProfileLoadException("User rejected migration."); + } + } else if (status == DataSourceFactory.SchemaStatus.INCOMPATIBLE) { + Popups.error(window, "The profile \"" + name + "\" has a data schema that's incompatible with this app."); + throw new ProfileLoadException("Incompatible schema version."); + } + } catch (IOException e) { + throw new ProfileLoadException("Failed to get profile's schema status.", e); + } + Popups.message(window, "Test!"); return new Profile(name, settings, dataSourceFactory.getDataSource(name)); } diff --git a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java index 526951d..c0d9994 100644 --- a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java +++ b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java @@ -9,6 +9,7 @@ import javafx.stage.Stage; import javafx.stage.StageStyle; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; /** @@ -60,6 +61,10 @@ public class StartupSplashScreen extends Stage implements Consumer { 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() { Thread.ofVirtual().start(() -> { try { @@ -69,7 +74,16 @@ public class StartupSplashScreen extends Stage implements Consumer { } for (var task : tasks) { try { - task.accept(this); + CompletableFuture future = new CompletableFuture<>(); + Platform.runLater(() -> { + try { + task.accept(this); + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + }); + future.join(); Thread.sleep(500); } catch (Exception e) { accept("Startup failed: " + e.getMessage()); From 788e043269245813dd517787345f80e86b4f447f Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Thu, 18 Jan 2024 11:03:15 -0500 Subject: [PATCH 04/18] Added more popups for user when opening a profile that requires migration. --- .../perfin/data/DataSourceFactory.java | 2 ++ .../perfin/data/impl/JdbcDataSourceFactory.java | 2 +- .../perfin/data/impl/migration/Migrations.java | 10 ++++++++++ .../andrewlalis/perfin/model/ProfileLoader.java | 16 +++++++++++++--- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java index 44c5d3f..5fbc7d8 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java @@ -16,4 +16,6 @@ public interface DataSourceFactory { INCOMPATIBLE } SchemaStatus getSchemaStatus(String profileName) throws IOException; + + int getSchemaVersion(String profileName) throws IOException; } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index 77ed7ab..59819b7 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -176,7 +176,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory { 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))) { try { return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip()); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java index bd8122d..6e80290 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java @@ -34,4 +34,14 @@ public class Migrations { } return selectedMigration; } + + public static Map getSchemaVersionCompatibility() { + final Map compatibilities = new HashMap<>(); + compatibilities.put(1, "1.4.0"); + return compatibilities; + } + + public static String getLatestCompatibleVersion(int schemaVersion) { + return getSchemaVersionCompatibility().get(schemaVersion); + } } diff --git a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java index 4b58b2d..b1f6a4e 100644 --- a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java +++ b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java @@ -4,6 +4,7 @@ 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; @@ -18,6 +19,10 @@ 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); @@ -49,16 +54,21 @@ public class ProfileLoader { 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) { - throw new ProfileLoadException("User rejected migration."); + 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."); + 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); } - Popups.message(window, "Test!"); return new Profile(name, settings, dataSourceFactory.getDataSource(name)); } From e17e2c55a56a781dbeeb084654dcffbc0bef7b0d Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Thu, 18 Jan 2024 11:10:12 -0500 Subject: [PATCH 05/18] Fixed issue with profile not being set. --- .../com/andrewlalis/perfin/control/ProfilesViewController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java index c26665a..8f3d08c 100644 --- a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java @@ -105,7 +105,7 @@ public class ProfilesViewController { private boolean openProfile(String name, boolean showPopup) { log.info("Opening profile \"{}\".", name); try { - PerfinApp.profileLoader.load(name); + Profile.setCurrent(PerfinApp.profileLoader.load(name)); ProfilesStage.closeView(); router.replace("accounts"); if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded."); From b9678313bf5fd1be8c52e51661c0c7c303eb2353 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Mon, 29 Jan 2024 14:01:49 -0500 Subject: [PATCH 06/18] Added ability to edit tags, vendor, and category of a transaction. --- .../com/andrewlalis/perfin/PerfinApp.java | 2 +- .../control/EditTransactionController.java | 154 ++++++++--- .../control/ProfilesViewController.java | 1 + .../andrewlalis/perfin/data/DataSource.java | 4 + .../data/TransactionCategoryRepository.java | 17 ++ .../perfin/data/TransactionRepository.java | 8 + .../data/TransactionVendorRepository.java | 15 ++ .../perfin/data/impl/JdbcDataSource.java | 10 + .../JdbcTransactionCategoryRepository.java | 90 +++++++ .../data/impl/JdbcTransactionRepository.java | 250 +++++++++++++++--- .../impl/JdbcTransactionVendorRepository.java | 78 ++++++ .../perfin/data/util/ColorUtil.java | 14 + .../andrewlalis/perfin/data/util/DbUtil.java | 25 +- .../andrewlalis/perfin/model/Transaction.java | 14 +- .../perfin/model/TransactionCategory.java | 35 +++ .../perfin/model/TransactionLineItem.java | 65 +++++ .../perfin/model/TransactionTag.java | 19 ++ .../perfin/model/TransactionVendor.java | 32 +++ .../perfin/view/StartupSplashScreen.java | 36 +-- src/main/resources/edit-transaction.fxml | 28 +- 20 files changed, 813 insertions(+), 84 deletions(-) create mode 100644 src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/TransactionTag.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index 13d1701..298f128 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -58,7 +58,7 @@ public class PerfinApp extends Application { PerfinApp::initAppDir, c -> initMainScreen(stage, c), PerfinApp::loadLastUsedProfile - )); + ), false); splashScreen.showAndWait(); if (splashScreen.isStartupSuccessful()) { stage.show(); diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index 5db7345..6faed03 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -1,12 +1,14 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.data.DataSource; import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.Sort; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.*; +import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.validation.ValidationApplier; @@ -15,10 +17,16 @@ import com.andrewlalis.perfin.view.component.validation.validators.PredicateVali import javafx.application.Platform; import javafx.beans.property.Property; import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.event.ActionEvent; import javafx.fxml.FXML; +import javafx.geometry.Pos; import javafx.scene.control.*; +import javafx.scene.input.KeyCode; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,10 +35,7 @@ import java.nio.file.Path; import java.time.DateTimeException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Collections; -import java.util.Comparator; -import java.util.Currency; -import java.util.List; +import java.util.*; import static com.andrewlalis.perfin.PerfinApp.router; @@ -49,6 +54,13 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public AccountSelectionBox debitAccountSelector; @FXML public AccountSelectionBox creditAccountSelector; + @FXML public ComboBox vendorComboBox; + @FXML public ComboBox categoryComboBox; + @FXML public ComboBox tagsComboBox; + @FXML public Button addTagButton; + @FXML public VBox tagsVBox; + private final ObservableList selectedTags = FXCollections.observableArrayList(); + @FXML public FileSelectionArea attachmentsSelectionArea; @FXML public Button saveButton; @@ -75,27 +87,40 @@ public class EditTransactionController implements RouteSelectionListener { Property linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts()); debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); - var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator() - .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." - ) - ).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty); + var linkedAccountsValid = new ValidationApplier<>(getLinkedAccountsValidator()) + .validatedInitially() + .attach(linkedAccountsContainer, linkedAccountsProperty); + + // Set up the list of added tags. + 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, 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; + }); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); saveButton.disableProperty().bind(formValid.not()); @@ -107,6 +132,9 @@ public class EditTransactionController implements RouteSelectionListener { Currency currency = currencyChoiceBox.getValue(); String description = getSanitizedDescription(); CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); + String vendor = vendorComboBox.getValue(); + String category = categoryComboBox.getValue(); + Set tags = new HashSet<>(selectedTags); List newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths(); List existingAttachments = attachmentsSelectionArea.getSelectedAttachments(); final long idToNavigate; @@ -119,6 +147,9 @@ public class EditTransactionController implements RouteSelectionListener { currency, description, linkedAccounts, + vendor, + category, + tags, newAttachmentPaths ) ); @@ -132,6 +163,9 @@ public class EditTransactionController implements RouteSelectionListener { currency, description, linkedAccounts, + vendor, + category, + tags, existingAttachments, newAttachmentPaths ) @@ -149,6 +183,11 @@ public class EditTransactionController implements RouteSelectionListener { public void onRouteSelected(Object context) { transaction = (Transaction) context; + // Clear some initial fields immediately: + tagsComboBox.setValue(null); + vendorComboBox.setValue(null); + categoryComboBox.setValue(null); + if (transaction == null) { titleLabel.setText("Create New Transaction"); timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); @@ -163,10 +202,13 @@ public class EditTransactionController implements RouteSelectionListener { // Fetch some account-specific data. container.setDisable(true); + DataSource ds = Profile.getCurrent().dataSource(); Thread.ofVirtual().start(() -> { try ( - var accountRepo = Profile.getCurrent().dataSource().getAccountRepository(); - var transactionRepo = Profile.getCurrent().dataSource().getTransactionRepository() + var accountRepo = ds.getAccountRepository(); + var transactionRepo = ds.getTransactionRepository(); + var vendorRepo = ds.getTransactionVendorRepository(); + var categoryRepo = ds.getTransactionCategoryRepository() ) { // First fetch all the data. List currencies = accountRepo.findAllUsedCurrencies().stream() @@ -174,23 +216,50 @@ public class EditTransactionController implements RouteSelectionListener { .toList(); List accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); final List attachments; + final List availableTags = transactionRepo.findAllTags(); + final List tags; final CreditAndDebitAccounts linkedAccounts; + final String vendorName; + final String categoryName; if (transaction == null) { attachments = Collections.emptyList(); + tags = Collections.emptyList(); linkedAccounts = new CreditAndDebitAccounts(null, null); + vendorName = null; + categoryName = null; } else { attachments = transactionRepo.findAttachments(transaction.id); + tags = transactionRepo.findTags(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) { + categoryName = categoryRepo.findById(transaction.getCategoryId()) + .map(TransactionCategory::getName).orElse(null); + } else { + categoryName = null; + } } + final List availableVendors = vendorRepo.findAll(); + final List availableCategories = categoryRepo.findAll(); // Then make updates to the view. Platform.runLater(() -> { + currencyChoiceBox.getItems().setAll(currencies); creditAccountSelector.setAccounts(accounts); debitAccountSelector.setAccounts(accounts); - currencyChoiceBox.getItems().setAll(currencies); + vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList()); + vendorComboBox.setValue(vendorName); + categoryComboBox.getItems().setAll(availableCategories.stream().map(TransactionCategory::getName).toList()); + categoryComboBox.setValue(categoryName); + tagsComboBox.getItems().setAll(availableTags); attachmentsSelectionArea.clear(); attachmentsSelectionArea.addAttachments(attachments); + selectedTags.clear(); if (transaction == null) { - // TODO: Allow user to select a default currency. currencyChoiceBox.getSelectionModel().selectFirst(); creditAccountSelector.select(null); debitAccountSelector.select(null); @@ -198,12 +267,14 @@ public class EditTransactionController implements RouteSelectionListener { currencyChoiceBox.getSelectionModel().select(transaction.getCurrency()); creditAccountSelector.select(linkedAccounts.creditAccount()); debitAccountSelector.select(linkedAccounts.debitAccount()); + selectedTags.addAll(tags); } container.setDisable(false); }); } catch (Exception e) { log.error("Failed to get repositories.", e); - Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()); + Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage())); + router.navigateBackAndClear(); } }); } @@ -215,6 +286,29 @@ public class EditTransactionController implements RouteSelectionListener { ); } + private PredicateValidator getLinkedAccountsValidator() { + return new PredicateValidator() + .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() { List formatters = List.of( DateTimeFormatter.ISO_LOCAL_DATE_TIME, diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java index 8f3d08c..0304a94 100644 --- a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java @@ -106,6 +106,7 @@ public class ProfilesViewController { log.info("Opening profile \"{}\".", name); try { Profile.setCurrent(PerfinApp.profileLoader.load(name)); + ProfileLoader.saveLastProfile(name); ProfilesStage.closeView(); router.replace("accounts"); if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded."); diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java index ca008de..ba57e7a 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java @@ -30,6 +30,8 @@ public interface DataSource { AccountRepository getAccountRepository(); BalanceRecordRepository getBalanceRecordRepository(); TransactionRepository getTransactionRepository(); + TransactionVendorRepository getTransactionVendorRepository(); + TransactionCategoryRepository getTransactionCategoryRepository(); AttachmentRepository getAttachmentRepository(); AccountHistoryItemRepository getAccountHistoryItemRepository(); @@ -81,6 +83,8 @@ public interface DataSource { AccountRepository.class, this::getAccountRepository, BalanceRecordRepository.class, this::getBalanceRecordRepository, TransactionRepository.class, this::getTransactionRepository, + TransactionVendorRepository.class, this::getTransactionVendorRepository, + TransactionCategoryRepository.class, this::getTransactionCategoryRepository, AttachmentRepository.class, this::getAttachmentRepository, AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository ); diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java new file mode 100644 index 0000000..71a3f3b --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java @@ -0,0 +1,17 @@ +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 findById(long id); + Optional findByName(String name); + List findAllBaseCategories(); + List findAll(); + long insert(long parentId, String name, Color color); + long insert(String name, Color color); + void deleteById(long id); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index 08003cd..7865a70 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -21,6 +21,9 @@ public interface TransactionRepository extends Repository, AutoCloseable { Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List attachments ); Optional findById(long id); @@ -31,6 +34,8 @@ public interface TransactionRepository extends Repository, AutoCloseable { Page findAllByAccounts(Set accountIds, PageRequest pagination); CreditAndDebitAccounts findLinkedAccounts(long transactionId); List findAttachments(long transactionId); + List findTags(long transactionId); + List findAllTags(); void delete(long transactionId); void update( long id, @@ -39,6 +44,9 @@ public interface TransactionRepository extends Repository, AutoCloseable { Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List existingAttachments, List newAttachmentPaths ); diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java new file mode 100644 index 0000000..36eab89 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java @@ -0,0 +1,15 @@ +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 findById(long id); + Optional findByName(String name); + List findAll(); + long insert(String name, String description); + long insert(String name); + void deleteById(long id); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java index 5296a2a..9ad342e 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java @@ -49,6 +49,16 @@ public class JdbcDataSource implements DataSource { return new JdbcTransactionRepository(getConnection(), contentDir); } + @Override + public TransactionVendorRepository getTransactionVendorRepository() { + return new JdbcTransactionVendorRepository(getConnection()); + } + + @Override + public TransactionCategoryRepository getTransactionCategoryRepository() { + return new JdbcTransactionCategoryRepository(getConnection()); + } + @Override public AttachmentRepository getAttachmentRepository() { return new JdbcAttachmentRepository(getConnection(), contentDir); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java new file mode 100644 index 0000000..3eb3901 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java @@ -0,0 +1,90 @@ +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.List; +import java.util.Optional; + +public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository { + @Override + public Optional findById(long id) { + return DbUtil.findById( + conn, + "SELECT * FROM transaction_category WHERE id = ?", + id, + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public Optional findByName(String name) { + return DbUtil.findOne( + conn, + "SELECT * FROM transaction_category WHERE name = ?", + List.of(name), + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public List findAllBaseCategories() { + return DbUtil.findAll( + conn, + "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC", + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public List 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 deleteById(long id) { + DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id); + } + + @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")) + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java index 5eb9a8d..4c1f720 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -8,14 +8,14 @@ import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.data.util.UncheckedSqlException; import com.andrewlalis.perfin.model.*; +import javafx.scene.paint.Color; import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.file.Path; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; +import java.sql.*; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -28,29 +28,104 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List attachments ) { return DbUtil.doTransaction(conn, () -> { - // 1. Insert the transaction. - long txId = DbUtil.insertOne( - conn, - "INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)", - List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description) - ); - // 2. Insert linked account entries. + Long vendorId = null; + if (vendor != null && !vendor.isBlank()) { + vendorId = getOrCreateVendorId(vendor.strip()); + } + Long categoryId = null; + if (category != null && !category.isBlank()) { + 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); 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)); - // 3. Add attachments. + // Add attachments. AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); for (Path attachmentPath : attachments) { Attachment attachment = attachmentRepo.insert(attachmentPath); 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; }); } + 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 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 public Optional findById(long id) { return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction); @@ -147,6 +222,30 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem ); } + @Override + public List 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 findAllTags() { + return DbUtil.findAll( + conn, + "SELECT name FROM transaction_tag ORDER BY name ASC", + rs -> rs.getString(1) + ); + } + @Override public void delete(long transactionId) { DbUtil.doTransaction(conn, () -> { @@ -164,44 +263,93 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List existingAttachments, List newAttachmentPaths ) { DbUtil.doTransaction(conn, () -> { - Transaction tx = findById(id).orElseThrow(); - CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id); - List currentAttachments = findAttachments(id); var entryRepo = new JdbcAccountEntryRepository(conn); 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 currentTags = new HashSet<>(findTags(id)); + List currentAttachments = findAttachments(id); + List updateMessages = new ArrayList<>(); 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) + "."); } BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP); 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)) + "."); } 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() + "."); } 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."); } - boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) || + boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) || !tx.getCurrency().equals(currency) || !tx.getTimestamp().equals(utcTimestamp) || !currentLinkedAccounts.equals(linkedAccounts); - if (updateAccountEntries) { - // Delete all entries and re-write them correctly? - DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id)); + if (shouldUpdateAccountEntries) { + // Delete all entries and re-write them correctly. + 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.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency)); 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 tagsAdded = new HashSet<>(tags); + tagsAdded.removeAll(currentTags); + Set 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. List removedAttachments = new ArrayList<>(currentAttachments); removedAttachments.removeAll(existingAttachments); @@ -214,6 +362,8 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem insertAttachmentLink(tx.id, attachment.id); 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); var historyRepo = new JdbcAccountHistoryItemRepository(conn); linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); @@ -226,16 +376,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem 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) { DbUtil.insertOne( conn, @@ -243,4 +383,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem 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) + ); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java new file mode 100644 index 0000000..25ef9ca --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java @@ -0,0 +1,78 @@ +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.Optional; + +public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository { + @Override + public Optional findById(long id) { + return DbUtil.findById( + conn, + "SELECT * FROM transaction_vendor WHERE id = ?", + id, + JdbcTransactionVendorRepository::parseVendor + ); + } + + @Override + public Optional findByName(String name) { + return DbUtil.findOne( + conn, + "SELECT * FROM transaction_vendor WHERE name = ?", + List.of(name), + JdbcTransactionVendorRepository::parseVendor + ); + } + + @Override + public List 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 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") + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java new file mode 100644 index 0000000..98b9325 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java @@ -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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java index 11d2cdb..94b2cd6 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java @@ -58,6 +58,17 @@ public final class DbUtil { 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 Optional findOne(Connection conn, String query, List args, ResultSetMapper mapper) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); @@ -82,6 +93,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 args) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); @@ -92,19 +107,27 @@ 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 args) { try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { setArgs(stmt, args); int result = stmt.executeUpdate(); if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row."); var rs = stmt.getGeneratedKeys(); - rs.next(); + if (!rs.next()) throw new UncheckedSqlException("Insert query did not generate any keys."); return rs.getLong(1); } catch (SQLException 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) { return Timestamp.from(utc.toInstant(ZoneOffset.UTC)); } diff --git a/src/main/java/com/andrewlalis/perfin/model/Transaction.java b/src/main/java/com/andrewlalis/perfin/model/Transaction.java index 8f179ca..92588d8 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Transaction.java +++ b/src/main/java/com/andrewlalis/perfin/model/Transaction.java @@ -17,13 +17,17 @@ public class Transaction extends IdEntity { private final BigDecimal amount; private final Currency currency; 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); this.timestamp = timestamp; this.amount = amount; this.currency = currency; this.description = description; + this.vendorId = vendorId; + this.categoryId = categoryId; } public LocalDateTime getTimestamp() { @@ -42,6 +46,14 @@ public class Transaction extends IdEntity { return description; } + public Long getVendorId() { + return vendorId; + } + + public Long getCategoryId() { + return categoryId; + } + public MoneyValue getMoneyAmount() { return new MoneyValue(amount, currency); } diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java b/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java new file mode 100644 index 0000000..e18d86f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java @@ -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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java new file mode 100644 index 0000000..fa58745 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java @@ -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 + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java new file mode 100644 index 0000000..2dfd29f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java @@ -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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java b/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java new file mode 100644 index 0000000..af4130b --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java @@ -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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java index c0d9994..e78fb82 100644 --- a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java +++ b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java @@ -18,12 +18,14 @@ import java.util.function.Consumer; */ public class StartupSplashScreen extends Stage implements Consumer { private final List>> tasks; + private final boolean delayTasks; private boolean startupSuccessful = false; private final TextArea textArea = new TextArea(); - public StartupSplashScreen(List>> tasks) { + public StartupSplashScreen(List>> tasks, boolean delayTasks) { this.tasks = tasks; + this.delayTasks = delayTasks; setTitle("Starting Perfin..."); setResizable(false); initStyle(StageStyle.UNDECORATED); @@ -67,11 +69,7 @@ public class StartupSplashScreen extends Stage implements Consumer { */ private void runTasks() { Thread.ofVirtual().start(() -> { - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } + if (delayTasks) sleepOrThrowRE(1000); for (var task : tasks) { try { CompletableFuture future = new CompletableFuture<>(); @@ -84,27 +82,31 @@ public class StartupSplashScreen extends Stage implements Consumer { } }); future.join(); - Thread.sleep(500); + if (delayTasks) sleepOrThrowRE(500); } catch (Exception e) { accept("Startup failed: " + e.getMessage()); e.printStackTrace(System.err); - try { - Thread.sleep(5000); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); - } + sleepOrThrowRE(5000); Platform.runLater(this::close); return; } } accept("Startup successful!"); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } + if (delayTasks) sleepOrThrowRE(1000); startupSuccessful = true; 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); + } + } } diff --git a/src/main/resources/edit-transaction.fxml b/src/main/resources/edit-transaction.fxml index a0dc9b8..3b0333c 100644 --- a/src/main/resources/edit-transaction.fxml +++ b/src/main/resources/edit-transaction.fxml @@ -43,15 +43,39 @@ - + - + + + + + + + + + +