diff --git a/README.md b/README.md
index 94c8a50..f3c6b63 100644
--- a/README.md
+++ b/README.md
@@ -37,3 +37,30 @@ to set the version everywhere that it needs to be.
Once that's done, the workflow will start, and you should see a release appear
in the next few minutes.
+
+## Migration Procedure
+
+Because this application relies on a structured relational database schema,
+changes to the schema must be handled with care to avoid destroying users' data.
+Specifically, when changes are made to the schema, a *migration* must be defined
+which provides instructions for Perfin to safely apply changes to an old schema.
+
+The database schema is versioned using whole-number versions (1, 2, 3, ...), and
+a migration is defined for each transition from version to version, such that
+any older version can be incrementally upgraded, step by step, to the latest
+schema version.
+
+Perfin only supports the latest schema version, as defined by `JdbcDataSourceFactory.SCHEMA_VERSION`.
+When the app loads a profile, it'll check that profile's schema version by
+reading a `.jdbc-schema-version.txt` file in the profile's main directory. If
+the profile's schema version is **less than** the current, Perfin will
+ask the user if they want to upgrade. If the profile's schema version is
+**greater than** the current, Perfin will tell the user that it can't load a
+schema from a newer version, and will prompt the user to upgrade.
+
+### Writing a Migration
+
+1. Write your migration. This can be plain SQL (placed in `resources/sql/migration`), or Java code.
+2. Add your migration to `com.andrewlalis.perfin.data.impl.migration.Migrations#getMigrations()`.
+3. Increment the schema version defined in `JdbcDataSourceFactory`.
+4. Test the migration yourself on a profile with data.
diff --git a/pom.xml b/pom.xml
index f7af29b..025ecdb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
com.andrewlalisperfin
- 1.4.0
+ 1.5.021
diff --git a/scripts/package-linux-deb.sh b/scripts/package-linux-deb.sh
index 61ec33e..df98726 100755
--- a/scripts/package-linux-deb.sh
+++ b/scripts/package-linux-deb.sh
@@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar"
jpackage \
--name "Perfin" \
- --app-version "1.4.0" \
+ --app-version "1.5.0" \
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
--icon design/perfin-logo_256.png \
--vendor "Andrew Lalis" \
diff --git a/scripts/package-windows-msi.ps1 b/scripts/package-windows-msi.ps1
index a51d31e..8a30060 100644
--- a/scripts/package-windows-msi.ps1
+++ b/scripts/package-windows-msi.ps1
@@ -12,7 +12,7 @@ $modulePath = "$modulePath;target\modules\h2-2.2.224.jar"
jpackage `
--name "Perfin" `
- --app-version "1.4.0" `
+ --app-version "1.5.0" `
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
--icon design\perfin-logo_256.ico `
--vendor "Andrew Lalis" `
diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java
index 22b0fa6..0ec8083 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,13 +51,14 @@ 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,
PerfinApp::initAppDir,
c -> initMainScreen(stage, c),
PerfinApp::loadLastUsedProfile
- ));
+ ), false);
splashScreen.showAndWait();
if (splashScreen.isStartupSuccessful()) {
stage.show();
@@ -87,6 +91,11 @@ public class PerfinApp extends Application {
router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml"));
router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml"));
router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml"));
+ router.map("vendors", PerfinApp.class.getResource("/vendors-view.fxml"));
+ router.map("edit-vendor", PerfinApp.class.getResource("/edit-vendor.fxml"));
+ router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
+ router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
+ router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
// Help pages.
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
@@ -112,9 +121,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..5241538 100644
--- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java
+++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java
@@ -1,12 +1,12 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
-import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.AccountRepository;
+import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile;
-import com.andrewlalis.perfin.model.history.AccountHistoryItem;
+import com.andrewlalis.perfin.model.history.HistoryItem;
import com.andrewlalis.perfin.view.component.AccountHistoryItemTile;
import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression;
@@ -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();
@@ -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 " +
@@ -96,18 +97,19 @@ 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");
}
}
@FXML public void unarchiveAccount() {
boolean confirm = Popups.confirm(
+ titleLabel,
"Are you sure you want to restore this account from its archived " +
"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");
}
}
@@ -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. " +
@@ -122,26 +125,21 @@ 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 -> {
- List historyItems = repo.findMostRecentForAccount(
- account.id,
- loadHistoryFrom,
- historyLoadSize
- );
- if (historyItems.size() < historyLoadSize) {
+ Profile.getCurrent().dataSource().useRepoAsync(HistoryRepository.class, repo -> {
+ long historyId = repo.getOrCreateHistoryForAccount(account.id);
+ List items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom);
+ if (items.size() < historyLoadSize) {
Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
} else {
- loadHistoryFrom = historyItems.getLast().getTimestamp();
+ loadHistoryFrom = items.getLast().getTimestamp();
}
- List extends Node> nodes = historyItems.stream()
- .map(item -> AccountHistoryItemTile.forItem(item, repo, this))
- .toList();
+ List extends Node> nodes = items.stream().map(AccountHistoryItemTile::forItem).toList();
Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
});
}
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..9e2c4d4 100644
--- a/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java
+++ b/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java
@@ -41,16 +41,19 @@ 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));
});
}
@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().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/CategoriesViewController.java b/src/main/java/com/andrewlalis/perfin/control/CategoriesViewController.java
new file mode 100644
index 0000000..a006b25
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/control/CategoriesViewController.java
@@ -0,0 +1,63 @@
+package com.andrewlalis.perfin.control;
+
+import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
+import com.andrewlalis.perfin.data.TransactionCategoryRepository;
+import com.andrewlalis.perfin.data.impl.JdbcDataSource;
+import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
+import com.andrewlalis.perfin.data.util.DbUtil;
+import com.andrewlalis.perfin.model.Profile;
+import com.andrewlalis.perfin.view.BindingUtil;
+import com.andrewlalis.perfin.view.component.CategoryTile;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.fxml.FXML;
+import javafx.scene.layout.VBox;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+import static com.andrewlalis.perfin.PerfinApp.router;
+
+public class CategoriesViewController implements RouteSelectionListener {
+ @FXML public VBox categoriesVBox;
+ private final ObservableList categoryTreeNodes = FXCollections.observableArrayList();
+
+ @FXML public void initialize() {
+ BindingUtil.mapContent(categoriesVBox.getChildren(), categoryTreeNodes, node -> new CategoryTile(node, this::refreshCategories));
+ }
+
+ @Override
+ public void onRouteSelected(Object context) {
+ refreshCategories();
+ }
+
+ @FXML public void addCategory() {
+ router.navigate("edit-category");
+ }
+
+ private void refreshCategories() {
+ Profile.getCurrent().dataSource().mapRepoAsync(
+ TransactionCategoryRepository.class,
+ TransactionCategoryRepository::findTree
+ ).thenAccept(nodes -> Platform.runLater(() -> categoryTreeNodes.setAll(nodes)));
+ }
+
+ @FXML public void addDefaultCategories() {
+ boolean confirm = Popups.confirm(categoriesVBox, "Are you sure you want to add all of Perfin's default categories to your profile? This might interfere with existing categories of the same name.");
+ if (!confirm) return;
+ JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
+ try (var conn = ds.getConnection()) {
+ DbUtil.doTransaction(conn, () -> {
+ try {
+ new JdbcDataSourceFactory().insertDefaultCategories(conn);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ });
+ refreshCategories();
+ } catch (Exception e) {
+ Popups.error(categoriesVBox, e);
+ }
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java
index f70b78a..583aff0 100644
--- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java
+++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java
@@ -11,6 +11,7 @@ import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
+import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import javafx.application.Platform;
@@ -39,7 +40,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
private Account account;
@FXML public void initialize() {
- var timestampValid = new ValidationApplier(input -> {
+ var timestampValid = new ValidationApplier<>((ValidationFunction) input -> {
try {
DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
return ValidationResult.valid();
@@ -60,7 +61,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 +77,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()))
@@ -89,13 +90,13 @@ 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)
));
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 +114,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)
);
@@ -122,7 +123,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 3074aa9..2d97e97 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();
@@ -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/EditCategoryController.java b/src/main/java/com/andrewlalis/perfin/control/EditCategoryController.java
new file mode 100644
index 0000000..792990e
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/control/EditCategoryController.java
@@ -0,0 +1,108 @@
+package com.andrewlalis.perfin.control;
+
+import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
+import com.andrewlalis.perfin.data.TransactionCategoryRepository;
+import com.andrewlalis.perfin.model.Profile;
+import com.andrewlalis.perfin.model.TransactionCategory;
+import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
+import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
+import javafx.fxml.FXML;
+import javafx.scene.control.Button;
+import javafx.scene.control.ColorPicker;
+import javafx.scene.control.TextField;
+import javafx.scene.paint.Color;
+
+import java.util.concurrent.CompletableFuture;
+
+import static com.andrewlalis.perfin.PerfinApp.router;
+
+public class EditCategoryController implements RouteSelectionListener {
+ public record CategoryRouteContext(TransactionCategory category) implements RouteContext {}
+ public record AddSubcategoryRouteContext(TransactionCategory parent) implements RouteContext {}
+ private sealed interface RouteContext permits AddSubcategoryRouteContext, CategoryRouteContext {}
+
+ private TransactionCategory category;
+ private TransactionCategory parent;
+
+ @FXML public TextField nameField;
+ @FXML public ColorPicker colorPicker;
+
+ @FXML public Button saveButton;
+
+ @FXML public void initialize() {
+ var nameValid = new ValidationApplier<>(new PredicateValidator()
+ .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
+ .addPredicate(s -> s.strip().length() <= TransactionCategory.NAME_MAX_LENGTH, "Name is too long.")
+ .addAsyncPredicate(
+ s -> {
+ if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
+ return Profile.getCurrent().dataSource().mapRepoAsync(
+ TransactionCategoryRepository.class,
+ repo -> {
+ var categoryByName = repo.findByName(s).orElse(null);
+ if (this.category != null) {
+ return this.category.equals(categoryByName) || categoryByName == null;
+ }
+ return categoryByName == null;
+ }
+ );
+ },
+ "Category with this name already exists."
+ )
+ ).validatedInitially().attachToTextField(nameField);
+
+ saveButton.disableProperty().bind(nameValid.not());
+ }
+
+ @Override
+ public void onRouteSelected(Object context) {
+ this.category = null;
+ this.parent = null;
+ if (context instanceof RouteContext ctx) {
+ switch (ctx) {
+ case CategoryRouteContext(var cat):
+ this.category = cat;
+ nameField.setText(cat.getName());
+ colorPicker.setValue(cat.getColor());
+ break;
+ case AddSubcategoryRouteContext(var par):
+ this.parent = par;
+ nameField.setText(null);
+ colorPicker.setValue(parent.getColor());
+ break;
+ }
+ } else {
+ nameField.setText(null);
+ colorPicker.setValue(Color.WHITE);
+ }
+ }
+
+ @FXML public void save() {
+ final String name = nameField.getText().strip();
+ final Color color = colorPicker.getValue();
+ if (this.category == null && this.parent == null) {
+ // New top-level category.
+ Profile.getCurrent().dataSource().useRepo(
+ TransactionCategoryRepository.class,
+ repo -> repo.insert(name, color)
+ );
+ } else if (this.category == null) {
+ // New subcategory.
+ Profile.getCurrent().dataSource().useRepo(
+ TransactionCategoryRepository.class,
+ repo -> repo.insert(parent.id, name, color)
+ );
+ } else if (this.parent == null) {
+ // Save edits to an existing category.
+ Profile.getCurrent().dataSource().useRepo(
+ TransactionCategoryRepository.class,
+ repo -> repo.update(category.id, name, color)
+ );
+ }
+ router.replace("categories");
+ }
+
+ @FXML public void cancel() {
+ router.navigateBackAndClear();
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java
index c708fe0..0a7c119 100644
--- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java
+++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java
@@ -1,24 +1,36 @@
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.CategorySelectionBox;
import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
+import javafx.beans.binding.BooleanExpression;
+import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
+import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
import javafx.fxml.FXML;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
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,13 +39,14 @@ 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;
+/**
+ * Controller for the "edit-transaction" view, which is where the user can
+ * create or edit transactions.
+ */
public class EditTransactionController implements RouteSelectionListener {
private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
@@ -49,6 +62,25 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public AccountSelectionBox debitAccountSelector;
@FXML public AccountSelectionBox creditAccountSelector;
+ @FXML public ComboBox vendorComboBox;
+ @FXML public Hyperlink vendorsHyperlink;
+ @FXML public CategorySelectionBox categoryComboBox;
+ @FXML public Hyperlink categoriesHyperlink;
+ @FXML public ComboBox tagsComboBox;
+ @FXML public Hyperlink tagsHyperlink;
+ @FXML public Button addTagButton;
+ @FXML public VBox tagsVBox;
+ private final ObservableList selectedTags = FXCollections.observableArrayList();
+
+ @FXML public Spinner lineItemQuantitySpinner;
+ @FXML public TextField lineItemValueField;
+ @FXML public TextField lineItemDescriptionField;
+ @FXML public Button addLineItemButton;
+ @FXML public VBox addLineItemForm;
+ @FXML public Button addLineItemAddButton;
+ @FXML public Button addLineItemCancelButton;
+ @FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
+
@FXML public FileSelectionArea attachmentsSelectionArea;
@FXML public Button saveButton;
@@ -70,32 +102,32 @@ public class EditTransactionController implements RouteSelectionListener {
var descriptionValid = new ValidationApplier<>(new PredicateValidator()
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
+ var linkedAccountsValid = initializeLinkedAccountsValidationUi();
+ initializeTagSelectionUi();
- // Linked accounts will use a property derived from both the debit and credit selections.
- 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);
+ vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
+ categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
+ tagsHyperlink.setOnAction(event -> router.navigate("tags"));
+
+ // Initialize line item stuff.
+ addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
+ addLineItemCancelButton.setOnAction(event -> {
+ lineItemQuantitySpinner.getValueFactory().setValue(1);
+ lineItemValueField.setText(null);
+ lineItemDescriptionField.setText(null);
+ addingLineItemProperty.set(false);
+ });
+ BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
+ BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
+ lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
+ var lineItemValueValid = new ValidationApplier<>(
+ new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
+ ).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
+ var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator()
+ .addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
+ ).attachToTextField(lineItemDescriptionField);
+ var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
+ addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not());
@@ -107,11 +139,14 @@ public class EditTransactionController implements RouteSelectionListener {
Currency currency = currencyChoiceBox.getValue();
String description = getSanitizedDescription();
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
+ String vendor = vendorComboBox.getValue();
+ String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName();
+ Set tags = new HashSet<>(selectedTags);
List newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
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,
@@ -119,11 +154,14 @@ public class EditTransactionController implements RouteSelectionListener {
currency,
description,
linkedAccounts,
+ vendor,
+ category,
+ tags,
newAttachmentPaths
)
);
} else {
- Profile.getCurrent().getDataSource().useRepo(
+ Profile.getCurrent().dataSource().useRepo(
TransactionRepository.class,
repo -> repo.update(
transaction.id,
@@ -132,6 +170,9 @@ public class EditTransactionController implements RouteSelectionListener {
currency,
description,
linkedAccounts,
+ vendor,
+ category,
+ tags,
existingAttachments,
newAttachmentPaths
)
@@ -149,6 +190,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.select(null);
+
if (transaction == null) {
titleLabel.setText("Create New Transaction");
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
@@ -163,10 +209,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().getDataSource().getAccountRepository();
- var transactionRepo = Profile.getCurrent().getDataSource().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 +223,50 @@ public class EditTransactionController implements RouteSelectionListener {
.toList();
List accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
final List attachments;
+ final var categoryTreeNodes = categoryRepo.findTree();
+ final List availableTags = transactionRepo.findAllTags();
+ final List tags;
final CreditAndDebitAccounts linkedAccounts;
+ final String vendorName;
+ final TransactionCategory category;
if (transaction == null) {
attachments = Collections.emptyList();
+ tags = Collections.emptyList();
linkedAccounts = new CreditAndDebitAccounts(null, null);
+ vendorName = null;
+ category = 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) {
+ category = categoryRepo.findById(transaction.getCategoryId()).orElse(null);
+ } else {
+ category = null;
+ }
}
+ final List availableVendors = vendorRepo.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.loadCategories(categoryTreeNodes);
+ categoryComboBox.select(category);
+ tagsComboBox.getItems().setAll(availableTags);
attachmentsSelectionArea.clear();
attachmentsSelectionArea.addAttachments(attachments);
+ selectedTags.clear();
+ selectedTags.addAll(tags);
if (transaction == null) {
- // TODO: Allow user to select a default currency.
currencyChoiceBox.getSelectionModel().selectFirst();
creditAccountSelector.select(null);
debitAccountSelector.select(null);
@@ -203,11 +279,53 @@ 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());
+ Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()));
+ router.navigateBackAndClear();
}
});
}
+ private BooleanExpression initializeLinkedAccountsValidationUi() {
+ Property linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
+ debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
+ creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
+ return new ValidationApplier<>(getLinkedAccountsValidator())
+ .validatedInitially()
+ .attach(linkedAccountsContainer, linkedAccountsProperty);
+ }
+
+ private void initializeTagSelectionUi() {
+ addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
+ addTagButton.setOnAction(event -> {
+ if (tagsComboBox.getValue() == null) return;
+ String tag = tagsComboBox.getValue().strip();
+ if (!selectedTags.contains(tag)) {
+ selectedTags.add(tag);
+ selectedTags.sort(String::compareToIgnoreCase);
+ }
+ tagsComboBox.setValue(null);
+ });
+ tagsComboBox.setOnKeyPressed(event -> {
+ if (event.getCode() == KeyCode.ENTER) {
+ addTagButton.fire();
+ }
+ });
+ BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, this::createTagListTile);
+ }
+
+ private Node createTagListTile(String tag) {
+ Label label = new Label(tag);
+ label.setMaxWidth(Double.POSITIVE_INFINITY);
+ label.getStyleClass().addAll("bold-text");
+ Button removeButton = new Button("Remove");
+ removeButton.setOnAction(event -> selectedTags.remove(tag));
+ BorderPane tile = new BorderPane(label);
+ tile.setRight(removeButton);
+ tile.getStyleClass().addAll("std-spacing");
+ BorderPane.setAlignment(label, Pos.CENTER_LEFT);
+ return tile;
+ }
+
private CreditAndDebitAccounts getSelectedAccounts() {
return new CreditAndDebitAccounts(
creditAccountSelector.getValue(),
@@ -215,6 +333,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/EditVendorController.java b/src/main/java/com/andrewlalis/perfin/control/EditVendorController.java
new file mode 100644
index 0000000..3c18cb4
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/control/EditVendorController.java
@@ -0,0 +1,93 @@
+package com.andrewlalis.perfin.control;
+
+import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
+import com.andrewlalis.perfin.data.DataSource;
+import com.andrewlalis.perfin.data.TransactionVendorRepository;
+import com.andrewlalis.perfin.model.Profile;
+import com.andrewlalis.perfin.model.TransactionVendor;
+import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
+import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
+import javafx.fxml.FXML;
+import javafx.scene.control.Button;
+import javafx.scene.control.TextArea;
+import javafx.scene.control.TextField;
+
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+
+import static com.andrewlalis.perfin.PerfinApp.router;
+
+public class EditVendorController implements RouteSelectionListener {
+ private TransactionVendor vendor;
+
+ @FXML public TextField nameField;
+ @FXML public TextArea descriptionField;
+ @FXML public Button saveButton;
+
+ @FXML public void initialize() {
+ var nameValid = new ValidationApplier<>(new PredicateValidator()
+ .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
+ .addPredicate(s -> s.strip().length() <= TransactionVendor.NAME_MAX_LENGTH, "Name is too long.")
+ // A predicate that prevents duplicate names.
+ .addAsyncPredicate(
+ s -> {
+ if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
+ return Profile.getCurrent().dataSource().mapRepoAsync(
+ TransactionVendorRepository.class,
+ repo -> {
+ var vendorByName = repo.findByName(s).orElse(null);
+ if (this.vendor != null) {
+ return this.vendor.equals(vendorByName) || vendorByName == null;
+ }
+ return vendorByName == null;
+ }
+ );
+ },
+ "Vendor with this name already exists."
+ )
+ ).validatedInitially().attachToTextField(nameField);
+ var descriptionValid = new ValidationApplier<>(new PredicateValidator()
+ .addPredicate(
+ s -> s == null || s.strip().length() <= TransactionVendor.DESCRIPTION_MAX_LENGTH,
+ "Description is too long."
+ )
+ ).validatedInitially().attach(descriptionField, descriptionField.textProperty());
+
+ var formValid = nameValid.and(descriptionValid);
+ saveButton.disableProperty().bind(formValid.not());
+ }
+
+ @Override
+ public void onRouteSelected(Object context) {
+ if (context instanceof TransactionVendor tv) {
+ this.vendor = tv;
+ nameField.setText(vendor.getName());
+ descriptionField.setText(vendor.getDescription());
+ } else {
+ nameField.setText(null);
+ descriptionField.setText(null);
+ }
+ }
+
+ @FXML public void save() {
+ String name = nameField.getText().strip();
+ String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
+ DataSource ds = Profile.getCurrent().dataSource();
+ if (vendor != null) {
+ ds.useRepo(TransactionVendorRepository.class, repo -> repo.update(vendor.id, name, description));
+ } else {
+ ds.useRepo(TransactionVendorRepository.class, repo -> {
+ if (description == null || description.isEmpty()) {
+ repo.insert(name);
+ } else {
+ repo.insert(name, description);
+ }
+ });
+ }
+ router.replace("vendors");
+ }
+
+ @FXML public void cancel() {
+ router.navigateBackAndClear();
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/control/Popups.java b/src/main/java/com/andrewlalis/perfin/control/Popups.java
index eb2c5d4..b0477bb 100644
--- a/src/main/java/com/andrewlalis/perfin/control/Popups.java
+++ b/src/main/java/com/andrewlalis/perfin/control/Popups.java
@@ -1,30 +1,70 @@
package com.andrewlalis.perfin.control;
+import javafx.application.Platform;
+import javafx.scene.Node;
+import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.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);
+ }
+
+ public static void errorLater(Node node, Exception e) {
+ Platform.runLater(() -> error(node, e));
+ }
+
+ private static Window getWindowFromNode(Node n) {
+ Window owner = null;
+ Scene scene = n.getScene();
+ if (scene != null) {
+ owner = scene.getWindow();
+ }
+ return owner;
+ }
}
diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java
index 89d7aca..0304a94 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,11 +45,11 @@ public class ProfilesViewController {
@FXML public void addProfile() {
String name = newProfileNameField.getText();
boolean valid = Profile.validateName(name);
- if (valid && !Profile.getAvailableProfiles().contains(name)) {
- boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?");
+ if (valid && !ProfileLoader.getAvailableProfiles().contains(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();
}
@@ -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,30 +105,31 @@ public class ProfilesViewController {
private boolean openProfile(String name, boolean showPopup) {
log.info("Opening profile \"{}\".", name);
try {
- Profile.load(name);
+ Profile.setCurrent(PerfinApp.profileLoader.load(name));
+ ProfileLoader.saveLastProfile(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));
// 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/TagsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TagsViewController.java
new file mode 100644
index 0000000..2f0c569
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/control/TagsViewController.java
@@ -0,0 +1,64 @@
+package com.andrewlalis.perfin.control;
+
+import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
+import com.andrewlalis.perfin.data.TransactionRepository;
+import com.andrewlalis.perfin.model.Profile;
+import com.andrewlalis.perfin.view.BindingUtil;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.fxml.FXML;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.VBox;
+
+public class TagsViewController implements RouteSelectionListener {
+ @FXML public VBox tagsVBox;
+ private final ObservableList tags = FXCollections.observableArrayList();
+
+ @FXML public void initialize() {
+ BindingUtil.mapContent(tagsVBox.getChildren(), tags, this::buildTagTile);
+ }
+
+ @Override
+ public void onRouteSelected(Object context) {
+ refreshTags();
+ }
+
+ private void refreshTags() {
+ Profile.getCurrent().dataSource().mapRepoAsync(
+ TransactionRepository.class,
+ TransactionRepository::findAllTags
+ ).thenAccept(strings -> Platform.runLater(() -> tags.setAll(strings)));
+ }
+
+ private Node buildTagTile(String name) {
+ BorderPane tile = new BorderPane();
+ tile.getStyleClass().addAll("tile");
+ Label nameLabel = new Label(name);
+ nameLabel.getStyleClass().addAll("bold-text");
+ Label usagesLabel = new Label();
+ usagesLabel.getStyleClass().addAll("small-font", "secondary-color-text-fill");
+ Profile.getCurrent().dataSource().mapRepoAsync(
+ TransactionRepository.class,
+ repo -> repo.countTagUsages(name)
+ ).thenAccept(count -> Platform.runLater(() -> usagesLabel.setText("Tagged transactions: " + count)));
+ VBox contentBox = new VBox(nameLabel, usagesLabel);
+ tile.setLeft(contentBox);
+ Button removeButton = new Button("Remove");
+ removeButton.setOnAction(event -> {
+ boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this tag? It will be removed from any transactions. This cannot be undone.");
+ if (confirm) {
+ Profile.getCurrent().dataSource().useRepo(
+ TransactionRepository.class,
+ repo -> repo.deleteTag(name)
+ );
+ refreshTags();
+ }
+ });
+ tile.setRight(removeButton);
+ return tile;
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java
index ca2181e..02cf37d 100644
--- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java
+++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java
@@ -3,23 +3,37 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
-import com.andrewlalis.perfin.model.Attachment;
-import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
-import com.andrewlalis.perfin.model.Profile;
-import com.andrewlalis.perfin.model.Transaction;
+import com.andrewlalis.perfin.model.*;
+import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
+import com.andrewlalis.perfin.view.component.PropertiesPane;
import javafx.application.Platform;
+import javafx.beans.property.ListProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleListProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
+import javafx.scene.shape.Circle;
import javafx.scene.text.TextFlow;
-
-import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import static com.andrewlalis.perfin.PerfinApp.router;
public class TransactionViewController {
- private Transaction transaction;
+ private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
+
+ private final ObjectProperty transactionProperty = new SimpleObjectProperty<>(null);
+ private final ObjectProperty linkedAccountsProperty = new SimpleObjectProperty<>(null);
+ private final ObjectProperty vendorProperty = new SimpleObjectProperty<>(null);
+ private final ObjectProperty categoryProperty = new SimpleObjectProperty<>(null);
+ private final ObservableList tagsList = FXCollections.observableArrayList();
+ private final ListProperty tagsListProperty = new SimpleListProperty<>(tagsList);
+ private final ObservableList attachmentsList = FXCollections.observableArrayList();
@FXML public Label titleLabel;
@@ -27,51 +41,108 @@ public class TransactionViewController {
@FXML public Label timestampLabel;
@FXML public Label descriptionLabel;
+ @FXML public Label vendorLabel;
+ @FXML public Circle categoryColorIndicator;
+ @FXML public Label categoryLabel;
+ @FXML public Label tagsLabel;
+
@FXML public Hyperlink debitAccountLink;
@FXML public Hyperlink creditAccountLink;
@FXML public AttachmentsViewPane attachmentsViewPane;
@FXML public void initialize() {
- configureAccountLinkBindings(debitAccountLink);
- configureAccountLinkBindings(creditAccountLink);
+ titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id));
+ amountLabel.textProperty().bind(transactionProperty.map(t -> CurrencyUtil.formatMoney(t.getMoneyAmount())));
+ timestampLabel.textProperty().bind(transactionProperty.map(t -> DateUtil.formatUTCAsLocalWithZone(t.getTimestamp())));
+ descriptionLabel.textProperty().bind(transactionProperty.map(Transaction::getDescription));
+
+ PropertiesPane vendorPane = (PropertiesPane) vendorLabel.getParent();
+ BindingUtil.bindManagedAndVisible(vendorPane, vendorProperty.isNotNull());
+ vendorLabel.textProperty().bind(vendorProperty.map(TransactionVendor::getName));
+
+ PropertiesPane categoryPane = (PropertiesPane) categoryLabel.getParent().getParent();
+ BindingUtil.bindManagedAndVisible(categoryPane, categoryProperty.isNotNull());
+ categoryLabel.textProperty().bind(categoryProperty.map(TransactionCategory::getName));
+ categoryColorIndicator.fillProperty().bind(categoryProperty.map(TransactionCategory::getColor));
+
+ PropertiesPane tagsPane = (PropertiesPane) tagsLabel.getParent();
+ BindingUtil.bindManagedAndVisible(tagsPane, tagsListProperty.emptyProperty().not());
+ tagsLabel.textProperty().bind(tagsListProperty.map(tags -> String.join(", ", tags)));
+
+ TextFlow debitText = (TextFlow) debitAccountLink.getParent();
+ BindingUtil.bindManagedAndVisible(debitText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasDebit));
+ debitAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasDebit() ? la.debitAccount().getShortName() : null));
+ debitAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
+ if (la.hasDebit()) {
+ return event -> router.navigate("account", la.debitAccount());
+ }
+ return event -> {};
+ }));
+ TextFlow creditText = (TextFlow) creditAccountLink.getParent();
+ BindingUtil.bindManagedAndVisible(creditText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasCredit));
+ creditAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasCredit() ? la.creditAccount().getShortName() : null));
+ creditAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
+ if (la.hasCredit()) {
+ return event -> router.navigate("account", la.creditAccount());
+ }
+ return event -> {};
+ }));
+
attachmentsViewPane.hideIfEmpty();
+ attachmentsViewPane.listProperty().bindContent(attachmentsList);
+
+ transactionProperty.addListener((observable, oldValue, newValue) -> {
+ if (newValue == null) {
+ linkedAccountsProperty.set(null);
+ vendorProperty.set(null);
+ categoryProperty.set(null);
+ tagsList.clear();
+ attachmentsList.clear();
+ } else {
+ updateLinkedData(newValue);
+ }
+ });
}
public void setTransaction(Transaction transaction) {
- this.transaction = transaction;
- if (transaction == null) return;
- titleLabel.setText("Transaction #" + transaction.id);
- amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
- timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
- descriptionLabel.setText(transaction.getDescription());
- Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
- CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
- List attachments = repo.findAttachments(transaction.id);
- Platform.runLater(() -> {
- if (accounts.hasDebit()) {
- debitAccountLink.setText(accounts.debitAccount().getShortName());
- debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount()));
- } else {
- debitAccountLink.setText(null);
- }
- if (accounts.hasCredit()) {
- creditAccountLink.setText(accounts.creditAccount().getShortName());
- creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount()));
- } else {
- creditAccountLink.setText(null);
- }
- attachmentsViewPane.setAttachments(attachments);
- });
+ this.transactionProperty.set(transaction);
+ }
+
+ private void updateLinkedData(Transaction tx) {
+ var ds = Profile.getCurrent().dataSource();
+ Thread.ofVirtual().start(() -> {
+ try (
+ var transactionRepo = ds.getTransactionRepository();
+ var vendorRepo = ds.getTransactionVendorRepository();
+ var categoryRepo = ds.getTransactionCategoryRepository()
+ ) {
+ final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
+ final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
+ final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
+ final var attachments = transactionRepo.findAttachments(tx.id);
+ final var tags = transactionRepo.findTags(tx.id);
+ Platform.runLater(() -> {
+ linkedAccountsProperty.set(linkedAccounts);
+ vendorProperty.set(vendor);
+ categoryProperty.set(category);
+ attachmentsList.setAll(attachments);
+ tagsList.setAll(tags);
+ });
+ } catch (Exception e) {
+ log.error("Failed to fetch additional transaction data.", e);
+ Popups.errorLater(titleLabel, e);
+ }
});
}
@FXML public void editTransaction() {
- router.navigate("edit-transaction", this.transaction);
+ router.navigate("edit-transaction", this.transactionProperty.get());
}
@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 " +
@@ -81,15 +152,8 @@ 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(this.transactionProperty.get().id));
router.replace("transactions");
}
}
-
- private void configureAccountLinkBindings(Hyperlink link) {
- TextFlow parent = (TextFlow) link.getParent();
- parent.managedProperty().bind(parent.visibleProperty());
- parent.visibleProperty().bind(link.textProperty().isNotEmpty());
- link.setText(null);
- }
}
diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java
index bb00272..751ba51 100644
--- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java
+++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java
@@ -3,14 +3,18 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.TransactionRepository;
+import com.andrewlalis.perfin.data.impl.JdbcDataSource;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort;
+import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
+import com.andrewlalis.perfin.data.search.SearchFilter;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
+import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.SceneUtil;
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
@@ -21,6 +25,7 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML;
import javafx.scene.Node;
+import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
@@ -29,8 +34,9 @@ import javafx.stage.FileChooser;
import java.io.File;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
-import java.util.Set;
import static com.andrewlalis.perfin.PerfinApp.router;
@@ -45,6 +51,7 @@ public class TransactionsViewController implements RouteSelectionListener {
public record RouteContext(Long selectedTransactionId) {}
@FXML public BorderPane transactionsListBorderPane;
+ @FXML public TextField searchField;
@FXML public AccountSelectionBox filterByAccountComboBox;
@FXML public VBox transactionsVBox;
private DataSourcePaginationControls paginationControls;
@@ -59,33 +66,30 @@ public class TransactionsViewController implements RouteSelectionListener {
paginationControls.setPage(1);
selectedTransaction.set(null);
});
+ searchField.textProperty().addListener((observable, oldValue, newValue) -> {
+ paginationControls.setPage(1);
+ selectedTransaction.set(null);
+ });
this.paginationControls = new DataSourcePaginationControls(
transactionsVBox.getChildren(),
new DataSourcePaginationControls.PageFetcherFunction() {
@Override
public Page extends Node> fetchPage(PageRequest pagination) throws Exception {
- Account accountFilter = filterByAccountComboBox.getValue();
- try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
- Page result;
- if (accountFilter == null) {
- result = repo.findAll(pagination);
- } else {
- result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination);
- }
- return result.map(TransactionsViewController.this::makeTile);
+ JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
+ try (var conn = ds.getConnection()) {
+ JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
+ return searcher.search(pagination, getCurrentSearchFilters())
+ .map(TransactionsViewController.this::makeTile);
}
}
@Override
public int getTotalCount() throws Exception {
- Account accountFilter = filterByAccountComboBox.getValue();
- try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
- if (accountFilter == null) {
- return (int) repo.countAll();
- } else {
- return (int) repo.countAllByAccounts(Set.of(accountFilter.id));
- }
+ JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
+ try (var conn = ds.getConnection()) {
+ JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
+ return (int) searcher.resultCount(getCurrentSearchFilters());
}
}
}
@@ -98,18 +102,13 @@ public class TransactionsViewController implements RouteSelectionListener {
detailPanel.minWidthProperty().bind(halfWidthProp);
detailPanel.maxWidthProperty().bind(halfWidthProp);
detailPanel.prefWidthProperty().bind(halfWidthProp);
- detailPanel.managedProperty().bind(detailPanel.visibleProperty());
- detailPanel.visibleProperty().bind(selectedTransaction.isNotNull());
+ BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull());
Pair detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
TransactionViewController transactionViewController = detailComponents.second();
BorderPane transactionDetailView = detailComponents.first();
- transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty());
- transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull());
detailPanel.getChildren().add(transactionDetailView);
- selectedTransaction.addListener((observable, oldValue, newValue) -> {
- transactionViewController.setTransaction(newValue);
- });
+ selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue));
// Clear the transactions when a new profile is loaded.
Profile.whenLoaded(profile -> {
@@ -121,10 +120,10 @@ public class TransactionsViewController implements RouteSelectionListener {
@Override
public void onRouteSelected(Object context) {
paginationControls.sorts.setAll(DEFAULT_SORTS);
- transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially.
+ selectedTransaction.set(null); // Initially set the selected transaction as null.
// Refresh account filter options.
- 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,18 +134,19 @@ public class TransactionsViewController implements RouteSelectionListener {
// If a transaction id is given in the route context, navigate to the page it's on and select it.
if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
- Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
- repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
- long offset = repo.countAllAfter(tx.id);
- int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
- Platform.runLater(() -> {
- paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx));
- });
- });
- });
+ 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;
+ Platform.runLater(() -> {
+ paginationControls.setPage(pageNumber);
+ selectedTransaction.set(tx);
+ });
+ })
+ );
} else {
paginationControls.setPage(1);
- selectedTransaction.set(null);
}
}
@@ -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");
@@ -177,11 +177,42 @@ public class TransactionsViewController implements RouteSelectionListener {
));
}
} catch (Exception e) {
- Popups.error("An error occurred: " + e.getMessage());
+ Popups.error(transactionsListBorderPane, e);
}
}
}
+ private List getCurrentSearchFilters() {
+ List filters = new ArrayList<>();
+ if (searchField.getText() != null && !searchField.getText().isBlank()) {
+ var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+"))
+ .map(t -> '%'+t+'%')
+ .toList();
+ var builder = new SearchFilter.Builder();
+ List orClauses = new ArrayList<>(likeTerms.size());
+ for (var term : likeTerms) {
+ orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?");
+ builder.withArg(term);
+ builder.withArg(term);
+ builder.withArg(term);
+ }
+ builder.where(String.join(" OR ", orClauses));
+ builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id");
+ builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id");
+ filters.add(builder.build());
+ }
+ if (filterByAccountComboBox.getValue() != null) {
+ Account filteredAccount = filterByAccountComboBox.getValue();
+ var filter = new SearchFilter.Builder()
+ .where("fae.account_id = ?")
+ .withArg(filteredAccount.id)
+ .withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id")
+ .build();
+ filters.add(filter);
+ }
+ return filters;
+ }
+
private TransactionTile makeTile(Transaction transaction) {
var tile = new TransactionTile(transaction);
tile.setOnMouseClicked(event -> {
diff --git a/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java b/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java
new file mode 100644
index 0000000..fb7598d
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java
@@ -0,0 +1,45 @@
+package com.andrewlalis.perfin.control;
+
+import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
+import com.andrewlalis.perfin.data.TransactionVendorRepository;
+import com.andrewlalis.perfin.model.Profile;
+import com.andrewlalis.perfin.model.TransactionVendor;
+import com.andrewlalis.perfin.view.BindingUtil;
+import com.andrewlalis.perfin.view.component.VendorTile;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.fxml.FXML;
+import javafx.scene.layout.VBox;
+
+import java.util.List;
+
+import static com.andrewlalis.perfin.PerfinApp.router;
+
+public class VendorsViewController implements RouteSelectionListener {
+ @FXML public VBox vendorsVBox;
+ private final ObservableList vendors = FXCollections.observableArrayList();
+
+ @FXML public void initialize() {
+ BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, vendor -> new VendorTile(vendor, this::refreshVendors));
+ }
+
+ @Override
+ public void onRouteSelected(Object context) {
+ refreshVendors();
+ }
+
+ @FXML public void addVendor() {
+ router.navigate("edit-vendor");
+ }
+
+ private void refreshVendors() {
+ Profile.getCurrent().dataSource().useRepoAsync(TransactionVendorRepository.class, repo -> {
+ final List vendors = repo.findAll();
+ Platform.runLater(() -> {
+ this.vendors.clear();
+ this.vendors.addAll(vendors);
+ });
+ });
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java
deleted file mode 100644
index a669d03..0000000
--- a/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.andrewlalis.perfin.data;
-
-import com.andrewlalis.perfin.data.util.DateUtil;
-import com.andrewlalis.perfin.model.AccountEntry;
-import com.andrewlalis.perfin.model.BalanceRecord;
-import com.andrewlalis.perfin.model.history.AccountHistoryItem;
-
-import java.time.LocalDateTime;
-import java.util.List;
-import java.util.Optional;
-
-public interface AccountHistoryItemRepository extends Repository, AutoCloseable {
- void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId);
- void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId);
- void recordText(LocalDateTime timestamp, long accountId, String text);
- List findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count);
- default Optional getMostRecentForAccount(long accountId) {
- var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1);
- if (items.isEmpty()) return Optional.empty();
- return Optional.of(items.getFirst());
- }
- String getTextItem(long itemId);
- AccountEntry getAccountEntryItem(long itemId);
- BalanceRecord getBalanceRecordItem(long itemId);
-}
diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java
index ca008de..bd2244f 100644
--- a/src/main/java/com/andrewlalis/perfin/data/DataSource.java
+++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java
@@ -30,8 +30,10 @@ public interface DataSource {
AccountRepository getAccountRepository();
BalanceRecordRepository getBalanceRecordRepository();
TransactionRepository getTransactionRepository();
+ TransactionVendorRepository getTransactionVendorRepository();
+ TransactionCategoryRepository getTransactionCategoryRepository();
AttachmentRepository getAttachmentRepository();
- AccountHistoryItemRepository getAccountHistoryItemRepository();
+ HistoryRepository getHistoryRepository();
// Repository helper methods:
@@ -81,8 +83,10 @@ 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
+ HistoryRepository.class, this::getHistoryRepository
);
return (Supplier) repoSuppliers.get(type);
}
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..5fbc7d8
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java
@@ -0,0 +1,21 @@
+package com.andrewlalis.perfin.data;
+
+import java.io.IOException;
+
+/**
+ * Interface that defines the data source factory, a component responsible for
+ * obtaining a data source, and performing some introspection around that data
+ * source before one is obtained.
+ */
+public interface DataSourceFactory {
+ DataSource getDataSource(String profileName) throws ProfileLoadException;
+
+ enum SchemaStatus {
+ UP_TO_DATE,
+ NEEDS_MIGRATION,
+ INCOMPATIBLE
+ }
+ SchemaStatus getSchemaStatus(String profileName) throws IOException;
+
+ int getSchemaVersion(String profileName) throws IOException;
+}
diff --git a/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java
new file mode 100644
index 0000000..815f67d
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java
@@ -0,0 +1,28 @@
+package com.andrewlalis.perfin.data;
+
+import com.andrewlalis.perfin.data.pagination.Page;
+import com.andrewlalis.perfin.data.pagination.PageRequest;
+import com.andrewlalis.perfin.data.util.DateUtil;
+import com.andrewlalis.perfin.model.history.HistoryItem;
+import com.andrewlalis.perfin.model.history.HistoryTextItem;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+
+public interface HistoryRepository extends Repository, AutoCloseable {
+ long getOrCreateHistoryForAccount(long accountId);
+ long getOrCreateHistoryForTransaction(long transactionId);
+ void deleteHistoryForAccount(long accountId);
+ void deleteHistoryForTransaction(long transactionId);
+
+ HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description);
+ default HistoryTextItem addTextItem(long historyId, String description) {
+ return addTextItem(historyId, DateUtil.nowAsUTC(), description);
+ }
+ Page getItems(long historyId, PageRequest pagination);
+ List getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
+ default List getNItemsBeforeNow(long historyId, int n) {
+ return getNItemsBefore(historyId, n, LocalDateTime.now(ZoneOffset.UTC));
+ }
+}
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..a996349
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java
@@ -0,0 +1,21 @@
+package com.andrewlalis.perfin.data;
+
+import com.andrewlalis.perfin.model.TransactionCategory;
+import javafx.scene.paint.Color;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface TransactionCategoryRepository extends Repository, AutoCloseable {
+ Optional 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 update(long id, String name, Color color);
+ void deleteById(long id);
+
+ record CategoryTreeNode(TransactionCategory category, List children){}
+ List findTree();
+}
diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java
index 08003cd..e5d845c 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,10 @@ 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 deleteTag(String name);
+ long countTagUsages(String name);
void delete(long transactionId);
void update(
long id,
@@ -39,6 +46,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..93dd5cd
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java
@@ -0,0 +1,16 @@
+package com.andrewlalis.perfin.data;
+
+import com.andrewlalis.perfin.model.TransactionVendor;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface TransactionVendorRepository extends Repository, AutoCloseable {
+ Optional findById(long id);
+ Optional findByName(String name);
+ List findAll();
+ long insert(String name, String description);
+ long insert(String name);
+ void update(long id, String name, String description);
+ void deleteById(long id);
+}
diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java
index 18fdd6f..0ec481b 100644
--- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java
@@ -1,7 +1,7 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository;
-import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
+import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.AccountEntry;
@@ -31,8 +31,9 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
)
);
// Insert an entry into the account's history.
- AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
- historyRepo.recordAccountEntry(timestamp, accountId, entryId);
+ HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
+ long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
+ historyRepo.addTextItem(historyId, timestamp, "Entry #" + entryId + " added as a " + type.name() + " from Transaction #" + transactionId + ".");
return entryId;
}
diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java
deleted file mode 100644
index eda1a7c..0000000
--- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java
+++ /dev/null
@@ -1,120 +0,0 @@
-package com.andrewlalis.perfin.data.impl;
-
-import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
-import com.andrewlalis.perfin.data.util.DbUtil;
-import com.andrewlalis.perfin.model.AccountEntry;
-import com.andrewlalis.perfin.model.BalanceRecord;
-import com.andrewlalis.perfin.model.history.AccountHistoryItem;
-import com.andrewlalis.perfin.model.history.AccountHistoryItemType;
-
-import java.sql.Connection;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.time.LocalDateTime;
-import java.util.List;
-
-public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository {
- @Override
- public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) {
- long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY);
- DbUtil.insertOne(
- conn,
- "INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)",
- List.of(itemId, entryId)
- );
- }
-
- @Override
- public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) {
- long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD);
- DbUtil.insertOne(
- conn,
- "INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)",
- List.of(itemId, recordId)
- );
- }
-
- @Override
- public void recordText(LocalDateTime timestamp, long accountId, String text) {
- long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT);
- DbUtil.insertOne(
- conn,
- "INSERT INTO account_history_item_text (item_id, description) VALUES (?, ?)",
- List.of(itemId, text)
- );
- }
-
- @Override
- public List findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count) {
- return DbUtil.findAll(
- conn,
- "SELECT * FROM account_history_item WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT " + count,
- List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
- JdbcAccountHistoryItemRepository::parseHistoryItem
- );
- }
-
- @Override
- public String getTextItem(long itemId) {
- return DbUtil.findOne(
- conn,
- "SELECT description FROM account_history_item_text WHERE item_id = ?",
- List.of(itemId),
- rs -> rs.getString(1)
- ).orElse(null);
- }
-
- @Override
- public AccountEntry getAccountEntryItem(long itemId) {
- return DbUtil.findOne(
- conn,
- """
- SELECT *
- FROM account_entry
- LEFT JOIN account_history_item_account_entry h ON h.entry_id = account_entry.id
- WHERE h.item_id = ?""",
- List.of(itemId),
- JdbcAccountEntryRepository::parse
- ).orElse(null);
- }
-
- @Override
- public BalanceRecord getBalanceRecordItem(long itemId) {
- return DbUtil.findOne(
- conn,
- """
- SELECT *
- FROM balance_record
- LEFT JOIN account_history_item_balance_record h ON h.record_id = balance_record.id
- WHERE h.item_id = ?""",
- List.of(itemId),
- JdbcBalanceRecordRepository::parse
- ).orElse(null);
- }
-
- @Override
- public void close() throws Exception {
- conn.close();
- }
-
- public static AccountHistoryItem parseHistoryItem(ResultSet rs) throws SQLException {
- return new AccountHistoryItem(
- rs.getLong("id"),
- DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
- rs.getLong("account_id"),
- AccountHistoryItemType.valueOf(rs.getString("type"))
- );
- }
-
- private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
- return DbUtil.insertOne(
- conn,
- "INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)",
- List.of(
- DbUtil.timestampFromUtcLDT(timestamp),
- accountId,
- type.name()
- )
- );
- }
-}
diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java
index 8a36051..73faf1d 100644
--- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java
@@ -1,12 +1,8 @@
package com.andrewlalis.perfin.data.impl;
-import com.andrewlalis.perfin.data.AccountEntryRepository;
-import com.andrewlalis.perfin.data.AccountRepository;
-import com.andrewlalis.perfin.data.BalanceRecordRepository;
-import com.andrewlalis.perfin.data.EntityNotFoundException;
+import com.andrewlalis.perfin.data.*;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
-import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry;
@@ -43,8 +39,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
)
);
// Insert a history item indicating the creation of the account.
- var historyRepo = new JdbcAccountHistoryItemRepository(conn);
- historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile.");
+ HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
+ long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
+ historyRepo.addTextItem(historyId, "Account added to your Perfin profile.");
return accountId;
});
}
@@ -59,11 +56,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
return DbUtil.findAll(
conn,
"""
- SELECT DISTINCT ON (account.id) account.*, ahi.timestamp AS _
+ SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
FROM account
- LEFT OUTER JOIN account_history_item ahi ON ahi.account_id = account.id
+ LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
+ LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
WHERE NOT account.archived
- ORDER BY ahi.timestamp DESC, account.created_at DESC""",
+ ORDER BY hi.timestamp DESC, account.created_at DESC""",
JdbcAccountRepository::parseAccount
);
}
@@ -160,7 +158,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
public void archive(long accountId) {
DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId));
- new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been archived.");
+ HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
+ long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
+ historyRepo.addTextItem(historyId, "Account has been archived.");
});
}
@@ -168,7 +168,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
public void unarchive(long accountId) {
DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId));
- new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been unarchived.");
+ HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
+ long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
+ historyRepo.addTextItem(historyId, "Account has been unarchived.");
});
}
diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java
index 44f42d2..da34639 100644
--- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java
@@ -1,11 +1,13 @@
package com.andrewlalis.perfin.data.impl;
-import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository;
+import com.andrewlalis.perfin.data.HistoryRepository;
+import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.BalanceRecord;
+import com.andrewlalis.perfin.model.MoneyValue;
import java.math.BigDecimal;
import java.nio.file.Path;
@@ -36,8 +38,9 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
}
}
// Add a history item entry.
- AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
- historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId);
+ HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
+ long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
+ historyRepo.addTextItem(historyId, utcTimestamp, "Balance Record #" + recordId + " added with a value of " + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(balance, currency)));
return recordId;
});
}
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..9f7b172 100644
--- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java
@@ -49,13 +49,23 @@ 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);
}
@Override
- public AccountHistoryItemRepository getAccountHistoryItemRepository() {
- return new JdbcAccountHistoryItemRepository(getConnection());
+ public HistoryRepository getHistoryRepository() {
+ return new JdbcHistoryRepository(getConnection());
}
}
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..bdad590 100644
--- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java
@@ -1,11 +1,16 @@
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;
+import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Profile;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -14,16 +19,14 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.sql.Connection;
-import java.sql.SQLException;
-import java.sql.Statement;
+import java.sql.*;
import java.util.Arrays;
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);
/**
@@ -32,7 +35,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 = 3;
public DataSource getDataSource(String profileName) throws ProfileLoadException {
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
@@ -59,6 +62,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));
@@ -69,6 +79,7 @@ public class JdbcDataSourceFactory {
if (in == null) throw new IOException("Could not load database schema SQL file.");
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
executeSqlScript(schemaStr, conn);
+ insertDefaultData(conn);
try {
writeCurrentSchemaVersion(profileName);
} catch (IOException e) {
@@ -89,6 +100,53 @@ public class JdbcDataSourceFactory {
}
}
+ /**
+ * Inserts all default data into the database, using static content found in
+ * various locations on the classpath.
+ * @param conn The connection to use to insert data.
+ * @throws IOException If resources couldn't be read.
+ * @throws SQLException If SQL fails.
+ */
+ public void insertDefaultData(Connection conn) throws IOException, SQLException {
+ insertDefaultCategories(conn);
+ }
+
+ public void insertDefaultCategories(Connection conn) throws IOException, SQLException {
+ try (
+ var categoriesIn = JdbcDataSourceFactory.class.getResourceAsStream("/sql/data/default-categories.json");
+ var stmt = conn.prepareStatement(
+ "INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
+ Statement.RETURN_GENERATED_KEYS
+ )
+ ) {
+ if (categoriesIn == null) throw new IOException("Couldn't load default categories file.");
+ ObjectMapper mapper = new ObjectMapper();
+ ArrayNode categoriesArray = mapper.readValue(categoriesIn, ArrayNode.class);
+ insertCategoriesRecursive(stmt, categoriesArray, null, "#FFFFFF");
+ }
+ }
+
+ private void insertCategoriesRecursive(PreparedStatement stmt, ArrayNode categoriesArray, Long parentId, String parentColorHex) throws SQLException {
+ for (JsonNode obj : categoriesArray) {
+ String name = obj.get("name").asText();
+ String colorHex = parentColorHex;
+ if (obj.hasNonNull("color")) colorHex = obj.get("color").asText(parentColorHex);
+ if (parentId == null) {
+ stmt.setNull(1, Types.BIGINT);
+ } else {
+ stmt.setLong(1, parentId);
+ }
+ stmt.setString(2, name);
+ stmt.setString(3, colorHex.substring(1));
+ int result = stmt.executeUpdate();
+ if (result != 1) throw new SQLException("Failed to insert category.");
+ long id = DbUtil.getGeneratedId(stmt);
+ if (obj.hasNonNull("children") && obj.get("children").isArray()) {
+ insertCategoriesRecursive(stmt, obj.withArray("children"), id, colorHex);
+ }
+ }
+ }
+
private boolean testConnection(JdbcDataSource dataSource) {
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
return stmt.execute("SELECT 1;");
@@ -168,7 +226,7 @@ public class JdbcDataSourceFactory {
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/JdbcHistoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java
new file mode 100644
index 0000000..5626d38
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java
@@ -0,0 +1,125 @@
+package com.andrewlalis.perfin.data.impl;
+
+import com.andrewlalis.perfin.data.HistoryRepository;
+import com.andrewlalis.perfin.data.pagination.Page;
+import com.andrewlalis.perfin.data.pagination.PageRequest;
+import com.andrewlalis.perfin.data.util.DateUtil;
+import com.andrewlalis.perfin.data.util.DbUtil;
+import com.andrewlalis.perfin.model.history.HistoryItem;
+import com.andrewlalis.perfin.model.history.HistoryTextItem;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.LocalDateTime;
+import java.util.List;
+
+public record JdbcHistoryRepository(Connection conn) implements HistoryRepository {
+ @Override
+ public long getOrCreateHistoryForAccount(long accountId) {
+ return getOrCreateHistoryForEntity(accountId, "history_account", "account_id");
+ }
+
+ @Override
+ public long getOrCreateHistoryForTransaction(long transactionId) {
+ return getOrCreateHistoryForEntity(transactionId, "history_transaction", "transaction_id");
+ }
+
+ private long getOrCreateHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
+ String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
+ var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
+ if (optionalHistoryId.isPresent()) return optionalHistoryId.get();
+ long historyId = DbUtil.insertOne(conn, "INSERT INTO history () VALUES ()");
+ String insertQuery = "INSERT INTO " + joinTableName + " (" + joinColumn + ", history_id) VALUES (?, ?)";
+ DbUtil.updateOne(conn, insertQuery, entityId, historyId);
+ return historyId;
+ }
+
+ @Override
+ public void deleteHistoryForAccount(long accountId) {
+ deleteHistoryForEntity(accountId, "history_account", "account_id");
+ }
+
+ @Override
+ public void deleteHistoryForTransaction(long transactionId) {
+ deleteHistoryForEntity(transactionId, "history_transaction", "transaction_id");
+ }
+
+ private void deleteHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
+ String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
+ var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
+ if (optionalHistoryId.isPresent()) {
+ long historyId = optionalHistoryId.get();
+ DbUtil.updateOne(conn, "DELETE FROM history WHERE id = ?", historyId);
+ }
+ }
+
+ @Override
+ public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) {
+ long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.TYPE_TEXT);
+ DbUtil.updateOne(
+ conn,
+ "INSERT INTO history_item_text (id, description) VALUES (?, ?)",
+ itemId,
+ description
+ );
+ return new HistoryTextItem(itemId, historyId, utcTimestamp, description);
+ }
+
+ private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) {
+ return DbUtil.insertOne(
+ conn,
+ "INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)",
+ historyId,
+ DbUtil.timestampFromUtcLDT(timestamp),
+ type
+ );
+ }
+
+ @Override
+ public Page getItems(long historyId, PageRequest pagination) {
+ return DbUtil.findAll(
+ conn,
+ "SELECT * FROM history_item WHERE history_id = ?",
+ pagination,
+ List.of(historyId),
+ JdbcHistoryRepository::parseItem
+ );
+ }
+
+ @Override
+ public List getNItemsBefore(long historyId, int n, LocalDateTime timestamp) {
+ return DbUtil.findAll(
+ conn,
+ """
+ SELECT *
+ FROM history_item
+ WHERE history_id = ? AND timestamp <= ?
+ ORDER BY timestamp DESC""",
+ List.of(historyId, DbUtil.timestampFromUtcLDT(timestamp)),
+ JdbcHistoryRepository::parseItem
+ );
+ }
+
+ @Override
+ public void close() throws Exception {
+ conn.close();
+ }
+
+ public static HistoryItem parseItem(ResultSet rs) throws SQLException {
+ long id = rs.getLong(1);
+ long historyId = rs.getLong(2);
+ LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3));
+ String type = rs.getString(4);
+ if (type.equalsIgnoreCase(HistoryItem.TYPE_TEXT)) {
+ String description = DbUtil.findOne(
+ rs.getStatement().getConnection(),
+ "SELECT description FROM history_item_text WHERE id = ?",
+ List.of(id),
+ r -> r.getString(1)
+ ).orElseThrow();
+ return new HistoryTextItem(id, historyId, timestamp, description);
+ }
+ throw new SQLException("Unknown history item type: " + type);
+ }
+}
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..7dcd072
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java
@@ -0,0 +1,142 @@
+package com.andrewlalis.perfin.data.impl;
+
+import com.andrewlalis.perfin.data.TransactionCategoryRepository;
+import com.andrewlalis.perfin.data.util.ColorUtil;
+import com.andrewlalis.perfin.data.util.DbUtil;
+import com.andrewlalis.perfin.model.TransactionCategory;
+import javafx.scene.paint.Color;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository {
+ @Override
+ public Optional 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 update(long id, String name, Color color) {
+ DbUtil.doTransaction(conn, () -> {
+ TransactionCategory category = findById(id).orElseThrow();
+ if (!category.getName().equals(name)) {
+ DbUtil.updateOne(
+ conn,
+ "UPDATE transaction_category SET name = ? WHERE id = ?",
+ name,
+ id
+ );
+ }
+ if (!category.getColor().equals(color)) {
+ DbUtil.updateOne(
+ conn,
+ "UPDATE transaction_category SET color = ? WHERE id = ?",
+ ColorUtil.toHex(color),
+ id
+ );
+ }
+ });
+ }
+
+ @Override
+ public void deleteById(long id) {
+ DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
+ }
+
+ @Override
+ public List findTree() {
+ List rootCategories = DbUtil.findAll(
+ conn,
+ "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
+ JdbcTransactionCategoryRepository::parseCategory
+ );
+ List rootNodes = new ArrayList<>(rootCategories.size());
+ for (var category : rootCategories) {
+ rootNodes.add(findTreeRecursive(category));
+ }
+ return rootNodes;
+ }
+
+ private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
+ CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
+ List childCategories = DbUtil.findAll(
+ conn,
+ "SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
+ List.of(root.id),
+ JdbcTransactionCategoryRepository::parseCategory
+ );
+ for (var childCategory : childCategories) {
+ node.children().add(findTreeRecursive(childCategory));
+ }
+ return node;
+ }
+
+ @Override
+ public void close() throws Exception {
+ conn.close();
+ }
+
+ public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
+ return new TransactionCategory(
+ rs.getLong("id"),
+ rs.getObject("parent_id", Long.class),
+ rs.getString("name"),
+ Color.valueOf("#" + rs.getString("color"))
+ );
+ }
+}
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..edd8bb1 100644
--- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java
@@ -2,20 +2,21 @@ package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AttachmentRepository;
+import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.pagination.Page;
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 +29,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 +223,51 @@ 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 deleteTag(String name) {
+ DbUtil.update(
+ conn,
+ "DELETE FROM transaction_tag WHERE name = ?",
+ name
+ );
+ }
+
+ @Override
+ public long countTagUsages(String name) {
+ return DbUtil.count(
+ conn,
+ """
+ SELECT COUNT(transaction_id)
+ FROM transaction_tag_join
+ WHERE tag_id = (SELECT id FROM transaction_tag WHERE name = ?)""",
+ name
+ );
+ }
+
@Override
public void delete(long transactionId) {
DbUtil.doTransaction(conn, () -> {
@@ -164,44 +285,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,10 +384,12 @@ 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));
- linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
+ HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
+ long historyId = historyRepo.getOrCreateHistoryForTransaction(id);
+ historyRepo.addTextItem(historyId, updateMessageStr);
});
}
@@ -226,16 +398,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 +405,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..4b9c388
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java
@@ -0,0 +1,102 @@
+package com.andrewlalis.perfin.data.impl;
+
+import com.andrewlalis.perfin.data.TransactionVendorRepository;
+import com.andrewlalis.perfin.data.util.DbUtil;
+import com.andrewlalis.perfin.model.TransactionVendor;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository {
+ @Override
+ public Optional 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 update(long id, String name, String description) {
+ DbUtil.doTransaction(conn, () -> {
+ TransactionVendor vendor = findById(id).orElseThrow();
+ if (!vendor.getName().equals(name)) {
+ DbUtil.updateOne(
+ conn,
+ "UPDATE transaction_vendor SET name = ? WHERE id = ?",
+ name,
+ id
+ );
+ }
+ if (!Objects.equals(vendor.getDescription(), description)) {
+ DbUtil.updateOne(
+ conn,
+ "UPDATE transaction_vendor SET description = ? WHERE id = ?",
+ description,
+ id
+ );
+ }
+ });
+ }
+
+ @Override
+ public void deleteById(long id) {
+ DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id));
+ }
+
+ @Override
+ public void close() throws Exception {
+ conn.close();
+ }
+
+ public static TransactionVendor parseVendor(ResultSet rs) throws SQLException {
+ return new TransactionVendor(
+ rs.getLong("id"),
+ rs.getString("name"),
+ rs.getString("description")
+ );
+ }
+}
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..3893fae 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,20 @@ 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"));
+ migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
return migrations;
}
@@ -25,4 +35,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/data/search/EntitySearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/EntitySearcher.java
new file mode 100644
index 0000000..ebc61c3
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/search/EntitySearcher.java
@@ -0,0 +1,28 @@
+package com.andrewlalis.perfin.data.search;
+
+import com.andrewlalis.perfin.data.pagination.Page;
+import com.andrewlalis.perfin.data.pagination.PageRequest;
+
+import java.util.List;
+
+/**
+ * An entity searcher will search for entities matching a list of filters.
+ * @param The entity type to search over.
+ */
+public interface EntitySearcher {
+ /**
+ * Gets a page of results that match the given filters.
+ * @param pageRequest The page request.
+ * @param filters The filters to apply.
+ * @return A page of results.
+ */
+ Page search(PageRequest pageRequest, List filters);
+
+ /**
+ * Gets the number of results that would be returned for a given set of
+ * filters.
+ * @param filters The filters to apply.
+ * @return The number of entities that match.
+ */
+ long resultCount(List filters);
+}
diff --git a/src/main/java/com/andrewlalis/perfin/data/search/JdbcEntitySearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/JdbcEntitySearcher.java
new file mode 100644
index 0000000..7e2d46f
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/search/JdbcEntitySearcher.java
@@ -0,0 +1,118 @@
+package com.andrewlalis.perfin.data.search;
+
+import com.andrewlalis.perfin.data.pagination.Page;
+import com.andrewlalis.perfin.data.pagination.PageRequest;
+import com.andrewlalis.perfin.data.util.Pair;
+import com.andrewlalis.perfin.data.util.ResultSetMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class JdbcEntitySearcher implements EntitySearcher {
+ private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class);
+
+ private final Connection conn;
+ private final String countExpression;
+ private final String selectExpression;
+ private final ResultSetMapper resultSetMapper;
+
+ public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper resultSetMapper) {
+ this.conn = conn;
+ this.countExpression = countExpression;
+ this.selectExpression = selectExpression;
+ this.resultSetMapper = resultSetMapper;
+ }
+
+ private Pair>> buildSearchQuery(List filters) {
+ if (filters.isEmpty()) return new Pair<>("", Collections.emptyList());
+ StringBuilder sb = new StringBuilder();
+ List> args = new ArrayList<>();
+ for (var filter : filters) {
+ args.addAll(filter.args());
+ for (var joinClause : filter.joinClauses()) {
+ sb.append(joinClause).append('\n');
+ }
+ }
+ sb.append("WHERE\n");
+ for (int i = 0; i < filters.size(); i++) {
+ sb.append(filters.get(i).whereClause());
+ if (i < filters.size() - 1) {
+ sb.append(" AND");
+ }
+ sb.append('\n');
+ }
+ return new Pair<>(sb.toString(), args);
+ }
+
+ private void applyArgs(PreparedStatement stmt, List> args) throws SQLException {
+ for (int i = 1; i <= args.size(); i++) {
+ Pair arg = args.get(i - 1);
+ if (arg.second() == null) {
+ stmt.setNull(i, arg.first());
+ } else {
+ stmt.setObject(i, arg.second(), arg.first());
+ }
+ }
+ }
+
+ @Override
+ public Page search(PageRequest pageRequest, List filters) {
+ var baseQueryAndArgs = buildSearchQuery(filters);
+ StringBuilder sqlBuilder = new StringBuilder(selectExpression);
+ if (baseQueryAndArgs.first() != null && !baseQueryAndArgs.first().isBlank()) {
+ sqlBuilder.append('\n').append(baseQueryAndArgs.first());
+ }
+ String pagingSql = pageRequest.toSQL();
+ if (pagingSql != null && !pagingSql.isBlank()) {
+ sqlBuilder.append('\n').append(pagingSql);
+ }
+ String sql = sqlBuilder.toString();
+ logger.debug(
+ "Searching with query:\n{}\nWith arguments: {}",
+ sql,
+ baseQueryAndArgs.second().stream()
+ .map(Pair::second)
+ .map(Object::toString)
+ .collect(Collectors.joining(", "))
+ );
+ try (var stmt = conn.prepareStatement(sql)) {
+ applyArgs(stmt, baseQueryAndArgs.second());
+ ResultSet rs = stmt.executeQuery();
+ List results = new ArrayList<>(pageRequest.size());
+ while (rs.next() && results.size() < pageRequest.size()) {
+ results.add(resultSetMapper.map(rs));
+ }
+ return new Page<>(results, pageRequest);
+ } catch (SQLException e) {
+ logger.error("Search failed.", e);
+ return new Page<>(Collections.emptyList(), pageRequest);
+ }
+ }
+
+ @Override
+ public long resultCount(List filters) {
+ var baseQueryAndArgs = buildSearchQuery(filters);
+ String sql = countExpression + "\n" + baseQueryAndArgs.first();
+ try (var stmt = conn.prepareStatement(sql)) {
+ applyArgs(stmt, baseQueryAndArgs.second());
+ ResultSet rs = stmt.executeQuery();
+ if (!rs.next()) throw new SQLException("No count result.");
+ return rs.getLong(1);
+ } catch (SQLException e) {
+ logger.error("Failed to get search result count.", e);
+ return 0L;
+ }
+ }
+
+ public static class Builder {
+
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java
new file mode 100644
index 0000000..8f5c111
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java
@@ -0,0 +1,35 @@
+package com.andrewlalis.perfin.data.search;
+
+import com.andrewlalis.perfin.data.util.DbUtil;
+import com.andrewlalis.perfin.model.Transaction;
+
+import java.math.BigDecimal;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.LocalDateTime;
+import java.util.Currency;
+
+public class JdbcTransactionSearcher extends JdbcEntitySearcher {
+ public JdbcTransactionSearcher(Connection conn) {
+ super(
+ conn,
+ "SELECT COUNT(transaction.id) FROM transaction",
+ "SELECT transaction.* FROM transaction",
+ JdbcTransactionSearcher::parseResultSet
+ );
+ }
+
+ private static Transaction parseResultSet(ResultSet rs) throws SQLException {
+ long id = rs.getLong(1);
+ LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2));
+ BigDecimal amount = rs.getBigDecimal(3);
+ Currency currency = Currency.getInstance(rs.getString(4));
+ String description = rs.getString(5);
+ Long vendorId = rs.getLong(6);
+ if (rs.wasNull()) vendorId = null;
+ Long categoryId = rs.getLong(7);
+ if (rs.wasNull()) categoryId = null;
+ return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/data/search/SearchFilter.java b/src/main/java/com/andrewlalis/perfin/data/search/SearchFilter.java
new file mode 100644
index 0000000..f69437a
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/data/search/SearchFilter.java
@@ -0,0 +1,61 @@
+package com.andrewlalis.perfin.data.search;
+
+import com.andrewlalis.perfin.data.util.DbUtil;
+import com.andrewlalis.perfin.data.util.Pair;
+
+import java.sql.Types;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public interface SearchFilter {
+ String whereClause();
+ List> args();
+ default List joinClauses() {
+ return Collections.emptyList();
+ }
+
+ record Impl(String whereClause, List> args, List joinClauses) implements SearchFilter {}
+
+ class Builder {
+ private String whereClause;
+ private List> args = new ArrayList<>();
+ private List joinClauses = new ArrayList<>();
+
+ public Builder where(String clause) {
+ this.whereClause = clause;
+ return this;
+ }
+
+ public Builder withArg(int sqlType, Object value) {
+ args.add(new Pair<>(sqlType, value));
+ return this;
+ }
+
+ public Builder withArg(int value) {
+ return withArg(Types.INTEGER, value);
+ }
+
+ public Builder withArg(long value) {
+ return withArg(Types.BIGINT, value);
+ }
+
+ public Builder withArg(String value) {
+ return withArg(Types.VARCHAR, value);
+ }
+
+ public Builder withArg(LocalDateTime utcTimestamp) {
+ return withArg(Types.TIMESTAMP, DbUtil.timestampFromUtcLDT(utcTimestamp));
+ }
+
+ public Builder withJoin(String joinClause) {
+ joinClauses.add(joinClause);
+ return this;
+ }
+
+ public SearchFilter build() {
+ return new Impl(whereClause, args, joinClauses);
+ }
+ }
+}
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..29ef062 100644
--- a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java
+++ b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java
@@ -31,6 +31,15 @@ public final class DbUtil {
setArgs(stmt, List.of(args));
}
+ public static long getGeneratedId(PreparedStatement stmt) {
+ try (ResultSet rs = stmt.getGeneratedKeys()) {
+ if (!rs.next()) throw new SQLException("No generated keys available.");
+ return rs.getLong(1);
+ } catch (SQLException e) {
+ throw new UncheckedSqlException(e);
+ }
+ }
+
public static List findAll(Connection conn, String query, List
+ *
+ * @param name The name of the profile.
+ * @param settings The profile's settings.
+ * @param dataSource The profile's data source.
*/
-public class Profile {
+public record Profile(String name, Properties settings, DataSource dataSource) {
private static final Logger log = LoggerFactory.getLogger(Profile.class);
private static 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);
}
@@ -78,89 +60,23 @@ 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);
+ log.debug("Current profile set to {}.", current.name());
+ }
+
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"));
- }
-
- private static void copyResourceFile(String resource, Path dest) throws IOException {
- try (
- var in = Profile.class.getResourceAsStream(resource);
- var out = Files.newOutputStream(dest)
- ) {
- if (in == null) throw new IOException("Could not load resource " + resource);
- in.transferTo(out);
}
+ currentProfileListeners.add(new WeakReference<>(consumer));
}
public static boolean validateName(String name) {
@@ -168,9 +84,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..b1f6a4e
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java
@@ -0,0 +1,116 @@
+package com.andrewlalis.perfin.model;
+
+import com.andrewlalis.perfin.PerfinApp;
+import com.andrewlalis.perfin.control.Popups;
+import com.andrewlalis.perfin.data.DataSourceFactory;
+import com.andrewlalis.perfin.data.ProfileLoadException;
+import com.andrewlalis.perfin.data.impl.migration.Migrations;
+import com.andrewlalis.perfin.data.util.FileUtil;
+import javafx.stage.Window;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+
+import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
+
+/**
+ * Component responsible for loading a profile from storage, as well as some
+ * other basic tasks concerning the set of stored profiles.
+ */
+public class ProfileLoader {
+ private static final Logger log = LoggerFactory.getLogger(ProfileLoader.class);
+
+ private final Window window;
+ private final DataSourceFactory dataSourceFactory;
+
+ public ProfileLoader(Window window, DataSourceFactory dataSourceFactory) {
+ this.window = window;
+ this.dataSourceFactory = dataSourceFactory;
+ }
+
+ public Profile load(String name) throws ProfileLoadException {
+ if (Files.notExists(Profile.getDir(name))) {
+ try {
+ initProfileDir(name);
+ } catch (IOException e) {
+ FileUtil.deleteIfPossible(Profile.getDir(name));
+ throw new ProfileLoadException("Failed to initialize new profile directory.", e);
+ }
+ }
+ Properties settings = new Properties();
+ try (var in = Files.newInputStream(Profile.getSettingsFile(name))) {
+ settings.load(in);
+ } catch (IOException e) {
+ throw new ProfileLoadException("Failed to load profile settings.", e);
+ }
+ try {
+ DataSourceFactory.SchemaStatus status = dataSourceFactory.getSchemaStatus(name);
+ if (status == DataSourceFactory.SchemaStatus.NEEDS_MIGRATION) {
+ boolean confirm = Popups.confirm(window, "The profile \"" + name + "\" has an outdated data schema and needs to be migrated to the latest version. Is this okay?");
+ if (!confirm) {
+ int existingSchemaVersion = dataSourceFactory.getSchemaVersion(name);
+ String compatibleVersion = Migrations.getLatestCompatibleVersion(existingSchemaVersion);
+ Popups.message(
+ window,
+ "The profile \"" + name + "\" is using schema version " + existingSchemaVersion + ", which is compatible with Perfin version " + compatibleVersion + ". Consider downgrading Perfin to access this profile safely."
+ );
+ throw new ProfileLoadException("User rejected the migration.");
+ }
+ } else if (status == DataSourceFactory.SchemaStatus.INCOMPATIBLE) {
+ Popups.error(window, "The profile \"" + name + "\" has a data schema that's incompatible with this app. Update Perfin to access this profile safely.");
+ throw new ProfileLoadException("Incompatible schema version.");
+ }
+ } catch (IOException e) {
+ throw new ProfileLoadException("Failed to get profile's schema status.", e);
+ }
+ return new Profile(name, settings, dataSourceFactory.getDataSource(name));
+ }
+
+ public static List 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/model/Transaction.java b/src/main/java/com/andrewlalis/perfin/model/Transaction.java
index 8f179ca..92588d8 100644
--- a/src/main/java/com/andrewlalis/perfin/model/Transaction.java
+++ b/src/main/java/com/andrewlalis/perfin/model/Transaction.java
@@ -17,13 +17,17 @@ public class Transaction extends IdEntity {
private final BigDecimal amount;
private final Currency currency;
private final String description;
+ private final Long vendorId;
+ private final Long categoryId;
- public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
+ public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) {
super(id);
this.timestamp = timestamp;
this.amount = amount;
this.currency = currency;
this.description = description;
+ this.vendorId = vendorId;
+ this.categoryId = categoryId;
}
public LocalDateTime getTimestamp() {
@@ -42,6 +46,14 @@ public class Transaction extends IdEntity {
return description;
}
+ public Long getVendorId() {
+ return vendorId;
+ }
+
+ public Long getCategoryId() {
+ return categoryId;
+ }
+
public MoneyValue getMoneyAmount() {
return new MoneyValue(amount, currency);
}
diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java b/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java
new file mode 100644
index 0000000..e18d86f
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java
@@ -0,0 +1,35 @@
+package com.andrewlalis.perfin.model;
+
+import javafx.scene.paint.Color;
+
+public class TransactionCategory extends IdEntity {
+ public static final int NAME_MAX_LENGTH = 63;
+
+ private final Long parentId;
+ private final String name;
+ private final Color color;
+
+ public TransactionCategory(long id, Long parentId, String name, Color color) {
+ super(id);
+ this.parentId = parentId;
+ this.name = name;
+ this.color = color;
+ }
+
+ public Long getParentId() {
+ return parentId;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Color getColor() {
+ return color;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java
new file mode 100644
index 0000000..fa58745
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java
@@ -0,0 +1,65 @@
+package com.andrewlalis.perfin.model;
+
+import java.math.BigDecimal;
+
+/**
+ * A line item that comprises part of a transaction. Its total value (value per
+ * item * quantity) is part of the transaction's total value. It can be used to
+ * record some transactions, like purchases and invoices, in more granular
+ * detail.
+ */
+public class TransactionLineItem extends IdEntity {
+ public static final int DESCRIPTION_MAX_LENGTH = 255;
+
+ private final long transactionId;
+ private final BigDecimal valuePerItem;
+ private final int quantity;
+ private final int idx;
+ private final String description;
+
+ public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description) {
+ super(id);
+ this.transactionId = transactionId;
+ this.valuePerItem = valuePerItem;
+ this.quantity = quantity;
+ this.idx = idx;
+ this.description = description;
+ }
+
+ public long getTransactionId() {
+ return transactionId;
+ }
+
+ public BigDecimal getValuePerItem() {
+ return valuePerItem;
+ }
+
+ public int getQuantity() {
+ return quantity;
+ }
+
+ public int getIdx() {
+ return idx;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public BigDecimal getTotalValue() {
+ return valuePerItem.multiply(new BigDecimal(quantity));
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "TransactionLineItem(id=%d, transactionId=%d, valuePerItem=%s, quantity=%d, idx=%d, description=\"%s\")",
+ id,
+ transactionId,
+ valuePerItem.toPlainString(),
+ quantity,
+ idx,
+ description
+ );
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java
new file mode 100644
index 0000000..2dfd29f
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java
@@ -0,0 +1,19 @@
+package com.andrewlalis.perfin.model;
+
+/**
+ * A tag that can be applied to a transaction to add some user-defined semantic
+ * meaning to it.
+ */
+public class TransactionTag extends IdEntity {
+ public static final int NAME_MAX_LENGTH = 63;
+ private final String name;
+
+ public TransactionTag(long id, String name) {
+ super(id);
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java b/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java
new file mode 100644
index 0000000..af4130b
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java
@@ -0,0 +1,32 @@
+package com.andrewlalis.perfin.model;
+
+/**
+ * A vendor is a business establishment that can be linked to a transaction, to
+ * denote the business that the transaction took place with.
+ */
+public class TransactionVendor extends IdEntity {
+ public static final int NAME_MAX_LENGTH = 255;
+ public static final int DESCRIPTION_MAX_LENGTH = 255;
+
+ private final String name;
+ private final String description;
+
+ public TransactionVendor(long id, String name, String description) {
+ super(id);
+ this.name = name;
+ this.description = description;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java
deleted file mode 100644
index 565452e..0000000
--- a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.andrewlalis.perfin.model.history;
-
-import com.andrewlalis.perfin.model.IdEntity;
-
-import java.time.LocalDateTime;
-
-/**
- * The base class representing account history items, a read-only record of an
- * account's data and changes over time. The type of history item determines
- * what exactly it means, and could be something like an account entry, balance
- * record, or modifications to the account's properties.
- */
-public class AccountHistoryItem extends IdEntity {
- private final LocalDateTime timestamp;
- private final long accountId;
- private final AccountHistoryItemType type;
-
- public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
- super(id);
- this.timestamp = timestamp;
- this.accountId = accountId;
- this.type = type;
- }
-
- public LocalDateTime getTimestamp() {
- return timestamp;
- }
-
- public long getAccountId() {
- return accountId;
- }
-
- public AccountHistoryItemType getType() {
- return type;
- }
-}
diff --git a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java
deleted file mode 100644
index eeeac1d..0000000
--- a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.andrewlalis.perfin.model.history;
-
-public enum AccountHistoryItemType {
- TEXT,
- ACCOUNT_ENTRY,
- BALANCE_RECORD
-}
diff --git a/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java b/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java
new file mode 100644
index 0000000..7e0d7b7
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java
@@ -0,0 +1,36 @@
+package com.andrewlalis.perfin.model.history;
+
+import com.andrewlalis.perfin.model.IdEntity;
+
+import java.time.LocalDateTime;
+
+/**
+ * Represents a single polymorphic history item. The item's "type" attribute
+ * tells where to find additional type-specific data.
+ */
+public abstract class HistoryItem extends IdEntity {
+ public static final String TYPE_TEXT = "TEXT";
+
+ private final long historyId;
+ private final LocalDateTime timestamp;
+ private final String type;
+
+ public HistoryItem(long id, long historyId, LocalDateTime timestamp, String type) {
+ super(id);
+ this.historyId = historyId;
+ this.timestamp = timestamp;
+ this.type = type;
+ }
+
+ public long getHistoryId() {
+ return historyId;
+ }
+
+ public LocalDateTime getTimestamp() {
+ return timestamp;
+ }
+
+ public String getType() {
+ return type;
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java b/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java
new file mode 100644
index 0000000..d325286
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java
@@ -0,0 +1,16 @@
+package com.andrewlalis.perfin.model.history;
+
+import java.time.LocalDateTime;
+
+public class HistoryTextItem extends HistoryItem {
+ private final String description;
+
+ public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) {
+ super(id, historyId, timestamp, HistoryItem.TYPE_TEXT);
+ this.description = description;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java b/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java
index 6a233c5..f3d6389 100644
--- a/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java
+++ b/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java
@@ -1,8 +1,10 @@
package com.andrewlalis.perfin.view;
import javafx.beans.WeakListener;
+import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
+import javafx.scene.Node;
import java.lang.ref.WeakReference;
import java.util.List;
@@ -86,4 +88,9 @@ public class BindingUtil {
return false;
}
}
+
+ public static void bindManagedAndVisible(Node node, ObservableValue extends Boolean> value) {
+ node.managedProperty().bind(node.visibleProperty());
+ node.visibleProperty().bind(value);
+ }
}
diff --git a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java
index 526951d..e78fb82 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;
/**
@@ -17,12 +18,14 @@ import java.util.function.Consumer;
*/
public class StartupSplashScreen extends Stage implements Consumer {
private final List>> tasks;
+ private final boolean delayTasks;
private boolean startupSuccessful = false;
private final TextArea textArea = new TextArea();
- public StartupSplashScreen(List>> tasks) {
+ public StartupSplashScreen(List>> tasks, boolean delayTasks) {
this.tasks = tasks;
+ this.delayTasks = delayTasks;
setTitle("Starting Perfin...");
setResizable(false);
initStyle(StageStyle.UNDECORATED);
@@ -60,37 +63,50 @@ 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 {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
+ if (delayTasks) sleepOrThrowRE(1000);
for (var task : tasks) {
try {
- task.accept(this);
- Thread.sleep(500);
+ CompletableFuture future = new CompletableFuture<>();
+ Platform.runLater(() -> {
+ try {
+ task.accept(this);
+ future.complete(null);
+ } catch (Exception e) {
+ future.completeExceptionally(e);
+ }
+ });
+ future.join();
+ if (delayTasks) sleepOrThrowRE(500);
} catch (Exception e) {
accept("Startup failed: " + e.getMessage());
e.printStackTrace(System.err);
- try {
- Thread.sleep(5000);
- } catch (InterruptedException ex) {
- throw new RuntimeException(ex);
- }
+ sleepOrThrowRE(5000);
Platform.runLater(this::close);
return;
}
}
accept("Startup successful!");
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
+ if (delayTasks) sleepOrThrowRE(1000);
startupSuccessful = true;
Platform.runLater(this::close);
});
}
+
+ /**
+ * Helper method to sleep the current thread or throw a runtime exception.
+ * @param ms The number of milliseconds to sleep for.
+ */
+ private static void sleepOrThrowRE(long ms) {
+ try {
+ Thread.sleep(ms);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
}
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java
deleted file mode 100644
index d78d42c..0000000
--- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.andrewlalis.perfin.view.component;
-
-import com.andrewlalis.perfin.control.TransactionsViewController;
-import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
-import com.andrewlalis.perfin.data.util.CurrencyUtil;
-import com.andrewlalis.perfin.model.AccountEntry;
-import com.andrewlalis.perfin.model.history.AccountHistoryItem;
-import javafx.scene.control.Hyperlink;
-import javafx.scene.text.Text;
-import javafx.scene.text.TextFlow;
-
-import static com.andrewlalis.perfin.PerfinApp.router;
-
-public class AccountHistoryAccountEntryTile extends AccountHistoryItemTile {
- public AccountHistoryAccountEntryTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
- super(item);
- AccountEntry entry = repo.getAccountEntryItem(item.id);
- if (entry == null) {
- setCenter(new TextFlow(new Text("Deleted account entry because of deleted transaction.")));
- return;
- }
-
- Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue()));
- Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId());
- transactionLink.setOnAction(event -> router.navigate(
- "transactions",
- new TransactionsViewController.RouteContext(entry.getTransactionId())
- ));
- var text = new TextFlow(
- transactionLink,
- new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of "),
- amountText
- );
- setCenter(text);
- }
-}
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java
deleted file mode 100644
index b263498..0000000
--- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.andrewlalis.perfin.view.component;
-
-import com.andrewlalis.perfin.control.AccountViewController;
-import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
-import com.andrewlalis.perfin.data.util.CurrencyUtil;
-import com.andrewlalis.perfin.model.BalanceRecord;
-import com.andrewlalis.perfin.model.history.AccountHistoryItem;
-import javafx.scene.control.Hyperlink;
-import javafx.scene.text.Text;
-import javafx.scene.text.TextFlow;
-
-import static com.andrewlalis.perfin.PerfinApp.router;
-
-public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile {
- public AccountHistoryBalanceRecordTile(AccountHistoryItem item, AccountHistoryItemRepository repo, AccountViewController controller) {
- super(item);
- BalanceRecord balanceRecord = repo.getBalanceRecordItem(item.id);
- if (balanceRecord == null) {
- setCenter(new TextFlow(new Text("Deleted balance record was added.")));
- return;
- }
-
- Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount()));
- var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText);
- setCenter(text);
-
- Hyperlink viewLink = new Hyperlink("View this balance record");
- viewLink.setOnAction(event -> router.navigate("balance-record", balanceRecord));
- setBottom(viewLink);
- }
-}
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java
index 9704d04..853bb7b 100644
--- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java
+++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java
@@ -1,9 +1,8 @@
package com.andrewlalis.perfin.view.component;
-import com.andrewlalis.perfin.control.AccountViewController;
-import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.DateUtil;
-import com.andrewlalis.perfin.model.history.AccountHistoryItem;
+import com.andrewlalis.perfin.model.history.HistoryItem;
+import com.andrewlalis.perfin.model.history.HistoryTextItem;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
@@ -11,7 +10,7 @@ import javafx.scene.layout.BorderPane;
* A tile that shows a brief bit of information about an account history item.
*/
public abstract class AccountHistoryItemTile extends BorderPane {
- public AccountHistoryItemTile(AccountHistoryItem item) {
+ public AccountHistoryItemTile(HistoryItem item) {
getStyleClass().add("tile");
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp()));
@@ -20,14 +19,11 @@ public abstract class AccountHistoryItemTile extends BorderPane {
}
public static AccountHistoryItemTile forItem(
- AccountHistoryItem item,
- AccountHistoryItemRepository repo,
- AccountViewController controller
+ HistoryItem item
) {
- return switch (item.getType()) {
- case TEXT -> new AccountHistoryTextTile(item, repo);
- case ACCOUNT_ENTRY -> new AccountHistoryAccountEntryTile(item, repo);
- case BALANCE_RECORD -> new AccountHistoryBalanceRecordTile(item, repo, controller);
- };
+ if (item instanceof HistoryTextItem t) {
+ return new AccountHistoryTextTile(t);
+ }
+ throw new RuntimeException("Unsupported history item type: " + item.getType());
}
}
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java
index 22f6c7d..2c7b8a2 100644
--- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java
+++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java
@@ -1,14 +1,12 @@
package com.andrewlalis.perfin.view.component;
-import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
-import com.andrewlalis.perfin.model.history.AccountHistoryItem;
+import com.andrewlalis.perfin.model.history.HistoryTextItem;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
public class AccountHistoryTextTile extends AccountHistoryItemTile {
- public AccountHistoryTextTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
+ public AccountHistoryTextTile(HistoryTextItem item) {
super(item);
- String text = repo.getTextItem(item.id);
- setCenter(new TextFlow(new Text(text)));
+ setCenter(new TextFlow(new Text(item.getDescription())));
}
}
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..eacddea 100644
--- a/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java
+++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java
@@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox {
showBalanceProperty.set(value);
}
- private static class CellFactory implements Callback, ListCell> {
- private final BooleanProperty showBalanceProp;
-
- private CellFactory(BooleanProperty showBalanceProp) {
- this.showBalanceProp = showBalanceProp;
- }
-
+ /**
+ * A simple cell factory that just returns instances of {@link AccountListCell}.
+ * @param showBalanceProp Whether to show the account's balance.
+ */
+ private record CellFactory(BooleanProperty showBalanceProp) implements Callback, ListCell> {
@Override
public ListCell call(ListView param) {
return new AccountListCell(showBalanceProp);
}
}
+ /**
+ * A list cell implementation which shows an account's name, and optionally,
+ * its current derived balance underneath.
+ */
private static class AccountListCell extends ListCell {
private final BooleanProperty showBalanceProp;
private final Label nameLabel = new Label();
@@ -110,7 +112,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/CategorySelectionBox.java b/src/main/java/com/andrewlalis/perfin/view/component/CategorySelectionBox.java
new file mode 100644
index 0000000..3a60753
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/view/component/CategorySelectionBox.java
@@ -0,0 +1,81 @@
+package com.andrewlalis.perfin.view.component;
+
+import com.andrewlalis.perfin.data.TransactionCategoryRepository;
+import com.andrewlalis.perfin.model.TransactionCategory;
+import javafx.geometry.Insets;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListCell;
+import javafx.scene.layout.HBox;
+import javafx.scene.shape.Circle;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class CategorySelectionBox extends ComboBox {
+ private final Map categoryIndentationLevels = new HashMap<>();
+
+ public CategorySelectionBox() {
+ setCellFactory(view -> new CategoryListCell(categoryIndentationLevels));
+ setButtonCell(new CategoryListCell(null));
+ }
+
+ public void loadCategories(List treeNodes) {
+ categoryIndentationLevels.clear();
+ getItems().clear();
+ populateCategories(treeNodes, 0);
+ getItems().add(null);
+ }
+
+ private void populateCategories(
+ List treeNodes,
+ int depth
+ ) {
+ for (var node : treeNodes) {
+ getItems().add(node.category());
+ categoryIndentationLevels.put(node.category(), depth);
+ populateCategories(node.children(), depth + 1);
+ }
+ }
+
+ public void select(TransactionCategory category) {
+ setButtonCell(new CategoryListCell(null));
+ getSelectionModel().select(category);
+ }
+
+ private static class CategoryListCell extends ListCell {
+ private final Label nameLabel = new Label();
+ private final Circle colorIndicator = new Circle(8);
+ private final Map categoryIndentationLevels;
+
+ public CategoryListCell(Map categoryIndentationLevels) {
+ this.categoryIndentationLevels = categoryIndentationLevels;
+ nameLabel.getStyleClass().add("normal-color-text-fill");
+ colorIndicator.managedProperty().bind(colorIndicator.visibleProperty());
+ HBox container = new HBox(colorIndicator, nameLabel);
+ container.getStyleClass().add("std-spacing");
+ setGraphic(container);
+ }
+
+ @Override
+ protected void updateItem(TransactionCategory item, boolean empty) {
+ super.updateItem(item, empty);
+ if (item == null || empty) {
+ nameLabel.setText("None");
+ colorIndicator.setVisible(false);
+ return;
+ }
+
+ nameLabel.setText(item.getName());
+ if (categoryIndentationLevels != null) {
+ HBox.setMargin(
+ colorIndicator,
+ new Insets(0, 0, 0, 10 * categoryIndentationLevels.getOrDefault(item, 0))
+ );
+ }
+ colorIndicator.setVisible(true);
+ colorIndicator.setFill(item.getColor());
+ }
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/CategoryTile.java b/src/main/java/com/andrewlalis/perfin/view/component/CategoryTile.java
new file mode 100644
index 0000000..73f4f2f
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/view/component/CategoryTile.java
@@ -0,0 +1,65 @@
+package com.andrewlalis.perfin.view.component;
+
+import com.andrewlalis.perfin.control.EditCategoryController;
+import com.andrewlalis.perfin.control.Popups;
+import com.andrewlalis.perfin.data.TransactionCategoryRepository;
+import com.andrewlalis.perfin.model.Profile;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+import javafx.scene.shape.Circle;
+
+import static com.andrewlalis.perfin.PerfinApp.router;
+
+public class CategoryTile extends VBox {
+ public CategoryTile(
+ TransactionCategoryRepository.CategoryTreeNode treeNode,
+ Runnable categoriesRefresh
+ ) {
+ this.getStyleClass().addAll("tile", "spacing-extra", "hand-cursor");
+ this.setStyle("-fx-border-width: 1px; -fx-border-color: grey;");
+ this.setOnMouseClicked(event -> {
+ event.consume();
+ router.navigate(
+ "edit-category",
+ new EditCategoryController.CategoryRouteContext(treeNode.category())
+ );
+ });
+
+ BorderPane borderPane = new BorderPane();
+ borderPane.getStyleClass().addAll("std-padding");
+ Label nameLabel = new Label(treeNode.category().getName());
+ nameLabel.getStyleClass().addAll("bold-text");
+ Circle colorCircle = new Circle(10, treeNode.category().getColor());
+ HBox contentBox = new HBox(colorCircle, nameLabel);
+ contentBox.getStyleClass().addAll("std-spacing");
+ borderPane.setLeft(contentBox);
+
+ Button addChildButton = new Button("Add Subcategory");
+ addChildButton.setOnAction(event -> router.navigate(
+ "edit-category",
+ new EditCategoryController.AddSubcategoryRouteContext(treeNode.category())
+ ));
+ Button removeButton = new Button("Remove");
+ removeButton.setOnAction(event -> {
+ boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this category? It will permanently remove the category from all linked transactions, and all subcategories will also be removed. This cannot be undone.");
+ if (confirm) {
+ Profile.getCurrent().dataSource().useRepo(
+ TransactionCategoryRepository.class,
+ repo -> repo.deleteById(treeNode.category().id)
+ );
+ categoriesRefresh.run();
+ }
+ });
+ HBox buttonsBox = new HBox(addChildButton, removeButton);
+ buttonsBox.getStyleClass().addAll("std-spacing");
+ borderPane.setRight(buttonsBox);
+
+ this.getChildren().add(borderPane);
+ for (var child : treeNode.children()) {
+ this.getChildren().add(new CategoryTile(child, categoriesRefresh));
+ }
+ }
+}
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)
);
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/VendorTile.java b/src/main/java/com/andrewlalis/perfin/view/component/VendorTile.java
new file mode 100644
index 0000000..9def0ce
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/view/component/VendorTile.java
@@ -0,0 +1,46 @@
+package com.andrewlalis.perfin.view.component;
+
+import com.andrewlalis.perfin.control.Popups;
+import com.andrewlalis.perfin.data.TransactionVendorRepository;
+import com.andrewlalis.perfin.model.Profile;
+import com.andrewlalis.perfin.model.TransactionVendor;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.VBox;
+
+import static com.andrewlalis.perfin.PerfinApp.router;
+
+public class VendorTile extends BorderPane {
+ public VendorTile(TransactionVendor vendor, Runnable vendorRefresh) {
+ this.getStyleClass().addAll("tile", "std-spacing", "hand-cursor");
+ this.setOnMouseClicked(event -> router.navigate("edit-vendor", vendor));
+
+ Label nameLabel = new Label(vendor.getName());
+ nameLabel.getStyleClass().addAll("bold-text");
+ Label descriptionLabel = new Label(vendor.getDescription());
+ descriptionLabel.setWrapText(true);
+ VBox contentVBox = new VBox(nameLabel, descriptionLabel);
+ contentVBox.getStyleClass().addAll("std-spacing");
+ this.setCenter(contentVBox);
+ BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT);
+
+ this.setRight(getRemoveButton(vendor, vendorRefresh));
+ }
+
+ private Button getRemoveButton(TransactionVendor transactionVendor, Runnable vendorRefresh) {
+ Button removeButton = new Button("Remove");
+ removeButton.setOnAction(event -> {
+ boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions assigned to this vendor will have their vendor field cleared. This cannot be undone.");
+ if (confirm) {
+ Profile.getCurrent().dataSource().useRepo(
+ TransactionVendorRepository.class,
+ repo -> repo.deleteById(transactionVendor.id)
+ );
+ vendorRefresh.run();
+ }
+ });
+ return removeButton;
+ }
+}
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/AsyncValidationFunction.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/AsyncValidationFunction.java
new file mode 100644
index 0000000..e618f41
--- /dev/null
+++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/AsyncValidationFunction.java
@@ -0,0 +1,7 @@
+package com.andrewlalis.perfin.view.component.validation;
+
+import java.util.concurrent.CompletableFuture;
+
+public interface AsyncValidationFunction {
+ CompletableFuture validate(T input);
+}
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java
index 663affe..2ad5e78 100644
--- a/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java
+++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java
@@ -1,24 +1,40 @@
package com.andrewlalis.perfin.view.component.validation;
import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator;
+import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.Property;
+import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.Node;
import javafx.scene.control.TextField;
+import java.util.concurrent.CompletableFuture;
+
/**
* Fluent interface for applying a validator to one or more controls.
* @param The value type.
*/
public class ValidationApplier {
- private final ValidationFunction validator;
+ private final AsyncValidationFunction validator;
private ValidationDecorator decorator = new FieldSubtextDecorator();
private boolean validateInitially = false;
public ValidationApplier(ValidationFunction validator) {
+ this.validator = input -> CompletableFuture.completedFuture(validator.validate(input));
+ }
+
+ public ValidationApplier(AsyncValidationFunction validator) {
this.validator = validator;
}
+ public static ValidationApplier of(ValidationFunction validator) {
+ return new ValidationApplier<>(validator);
+ }
+
+ public static ValidationApplier ofAsync(AsyncValidationFunction validator) {
+ return new ValidationApplier<>(validator);
+ }
+
public ValidationApplier decoratedWith(ValidationDecorator decorator) {
this.decorator = decorator;
return this;
@@ -29,24 +45,47 @@ public class ValidationApplier {
return this;
}
+ /**
+ * Attaches the configured validator and decorator to a node, so that when
+ * the node's specified valueProperty changes, the validator will be called
+ * and if the new value is invalid, the decorator will update the UI to
+ * show the message(s) to the user.
+ * @param node The node to attach to.
+ * @param valueProperty The property to listen for changes and validate on.
+ * @param triggerProperties Additional properties that, when changed, can
+ * trigger validation.
+ * @return A boolean expression that tells whether the given valueProperty
+ * is valid at any given time.
+ */
public BooleanExpression attach(Node node, Property valueProperty, Property>... triggerProperties) {
- BooleanExpression validProperty = BooleanExpression.booleanExpression(
- valueProperty.map(value -> validator.validate(value).isValid())
- );
+ final SimpleBooleanProperty validProperty = new SimpleBooleanProperty();
valueProperty.addListener((observable, oldValue, newValue) -> {
- ValidationResult result = validator.validate(newValue);
- decorator.decorate(node, result);
+ validProperty.set(false); // Always set valid to false before we start validation.
+ validator.validate(newValue)
+ .thenAccept(result -> Platform.runLater(() -> {
+ validProperty.set(result.isValid());
+ decorator.decorate(node, result);
+ }));
});
for (Property> influencingProperty : triggerProperties) {
influencingProperty.addListener((observable, oldValue, newValue) -> {
- ValidationResult result = validator.validate(valueProperty.getValue());
- decorator.decorate(node, result);
+ validProperty.set(false); // Always set valid to false before we start validation.
+ validator.validate(valueProperty.getValue())
+ .thenAccept(result -> Platform.runLater(() -> {
+ validProperty.set(result.isValid());
+ decorator.decorate(node, result);
+ }));
});
}
if (validateInitially) {
// Call the decorator once to perform validation right away.
- decorator.decorate(node, validator.validate(valueProperty.getValue()));
+ validProperty.set(false); // Always set valid to false before we start validation.
+ validator.validate(valueProperty.getValue())
+ .thenAccept(result -> Platform.runLater(() -> {
+ validProperty.set(result.isValid());
+ decorator.decorate(node, result);
+ }));
}
return validProperty;
}
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java
index 14f41ef..d386211 100644
--- a/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java
+++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java
@@ -4,6 +4,7 @@ import com.andrewlalis.perfin.view.component.validation.ValidationDecorator;
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import javafx.scene.Node;
import javafx.scene.control.Label;
+import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import org.slf4j.Logger;
@@ -55,6 +56,9 @@ public class FieldSubtextDecorator implements ValidationDecorator {
errorLabel.getStyleClass().addAll("small-font", "negative-color-text-fill");
errorLabel.setWrapText(true);
VBox validationContainer = new VBox(node, errorLabel);
+ if (trueParent instanceof HBox) {
+ HBox.setHgrow(validationContainer, HBox.getHgrow(node));
+ }
validationContainer.setUserData(WRAP_KEY);
trueParent.getChildren().add(idx, validationContainer);
return errorLabel;
diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java
index 51c73a5..6ac94d0 100644
--- a/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java
+++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java
@@ -1,10 +1,14 @@
package com.andrewlalis.perfin.view.component.validation.validators;
-import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
+import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction;
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
import java.util.function.Function;
/**
@@ -12,32 +16,73 @@ import java.util.function.Function;
* determine if it's valid. If invalid, a message is added.
* @param The value type.
*/
-public class PredicateValidator implements ValidationFunction {
- private record ValidationStep(Function predicate, String message, boolean terminal) {}
+public class PredicateValidator implements AsyncValidationFunction {
+ private static final Logger logger = LoggerFactory.getLogger(PredicateValidator.class);
+
+ private record ValidationStep(Function> predicate, String message, boolean terminal) {}
private final List> steps = new ArrayList<>();
- public PredicateValidator addPredicate(Function predicate, String errorMessage) {
- steps.add(new ValidationStep<>(predicate, errorMessage, false));
+ private PredicateValidator addPredicate(Function predicate, String errorMessage, boolean terminal) {
+ steps.add(new ValidationStep<>(
+ v -> CompletableFuture.completedFuture(predicate.apply(v)),
+ errorMessage,
+ terminal
+ ));
return this;
}
- public PredicateValidator addTerminalPredicate(Function predicate, String errorMessage) {
- steps.add(new ValidationStep<>(predicate, errorMessage, true));
+ private PredicateValidator addAsyncPredicate(Function> asyncPredicate, String errorMessage, boolean terminal) {
+ steps.add(new ValidationStep<>(asyncPredicate, errorMessage, terminal));
return this;
}
+ public PredicateValidator addPredicate(Function predicate, String errorMessage) {
+ return addPredicate(predicate, errorMessage, false);
+ }
+
+ public PredicateValidator addAsyncPredicate(Function> asyncPredicate, String errorMessage) {
+ return addAsyncPredicate(asyncPredicate, errorMessage, false);
+ }
+
+ /**
+ * Adds a terminal predicate, that is, if the given boolean function
+ * evaluates to false, then no further predicates are evaluated.
+ * @param predicate The predicate function.
+ * @param errorMessage The error message to display if the predicate
+ * evaluates to false for a given value.
+ * @return A reference to the validator, for method chaining.
+ */
+ public PredicateValidator addTerminalPredicate(Function predicate, String errorMessage) {
+ return addPredicate(predicate, errorMessage, true);
+ }
+
+ public PredicateValidator addTerminalAsyncPredicate(Function> asyncPredicate, String errorMessage) {
+ return addAsyncPredicate(asyncPredicate, errorMessage);
+ }
+
@Override
- public ValidationResult validate(T input) {
- List messages = new ArrayList<>();
- for (var step : steps) {
- if (!step.predicate().apply(input)) {
- messages.add(step.message());
- if (step.terminal()) {
- return new ValidationResult(messages);
+ public CompletableFuture validate(T input) {
+ CompletableFuture cf = new CompletableFuture<>();
+ Thread.ofVirtual().start(() -> {
+ List messages = new ArrayList<>();
+ for (var step : steps) {
+ try {
+ boolean success = step.predicate().apply(input).get();
+ if (!success) {
+ messages.add(step.message());
+ if (step.terminal()) {
+ cf.complete(new ValidationResult(messages));
+ return; // Exit if this is a terminal step and it failed.
+ }
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ logger.error("Applying a predicate to input failed.", e);
+ cf.completeExceptionally(e);
}
}
- }
- return new ValidationResult(messages);
+ cf.complete(new ValidationResult(messages));
+ });
+ return cf;
}
}
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 006df3f..766abce 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -19,4 +19,5 @@ module com.andrewlalis.perfin {
opens com.andrewlalis.perfin.view to javafx.fxml;
opens com.andrewlalis.perfin.view.component to javafx.fxml;
opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
+ exports com.andrewlalis.perfin.model.history to javafx.graphics;
}
\ No newline at end of file
diff --git a/src/main/resources/categories-view.fxml b/src/main/resources/categories-view.fxml
new file mode 100644
index 0000000..c34b38f
--- /dev/null
+++ b/src/main/resources/categories-view.fxml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Categories are used to group your transactions based on their
+ purpose. It's helpful to categorize transactions in order to get
+ a better view of your spending habits, and it makes it easier to
+ lookup transactions later.
+
+
+
+
+
+
+
+
+
+