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 extends Node> 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