Merge pull request #15 from andrewlalis/transaction-properties
Add Transaction Properties
This commit is contained in:
		
						commit
						6900fdb481
					
				
							
								
								
									
										27
									
								
								README.md
								
								
								
								
							
							
						
						
									
										27
									
								
								README.md
								
								
								
								
							| 
						 | 
				
			
			@ -37,3 +37,30 @@ to set the version everywhere that it needs to be.
 | 
			
		|||
 | 
			
		||||
Once that's done, the workflow will start, and you should see a release appear
 | 
			
		||||
in the next few minutes.
 | 
			
		||||
 | 
			
		||||
## Migration Procedure
 | 
			
		||||
 | 
			
		||||
Because this application relies on a structured relational database schema,
 | 
			
		||||
changes to the schema must be handled with care to avoid destroying users' data.
 | 
			
		||||
Specifically, when changes are made to the schema, a *migration* must be defined
 | 
			
		||||
which provides instructions for Perfin to safely apply changes to an old schema.
 | 
			
		||||
 | 
			
		||||
The database schema is versioned using whole-number versions (1, 2, 3, ...), and
 | 
			
		||||
a migration is defined for each transition from version to version, such that
 | 
			
		||||
any older version can be incrementally upgraded, step by step, to the latest
 | 
			
		||||
schema version.
 | 
			
		||||
 | 
			
		||||
Perfin only supports the latest schema version, as defined by `JdbcDataSourceFactory.SCHEMA_VERSION`.
 | 
			
		||||
When the app loads a profile, it'll check that profile's schema version by
 | 
			
		||||
reading a `.jdbc-schema-version.txt` file in the profile's main directory. If
 | 
			
		||||
the profile's schema version is **less than** the current, Perfin will
 | 
			
		||||
ask the user if they want to upgrade. If the profile's schema version is
 | 
			
		||||
**greater than** the current, Perfin will tell the user that it can't load a
 | 
			
		||||
schema from a newer version, and will prompt the user to upgrade.
 | 
			
		||||
 | 
			
		||||
### Writing a Migration
 | 
			
		||||
 | 
			
		||||
1. Write your migration. This can be plain SQL (placed in `resources/sql/migration`), or Java code.
 | 
			
		||||
2. Add your migration to `com.andrewlalis.perfin.data.impl.migration.Migrations#getMigrations()`.
 | 
			
		||||
3. Increment the schema version defined in `JdbcDataSourceFactory`.
 | 
			
		||||
4. Test the migration yourself on a profile with data.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								pom.xml
								
								
								
								
							
							
						
						
									
										2
									
								
								pom.xml
								
								
								
								
							| 
						 | 
				
			
			@ -6,7 +6,7 @@
 | 
			
		|||
 | 
			
		||||
    <groupId>com.andrewlalis</groupId>
 | 
			
		||||
    <artifactId>perfin</artifactId>
 | 
			
		||||
    <version>1.4.0</version>
 | 
			
		||||
    <version>1.5.0</version>
 | 
			
		||||
 | 
			
		||||
    <properties>
 | 
			
		||||
        <maven.compiler.source>21</maven.compiler.source>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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" \
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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" `
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<String> 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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<AccountHistoryItem> 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<HistoryItem> 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));
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<Account> 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())));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<Attachment> 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();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,63 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.view.BindingUtil;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.CategoryTile;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
import javafx.collections.FXCollections;
 | 
			
		||||
import javafx.collections.ObservableList;
 | 
			
		||||
import javafx.fxml.FXML;
 | 
			
		||||
import javafx.scene.layout.VBox;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.io.UncheckedIOException;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class CategoriesViewController implements RouteSelectionListener {
 | 
			
		||||
    @FXML public VBox categoriesVBox;
 | 
			
		||||
    private final ObservableList<TransactionCategoryRepository.CategoryTreeNode> categoryTreeNodes = FXCollections.observableArrayList();
 | 
			
		||||
 | 
			
		||||
    @FXML public void initialize() {
 | 
			
		||||
        BindingUtil.mapContent(categoriesVBox.getChildren(), categoryTreeNodes, node -> new CategoryTile(node, this::refreshCategories));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void onRouteSelected(Object context) {
 | 
			
		||||
        refreshCategories();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void addCategory() {
 | 
			
		||||
        router.navigate("edit-category");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void refreshCategories() {
 | 
			
		||||
        Profile.getCurrent().dataSource().mapRepoAsync(
 | 
			
		||||
                TransactionCategoryRepository.class,
 | 
			
		||||
                TransactionCategoryRepository::findTree
 | 
			
		||||
        ).thenAccept(nodes -> Platform.runLater(() -> categoryTreeNodes.setAll(nodes)));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void addDefaultCategories() {
 | 
			
		||||
        boolean confirm = Popups.confirm(categoriesVBox, "Are you sure you want to add all of Perfin's default categories to your profile? This might interfere with existing categories of the same name.");
 | 
			
		||||
        if (!confirm) return;
 | 
			
		||||
        JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
 | 
			
		||||
        try (var conn = ds.getConnection()) {
 | 
			
		||||
            DbUtil.doTransaction(conn, () -> {
 | 
			
		||||
                try {
 | 
			
		||||
                    new JdbcDataSourceFactory().insertDefaultCategories(conn);
 | 
			
		||||
                } catch (IOException e) {
 | 
			
		||||
                    throw new UncheckedIOException(e);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
            refreshCategories();
 | 
			
		||||
        } catch (Exception e) {
 | 
			
		||||
            Popups.error(categoriesVBox, e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -11,6 +11,7 @@ import com.andrewlalis.perfin.model.Profile;
 | 
			
		|||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.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<String>(input -> {
 | 
			
		||||
        var timestampValid = new ValidationApplier<>((ValidationFunction<String>)  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;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<Path> attachments = Collections.emptyList();
 | 
			
		||||
 | 
			
		||||
                boolean success = Popups.confirm("Are you sure you want to create this account?");
 | 
			
		||||
                boolean success = Popups.confirm(accountNameField, "Are you sure you want to create this account?");
 | 
			
		||||
                if (success) {
 | 
			
		||||
                    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());
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,108 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionCategory;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
			
		||||
import javafx.fxml.FXML;
 | 
			
		||||
import javafx.scene.control.Button;
 | 
			
		||||
import javafx.scene.control.ColorPicker;
 | 
			
		||||
import javafx.scene.control.TextField;
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
import java.util.concurrent.CompletableFuture;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class EditCategoryController implements RouteSelectionListener {
 | 
			
		||||
    public record CategoryRouteContext(TransactionCategory category) implements RouteContext {}
 | 
			
		||||
    public record AddSubcategoryRouteContext(TransactionCategory parent) implements RouteContext {}
 | 
			
		||||
    private sealed interface RouteContext permits AddSubcategoryRouteContext, CategoryRouteContext {}
 | 
			
		||||
 | 
			
		||||
    private TransactionCategory category;
 | 
			
		||||
    private TransactionCategory parent;
 | 
			
		||||
 | 
			
		||||
    @FXML public TextField nameField;
 | 
			
		||||
    @FXML public ColorPicker colorPicker;
 | 
			
		||||
 | 
			
		||||
    @FXML public Button saveButton;
 | 
			
		||||
 | 
			
		||||
    @FXML public void initialize() {
 | 
			
		||||
        var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
			
		||||
                .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
 | 
			
		||||
                .addPredicate(s -> s.strip().length() <= TransactionCategory.NAME_MAX_LENGTH, "Name is too long.")
 | 
			
		||||
                .addAsyncPredicate(
 | 
			
		||||
                        s -> {
 | 
			
		||||
                            if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
 | 
			
		||||
                            return Profile.getCurrent().dataSource().mapRepoAsync(
 | 
			
		||||
                                    TransactionCategoryRepository.class,
 | 
			
		||||
                                    repo -> {
 | 
			
		||||
                                        var categoryByName = repo.findByName(s).orElse(null);
 | 
			
		||||
                                        if (this.category != null) {
 | 
			
		||||
                                            return this.category.equals(categoryByName) || categoryByName == null;
 | 
			
		||||
                                        }
 | 
			
		||||
                                        return categoryByName == null;
 | 
			
		||||
                                    }
 | 
			
		||||
                            );
 | 
			
		||||
                        },
 | 
			
		||||
                        "Category with this name already exists."
 | 
			
		||||
                )
 | 
			
		||||
        ).validatedInitially().attachToTextField(nameField);
 | 
			
		||||
 | 
			
		||||
        saveButton.disableProperty().bind(nameValid.not());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void onRouteSelected(Object context) {
 | 
			
		||||
        this.category = null;
 | 
			
		||||
        this.parent = null;
 | 
			
		||||
        if (context instanceof RouteContext ctx) {
 | 
			
		||||
            switch (ctx) {
 | 
			
		||||
                case CategoryRouteContext(var cat):
 | 
			
		||||
                    this.category = cat;
 | 
			
		||||
                    nameField.setText(cat.getName());
 | 
			
		||||
                    colorPicker.setValue(cat.getColor());
 | 
			
		||||
                    break;
 | 
			
		||||
                case AddSubcategoryRouteContext(var par):
 | 
			
		||||
                    this.parent = par;
 | 
			
		||||
                    nameField.setText(null);
 | 
			
		||||
                    colorPicker.setValue(parent.getColor());
 | 
			
		||||
                    break;
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            nameField.setText(null);
 | 
			
		||||
            colorPicker.setValue(Color.WHITE);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void save() {
 | 
			
		||||
        final String name = nameField.getText().strip();
 | 
			
		||||
        final Color color = colorPicker.getValue();
 | 
			
		||||
        if (this.category == null && this.parent == null) {
 | 
			
		||||
            // New top-level category.
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(
 | 
			
		||||
                    TransactionCategoryRepository.class,
 | 
			
		||||
                    repo -> repo.insert(name, color)
 | 
			
		||||
            );
 | 
			
		||||
        } else if (this.category == null) {
 | 
			
		||||
            // New subcategory.
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(
 | 
			
		||||
                    TransactionCategoryRepository.class,
 | 
			
		||||
                    repo -> repo.insert(parent.id, name, color)
 | 
			
		||||
            );
 | 
			
		||||
        } else if (this.parent == null) {
 | 
			
		||||
            // Save edits to an existing category.
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(
 | 
			
		||||
                    TransactionCategoryRepository.class,
 | 
			
		||||
                    repo -> repo.update(category.id, name, color)
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
        router.replace("categories");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void cancel() {
 | 
			
		||||
        router.navigateBackAndClear();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,24 +1,36 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
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<String> vendorComboBox;
 | 
			
		||||
    @FXML public Hyperlink vendorsHyperlink;
 | 
			
		||||
    @FXML public CategorySelectionBox categoryComboBox;
 | 
			
		||||
    @FXML public Hyperlink categoriesHyperlink;
 | 
			
		||||
    @FXML public ComboBox<String> tagsComboBox;
 | 
			
		||||
    @FXML public Hyperlink tagsHyperlink;
 | 
			
		||||
    @FXML public Button addTagButton;
 | 
			
		||||
    @FXML public VBox tagsVBox;
 | 
			
		||||
    private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
 | 
			
		||||
 | 
			
		||||
    @FXML public Spinner<Integer> lineItemQuantitySpinner;
 | 
			
		||||
    @FXML public TextField lineItemValueField;
 | 
			
		||||
    @FXML public TextField lineItemDescriptionField;
 | 
			
		||||
    @FXML public Button addLineItemButton;
 | 
			
		||||
    @FXML public VBox addLineItemForm;
 | 
			
		||||
    @FXML public Button addLineItemAddButton;
 | 
			
		||||
    @FXML public Button addLineItemCancelButton;
 | 
			
		||||
    @FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
 | 
			
		||||
 | 
			
		||||
    @FXML public FileSelectionArea attachmentsSelectionArea;
 | 
			
		||||
 | 
			
		||||
    @FXML public Button saveButton;
 | 
			
		||||
| 
						 | 
				
			
			@ -70,32 +102,32 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
        var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
			
		||||
                .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<CreditAndDebitAccounts> 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<CreditAndDebitAccounts>()
 | 
			
		||||
                .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
 | 
			
		||||
                .addPredicate(
 | 
			
		||||
                        accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
 | 
			
		||||
                        "The credit and debit accounts cannot be the same."
 | 
			
		||||
                )
 | 
			
		||||
                .addPredicate(
 | 
			
		||||
                        accounts -> (
 | 
			
		||||
                                (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
 | 
			
		||||
                                (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
 | 
			
		||||
                        ),
 | 
			
		||||
                        "Linked accounts must use the same currency."
 | 
			
		||||
                )
 | 
			
		||||
                .addPredicate(
 | 
			
		||||
                        accounts -> (
 | 
			
		||||
                                (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
 | 
			
		||||
                                (!accounts.hasDebit() || !accounts.debitAccount().isArchived())
 | 
			
		||||
                        ),
 | 
			
		||||
                        "Linked accounts must not be archived."
 | 
			
		||||
                )
 | 
			
		||||
        ).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<String>()
 | 
			
		||||
                .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<String> tags = new HashSet<>(selectedTags);
 | 
			
		||||
        List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
 | 
			
		||||
        List<Attachment> 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<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
 | 
			
		||||
| 
						 | 
				
			
			@ -174,23 +223,50 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
                        .toList();
 | 
			
		||||
                List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
 | 
			
		||||
                final List<Attachment> attachments;
 | 
			
		||||
                final var categoryTreeNodes = categoryRepo.findTree();
 | 
			
		||||
                final List<String> availableTags = transactionRepo.findAllTags();
 | 
			
		||||
                final List<String> 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<TransactionVendor> 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<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
 | 
			
		||||
        debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
 | 
			
		||||
        creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
 | 
			
		||||
        return new ValidationApplier<>(getLinkedAccountsValidator())
 | 
			
		||||
                .validatedInitially()
 | 
			
		||||
                .attach(linkedAccountsContainer, linkedAccountsProperty);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void initializeTagSelectionUi() {
 | 
			
		||||
        addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
 | 
			
		||||
        addTagButton.setOnAction(event -> {
 | 
			
		||||
            if (tagsComboBox.getValue() == null) return;
 | 
			
		||||
            String tag = tagsComboBox.getValue().strip();
 | 
			
		||||
            if (!selectedTags.contains(tag)) {
 | 
			
		||||
                selectedTags.add(tag);
 | 
			
		||||
                selectedTags.sort(String::compareToIgnoreCase);
 | 
			
		||||
            }
 | 
			
		||||
            tagsComboBox.setValue(null);
 | 
			
		||||
        });
 | 
			
		||||
        tagsComboBox.setOnKeyPressed(event -> {
 | 
			
		||||
            if (event.getCode() == KeyCode.ENTER) {
 | 
			
		||||
                addTagButton.fire();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, this::createTagListTile);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Node createTagListTile(String tag) {
 | 
			
		||||
        Label label = new Label(tag);
 | 
			
		||||
        label.setMaxWidth(Double.POSITIVE_INFINITY);
 | 
			
		||||
        label.getStyleClass().addAll("bold-text");
 | 
			
		||||
        Button removeButton = new Button("Remove");
 | 
			
		||||
        removeButton.setOnAction(event -> selectedTags.remove(tag));
 | 
			
		||||
        BorderPane tile = new BorderPane(label);
 | 
			
		||||
        tile.setRight(removeButton);
 | 
			
		||||
        tile.getStyleClass().addAll("std-spacing");
 | 
			
		||||
        BorderPane.setAlignment(label, Pos.CENTER_LEFT);
 | 
			
		||||
        return tile;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private CreditAndDebitAccounts getSelectedAccounts() {
 | 
			
		||||
        return new CreditAndDebitAccounts(
 | 
			
		||||
                creditAccountSelector.getValue(),
 | 
			
		||||
| 
						 | 
				
			
			@ -215,6 +333,29 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private PredicateValidator<CreditAndDebitAccounts> getLinkedAccountsValidator() {
 | 
			
		||||
        return new PredicateValidator<CreditAndDebitAccounts>()
 | 
			
		||||
            .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
 | 
			
		||||
            .addPredicate(
 | 
			
		||||
                accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
 | 
			
		||||
                "The credit and debit accounts cannot be the same."
 | 
			
		||||
            )
 | 
			
		||||
            .addPredicate(
 | 
			
		||||
                accounts -> (
 | 
			
		||||
                    (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
 | 
			
		||||
                    (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
 | 
			
		||||
                ),
 | 
			
		||||
                "Linked accounts must use the same currency."
 | 
			
		||||
            )
 | 
			
		||||
            .addPredicate(
 | 
			
		||||
                accounts -> (
 | 
			
		||||
                    (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
 | 
			
		||||
                    (!accounts.hasDebit() || !accounts.debitAccount().isArchived())
 | 
			
		||||
                ),
 | 
			
		||||
                "Linked accounts must not be archived."
 | 
			
		||||
            );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private LocalDateTime parseTimestamp() {
 | 
			
		||||
        List<DateTimeFormatter> formatters = List.of(
 | 
			
		||||
                DateTimeFormatter.ISO_LOCAL_DATE_TIME,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,93 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionVendor;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
			
		||||
import javafx.fxml.FXML;
 | 
			
		||||
import javafx.scene.control.Button;
 | 
			
		||||
import javafx.scene.control.TextArea;
 | 
			
		||||
import javafx.scene.control.TextField;
 | 
			
		||||
 | 
			
		||||
import java.util.Objects;
 | 
			
		||||
import java.util.concurrent.CompletableFuture;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class EditVendorController implements RouteSelectionListener {
 | 
			
		||||
    private TransactionVendor vendor;
 | 
			
		||||
 | 
			
		||||
    @FXML public TextField nameField;
 | 
			
		||||
    @FXML public TextArea descriptionField;
 | 
			
		||||
    @FXML public Button saveButton;
 | 
			
		||||
 | 
			
		||||
    @FXML public void initialize() {
 | 
			
		||||
        var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
			
		||||
                .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
 | 
			
		||||
                .addPredicate(s -> s.strip().length() <= TransactionVendor.NAME_MAX_LENGTH, "Name is too long.")
 | 
			
		||||
                // A predicate that prevents duplicate names.
 | 
			
		||||
                .addAsyncPredicate(
 | 
			
		||||
                        s -> {
 | 
			
		||||
                            if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
 | 
			
		||||
                            return Profile.getCurrent().dataSource().mapRepoAsync(
 | 
			
		||||
                                    TransactionVendorRepository.class,
 | 
			
		||||
                                    repo -> {
 | 
			
		||||
                                        var vendorByName = repo.findByName(s).orElse(null);
 | 
			
		||||
                                        if (this.vendor != null) {
 | 
			
		||||
                                            return this.vendor.equals(vendorByName) || vendorByName == null;
 | 
			
		||||
                                        }
 | 
			
		||||
                                        return vendorByName == null;
 | 
			
		||||
                                    }
 | 
			
		||||
                            );
 | 
			
		||||
                        },
 | 
			
		||||
                        "Vendor with this name already exists."
 | 
			
		||||
                )
 | 
			
		||||
            ).validatedInitially().attachToTextField(nameField);
 | 
			
		||||
        var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
			
		||||
                .addPredicate(
 | 
			
		||||
                        s -> s == null || s.strip().length() <= TransactionVendor.DESCRIPTION_MAX_LENGTH,
 | 
			
		||||
                        "Description is too long."
 | 
			
		||||
                )
 | 
			
		||||
            ).validatedInitially().attach(descriptionField, descriptionField.textProperty());
 | 
			
		||||
 | 
			
		||||
        var formValid = nameValid.and(descriptionValid);
 | 
			
		||||
        saveButton.disableProperty().bind(formValid.not());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void onRouteSelected(Object context) {
 | 
			
		||||
        if (context instanceof TransactionVendor tv) {
 | 
			
		||||
            this.vendor = tv;
 | 
			
		||||
            nameField.setText(vendor.getName());
 | 
			
		||||
            descriptionField.setText(vendor.getDescription());
 | 
			
		||||
        } else {
 | 
			
		||||
            nameField.setText(null);
 | 
			
		||||
            descriptionField.setText(null);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void save() {
 | 
			
		||||
        String name = nameField.getText().strip();
 | 
			
		||||
        String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
 | 
			
		||||
        DataSource ds = Profile.getCurrent().dataSource();
 | 
			
		||||
        if (vendor != null) {
 | 
			
		||||
            ds.useRepo(TransactionVendorRepository.class, repo -> repo.update(vendor.id, name, description));
 | 
			
		||||
        } else {
 | 
			
		||||
            ds.useRepo(TransactionVendorRepository.class, repo -> {
 | 
			
		||||
                if (description == null || description.isEmpty()) {
 | 
			
		||||
                    repo.insert(name);
 | 
			
		||||
                } else {
 | 
			
		||||
                    repo.insert(name, description);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        router.replace("vendors");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void cancel() {
 | 
			
		||||
        router.navigateBackAndClear();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,30 +1,70 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<String> profileNames = Profile.getAvailableProfiles();
 | 
			
		||||
        String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName();
 | 
			
		||||
        List<String> profileNames = ProfileLoader.getAvailableProfiles();
 | 
			
		||||
        String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name();
 | 
			
		||||
        List<Node> 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();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,64 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionRepository;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.view.BindingUtil;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
import javafx.collections.FXCollections;
 | 
			
		||||
import javafx.collections.ObservableList;
 | 
			
		||||
import javafx.fxml.FXML;
 | 
			
		||||
import javafx.scene.Node;
 | 
			
		||||
import javafx.scene.control.Button;
 | 
			
		||||
import javafx.scene.control.Label;
 | 
			
		||||
import javafx.scene.layout.BorderPane;
 | 
			
		||||
import javafx.scene.layout.VBox;
 | 
			
		||||
 | 
			
		||||
public class TagsViewController implements RouteSelectionListener {
 | 
			
		||||
    @FXML public VBox tagsVBox;
 | 
			
		||||
    private final ObservableList<String> tags = FXCollections.observableArrayList();
 | 
			
		||||
 | 
			
		||||
    @FXML public void initialize() {
 | 
			
		||||
        BindingUtil.mapContent(tagsVBox.getChildren(), tags, this::buildTagTile);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void onRouteSelected(Object context) {
 | 
			
		||||
        refreshTags();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void refreshTags() {
 | 
			
		||||
        Profile.getCurrent().dataSource().mapRepoAsync(
 | 
			
		||||
                TransactionRepository.class,
 | 
			
		||||
                TransactionRepository::findAllTags
 | 
			
		||||
        ).thenAccept(strings -> Platform.runLater(() -> tags.setAll(strings)));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Node buildTagTile(String name) {
 | 
			
		||||
        BorderPane tile = new BorderPane();
 | 
			
		||||
        tile.getStyleClass().addAll("tile");
 | 
			
		||||
        Label nameLabel = new Label(name);
 | 
			
		||||
        nameLabel.getStyleClass().addAll("bold-text");
 | 
			
		||||
        Label usagesLabel = new Label();
 | 
			
		||||
        usagesLabel.getStyleClass().addAll("small-font", "secondary-color-text-fill");
 | 
			
		||||
        Profile.getCurrent().dataSource().mapRepoAsync(
 | 
			
		||||
                TransactionRepository.class,
 | 
			
		||||
                repo -> repo.countTagUsages(name)
 | 
			
		||||
        ).thenAccept(count -> Platform.runLater(() -> usagesLabel.setText("Tagged transactions: " + count)));
 | 
			
		||||
        VBox contentBox = new VBox(nameLabel, usagesLabel);
 | 
			
		||||
        tile.setLeft(contentBox);
 | 
			
		||||
        Button removeButton = new Button("Remove");
 | 
			
		||||
        removeButton.setOnAction(event -> {
 | 
			
		||||
            boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this tag? It will be removed from any transactions. This cannot be undone.");
 | 
			
		||||
            if (confirm) {
 | 
			
		||||
                Profile.getCurrent().dataSource().useRepo(
 | 
			
		||||
                        TransactionRepository.class,
 | 
			
		||||
                        repo -> repo.deleteTag(name)
 | 
			
		||||
                );
 | 
			
		||||
                refreshTags();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        tile.setRight(removeButton);
 | 
			
		||||
        return tile;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -3,23 +3,37 @@ package com.andrewlalis.perfin.control;
 | 
			
		|||
import com.andrewlalis.perfin.data.TransactionRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.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<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
 | 
			
		||||
    private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
 | 
			
		||||
    private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
 | 
			
		||||
    private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
 | 
			
		||||
    private final ObservableList<String> tagsList = FXCollections.observableArrayList();
 | 
			
		||||
    private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
 | 
			
		||||
    private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
 | 
			
		||||
 | 
			
		||||
    @FXML public Label titleLabel;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -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<Attachment> 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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<Transaction> 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<BorderPane, TransactionViewController> 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<Account> 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<SearchFilter> getCurrentSearchFilters() {
 | 
			
		||||
        List<SearchFilter> filters = new ArrayList<>();
 | 
			
		||||
        if (searchField.getText() != null && !searchField.getText().isBlank()) {
 | 
			
		||||
            var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+"))
 | 
			
		||||
                    .map(t -> '%'+t+'%')
 | 
			
		||||
                    .toList();
 | 
			
		||||
            var builder = new SearchFilter.Builder();
 | 
			
		||||
            List<String> orClauses = new ArrayList<>(likeTerms.size());
 | 
			
		||||
            for (var term : likeTerms) {
 | 
			
		||||
                orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?");
 | 
			
		||||
                builder.withArg(term);
 | 
			
		||||
                builder.withArg(term);
 | 
			
		||||
                builder.withArg(term);
 | 
			
		||||
            }
 | 
			
		||||
            builder.where(String.join(" OR ", orClauses));
 | 
			
		||||
            builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id");
 | 
			
		||||
            builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id");
 | 
			
		||||
            filters.add(builder.build());
 | 
			
		||||
        }
 | 
			
		||||
        if (filterByAccountComboBox.getValue() != null) {
 | 
			
		||||
            Account filteredAccount = filterByAccountComboBox.getValue();
 | 
			
		||||
            var filter = new SearchFilter.Builder()
 | 
			
		||||
                    .where("fae.account_id = ?")
 | 
			
		||||
                    .withArg(filteredAccount.id)
 | 
			
		||||
                    .withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id")
 | 
			
		||||
                    .build();
 | 
			
		||||
            filters.add(filter);
 | 
			
		||||
        }
 | 
			
		||||
        return filters;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private TransactionTile makeTile(Transaction transaction) {
 | 
			
		||||
        var tile = new TransactionTile(transaction);
 | 
			
		||||
        tile.setOnMouseClicked(event -> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,45 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionVendor;
 | 
			
		||||
import com.andrewlalis.perfin.view.BindingUtil;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.VendorTile;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
import javafx.collections.FXCollections;
 | 
			
		||||
import javafx.collections.ObservableList;
 | 
			
		||||
import javafx.fxml.FXML;
 | 
			
		||||
import javafx.scene.layout.VBox;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class VendorsViewController implements RouteSelectionListener {
 | 
			
		||||
    @FXML public VBox vendorsVBox;
 | 
			
		||||
    private final ObservableList<TransactionVendor> vendors = FXCollections.observableArrayList();
 | 
			
		||||
 | 
			
		||||
    @FXML public void initialize() {
 | 
			
		||||
        BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, vendor -> new VendorTile(vendor, this::refreshVendors));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void onRouteSelected(Object context) {
 | 
			
		||||
        refreshVendors();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void addVendor() {
 | 
			
		||||
        router.navigate("edit-vendor");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void refreshVendors() {
 | 
			
		||||
        Profile.getCurrent().dataSource().useRepoAsync(TransactionVendorRepository.class, repo -> {
 | 
			
		||||
            final List<TransactionVendor> vendors = repo.findAll();
 | 
			
		||||
            Platform.runLater(() -> {
 | 
			
		||||
                this.vendors.clear();
 | 
			
		||||
                this.vendors.addAll(vendors);
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,25 +0,0 @@
 | 
			
		|||
package com.andrewlalis.perfin.data;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.AccountEntry;
 | 
			
		||||
import com.andrewlalis.perfin.model.BalanceRecord;
 | 
			
		||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
			
		||||
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public interface AccountHistoryItemRepository extends Repository, AutoCloseable {
 | 
			
		||||
    void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId);
 | 
			
		||||
    void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId);
 | 
			
		||||
    void recordText(LocalDateTime timestamp, long accountId, String text);
 | 
			
		||||
    List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count);
 | 
			
		||||
    default Optional<AccountHistoryItem> getMostRecentForAccount(long accountId) {
 | 
			
		||||
        var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1);
 | 
			
		||||
        if (items.isEmpty()) return Optional.empty();
 | 
			
		||||
        return Optional.of(items.getFirst());
 | 
			
		||||
    }
 | 
			
		||||
    String getTextItem(long itemId);
 | 
			
		||||
    AccountEntry getAccountEntryItem(long itemId);
 | 
			
		||||
    BalanceRecord getBalanceRecordItem(long itemId);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -30,8 +30,10 @@ public interface DataSource {
 | 
			
		|||
    AccountRepository getAccountRepository();
 | 
			
		||||
    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<R>) repoSuppliers.get(type);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
package com.andrewlalis.perfin.data;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Interface that defines the data source factory, a component responsible for
 | 
			
		||||
 * obtaining a data source, and performing some introspection around that data
 | 
			
		||||
 * source before one is obtained.
 | 
			
		||||
 */
 | 
			
		||||
public interface DataSourceFactory {
 | 
			
		||||
    DataSource getDataSource(String profileName) throws ProfileLoadException;
 | 
			
		||||
 | 
			
		||||
    enum SchemaStatus {
 | 
			
		||||
        UP_TO_DATE,
 | 
			
		||||
        NEEDS_MIGRATION,
 | 
			
		||||
        INCOMPATIBLE
 | 
			
		||||
    }
 | 
			
		||||
    SchemaStatus getSchemaStatus(String profileName) throws IOException;
 | 
			
		||||
 | 
			
		||||
    int getSchemaVersion(String profileName) throws IOException;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
package com.andrewlalis.perfin.data;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.history.HistoryItem;
 | 
			
		||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
 | 
			
		||||
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.time.ZoneOffset;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
public interface HistoryRepository extends Repository, AutoCloseable {
 | 
			
		||||
    long getOrCreateHistoryForAccount(long accountId);
 | 
			
		||||
    long getOrCreateHistoryForTransaction(long transactionId);
 | 
			
		||||
    void deleteHistoryForAccount(long accountId);
 | 
			
		||||
    void deleteHistoryForTransaction(long transactionId);
 | 
			
		||||
 | 
			
		||||
    HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description);
 | 
			
		||||
    default HistoryTextItem addTextItem(long historyId, String description) {
 | 
			
		||||
        return addTextItem(historyId, DateUtil.nowAsUTC(), description);
 | 
			
		||||
    }
 | 
			
		||||
    Page<HistoryItem> getItems(long historyId, PageRequest pagination);
 | 
			
		||||
    List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
 | 
			
		||||
    default List<HistoryItem> getNItemsBeforeNow(long historyId, int n) {
 | 
			
		||||
        return getNItemsBefore(historyId, n, LocalDateTime.now(ZoneOffset.UTC));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
package com.andrewlalis.perfin.data;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionCategory;
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public interface TransactionCategoryRepository extends Repository, AutoCloseable {
 | 
			
		||||
    Optional<TransactionCategory> findById(long id);
 | 
			
		||||
    Optional<TransactionCategory> findByName(String name);
 | 
			
		||||
    List<TransactionCategory> findAllBaseCategories();
 | 
			
		||||
    List<TransactionCategory> findAll();
 | 
			
		||||
    long insert(long parentId, String name, Color color);
 | 
			
		||||
    long insert(String name, Color color);
 | 
			
		||||
    void update(long id, String name, Color color);
 | 
			
		||||
    void deleteById(long id);
 | 
			
		||||
 | 
			
		||||
    record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children){}
 | 
			
		||||
    List<CategoryTreeNode> findTree();
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -21,6 +21,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
 | 
			
		|||
            Currency currency,
 | 
			
		||||
            String description,
 | 
			
		||||
            CreditAndDebitAccounts linkedAccounts,
 | 
			
		||||
            String vendor,
 | 
			
		||||
            String category,
 | 
			
		||||
            Set<String> tags,
 | 
			
		||||
            List<Path> attachments
 | 
			
		||||
    );
 | 
			
		||||
    Optional<Transaction> findById(long id);
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +34,10 @@ public interface TransactionRepository extends Repository, AutoCloseable {
 | 
			
		|||
    Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
 | 
			
		||||
    CreditAndDebitAccounts findLinkedAccounts(long transactionId);
 | 
			
		||||
    List<Attachment> findAttachments(long transactionId);
 | 
			
		||||
    List<String> findTags(long transactionId);
 | 
			
		||||
    List<String> findAllTags();
 | 
			
		||||
    void deleteTag(String name);
 | 
			
		||||
    long countTagUsages(String name);
 | 
			
		||||
    void delete(long transactionId);
 | 
			
		||||
    void update(
 | 
			
		||||
            long id,
 | 
			
		||||
| 
						 | 
				
			
			@ -39,6 +46,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
 | 
			
		|||
            Currency currency,
 | 
			
		||||
            String description,
 | 
			
		||||
            CreditAndDebitAccounts linkedAccounts,
 | 
			
		||||
            String vendor,
 | 
			
		||||
            String category,
 | 
			
		||||
            Set<String> tags,
 | 
			
		||||
            List<Attachment> existingAttachments,
 | 
			
		||||
            List<Path> newAttachmentPaths
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
package com.andrewlalis.perfin.data;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionVendor;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public interface TransactionVendorRepository extends Repository, AutoCloseable {
 | 
			
		||||
    Optional<TransactionVendor> findById(long id);
 | 
			
		||||
    Optional<TransactionVendor> findByName(String name);
 | 
			
		||||
    List<TransactionVendor> findAll();
 | 
			
		||||
    long insert(String name, String description);
 | 
			
		||||
    long insert(String name);
 | 
			
		||||
    void update(long id, String name, String description);
 | 
			
		||||
    void deleteById(long id);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,120 +0,0 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.AccountEntry;
 | 
			
		||||
import com.andrewlalis.perfin.model.BalanceRecord;
 | 
			
		||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
			
		||||
import com.andrewlalis.perfin.model.history.AccountHistoryItemType;
 | 
			
		||||
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository {
 | 
			
		||||
    @Override
 | 
			
		||||
    public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) {
 | 
			
		||||
        long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY);
 | 
			
		||||
        DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)",
 | 
			
		||||
                List.of(itemId, entryId)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) {
 | 
			
		||||
        long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD);
 | 
			
		||||
        DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)",
 | 
			
		||||
                List.of(itemId, recordId)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void recordText(LocalDateTime timestamp, long accountId, String text) {
 | 
			
		||||
        long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT);
 | 
			
		||||
        DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO account_history_item_text (item_id, description) VALUES (?, ?)",
 | 
			
		||||
                List.of(itemId, text)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count) {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM account_history_item WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT " + count,
 | 
			
		||||
                List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
 | 
			
		||||
                JdbcAccountHistoryItemRepository::parseHistoryItem
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public String getTextItem(long itemId) {
 | 
			
		||||
        return DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT description FROM account_history_item_text WHERE item_id = ?",
 | 
			
		||||
                List.of(itemId),
 | 
			
		||||
                rs -> rs.getString(1)
 | 
			
		||||
        ).orElse(null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public AccountEntry getAccountEntryItem(long itemId) {
 | 
			
		||||
        return DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                """
 | 
			
		||||
                        SELECT *
 | 
			
		||||
                        FROM account_entry
 | 
			
		||||
                        LEFT JOIN account_history_item_account_entry h ON h.entry_id = account_entry.id
 | 
			
		||||
                        WHERE h.item_id = ?""",
 | 
			
		||||
                List.of(itemId),
 | 
			
		||||
                JdbcAccountEntryRepository::parse
 | 
			
		||||
        ).orElse(null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public BalanceRecord getBalanceRecordItem(long itemId) {
 | 
			
		||||
        return DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                """
 | 
			
		||||
                        SELECT *
 | 
			
		||||
                        FROM balance_record
 | 
			
		||||
                        LEFT JOIN account_history_item_balance_record h ON h.record_id = balance_record.id
 | 
			
		||||
                        WHERE h.item_id = ?""",
 | 
			
		||||
                List.of(itemId),
 | 
			
		||||
                JdbcBalanceRecordRepository::parse
 | 
			
		||||
        ).orElse(null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void close() throws Exception {
 | 
			
		||||
        conn.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static AccountHistoryItem parseHistoryItem(ResultSet rs) throws SQLException {
 | 
			
		||||
        return new AccountHistoryItem(
 | 
			
		||||
                rs.getLong("id"),
 | 
			
		||||
                DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
 | 
			
		||||
                rs.getLong("account_id"),
 | 
			
		||||
                AccountHistoryItemType.valueOf(rs.getString("type"))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)",
 | 
			
		||||
                List.of(
 | 
			
		||||
                        DbUtil.timestampFromUtcLDT(timestamp),
 | 
			
		||||
                        accountId,
 | 
			
		||||
                        type.name()
 | 
			
		||||
                )
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,12 +1,8 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
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.");
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,125 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.HistoryRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.history.HistoryItem;
 | 
			
		||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
 | 
			
		||||
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
public record JdbcHistoryRepository(Connection conn) implements HistoryRepository {
 | 
			
		||||
    @Override
 | 
			
		||||
    public long getOrCreateHistoryForAccount(long accountId) {
 | 
			
		||||
        return getOrCreateHistoryForEntity(accountId, "history_account", "account_id");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long getOrCreateHistoryForTransaction(long transactionId) {
 | 
			
		||||
        return getOrCreateHistoryForEntity(transactionId, "history_transaction", "transaction_id");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private long getOrCreateHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
 | 
			
		||||
        String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
 | 
			
		||||
        var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
 | 
			
		||||
        if (optionalHistoryId.isPresent()) return optionalHistoryId.get();
 | 
			
		||||
        long historyId = DbUtil.insertOne(conn, "INSERT INTO history () VALUES ()");
 | 
			
		||||
        String insertQuery = "INSERT INTO " + joinTableName + " (" + joinColumn + ", history_id) VALUES (?, ?)";
 | 
			
		||||
        DbUtil.updateOne(conn, insertQuery, entityId, historyId);
 | 
			
		||||
        return historyId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void deleteHistoryForAccount(long accountId) {
 | 
			
		||||
        deleteHistoryForEntity(accountId, "history_account", "account_id");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void deleteHistoryForTransaction(long transactionId) {
 | 
			
		||||
        deleteHistoryForEntity(transactionId, "history_transaction", "transaction_id");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void deleteHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
 | 
			
		||||
        String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
 | 
			
		||||
        var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
 | 
			
		||||
        if (optionalHistoryId.isPresent()) {
 | 
			
		||||
            long historyId = optionalHistoryId.get();
 | 
			
		||||
            DbUtil.updateOne(conn, "DELETE FROM history WHERE id = ?", historyId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) {
 | 
			
		||||
        long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.TYPE_TEXT);
 | 
			
		||||
        DbUtil.updateOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO history_item_text (id, description) VALUES (?, ?)",
 | 
			
		||||
                itemId,
 | 
			
		||||
                description
 | 
			
		||||
        );
 | 
			
		||||
        return new HistoryTextItem(itemId, historyId, utcTimestamp, description);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)",
 | 
			
		||||
                historyId,
 | 
			
		||||
                DbUtil.timestampFromUtcLDT(timestamp),
 | 
			
		||||
                type
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public Page<HistoryItem> getItems(long historyId, PageRequest pagination) {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM history_item WHERE history_id = ?",
 | 
			
		||||
                pagination,
 | 
			
		||||
                List.of(historyId),
 | 
			
		||||
                JdbcHistoryRepository::parseItem
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp) {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                """
 | 
			
		||||
                        SELECT *
 | 
			
		||||
                        FROM history_item
 | 
			
		||||
                        WHERE history_id = ? AND timestamp <= ?
 | 
			
		||||
                        ORDER BY timestamp DESC""",
 | 
			
		||||
                List.of(historyId, DbUtil.timestampFromUtcLDT(timestamp)),
 | 
			
		||||
                JdbcHistoryRepository::parseItem
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void close() throws Exception {
 | 
			
		||||
        conn.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static HistoryItem parseItem(ResultSet rs) throws SQLException {
 | 
			
		||||
        long id = rs.getLong(1);
 | 
			
		||||
        long historyId = rs.getLong(2);
 | 
			
		||||
        LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3));
 | 
			
		||||
        String type = rs.getString(4);
 | 
			
		||||
        if (type.equalsIgnoreCase(HistoryItem.TYPE_TEXT)) {
 | 
			
		||||
            String description = DbUtil.findOne(
 | 
			
		||||
                    rs.getStatement().getConnection(),
 | 
			
		||||
                    "SELECT description FROM history_item_text WHERE id = ?",
 | 
			
		||||
                    List.of(id),
 | 
			
		||||
                    r -> r.getString(1)
 | 
			
		||||
            ).orElseThrow();
 | 
			
		||||
            return new HistoryTextItem(id, historyId, timestamp, description);
 | 
			
		||||
        }
 | 
			
		||||
        throw new SQLException("Unknown history item type: " + type);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,142 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.ColorUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionCategory;
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository {
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<TransactionCategory> findById(long id) {
 | 
			
		||||
        return DbUtil.findById(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category WHERE id = ?",
 | 
			
		||||
                id,
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<TransactionCategory> findByName(String name) {
 | 
			
		||||
        return DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category WHERE name = ?",
 | 
			
		||||
                List.of(name),
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<TransactionCategory> findAllBaseCategories() {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<TransactionCategory> findAll() {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category ORDER BY parent_id ASC, name ASC",
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long insert(long parentId, String name, Color color) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
 | 
			
		||||
                List.of(parentId, name, ColorUtil.toHex(color))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long insert(String name, Color color) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO transaction_category (name, color) VALUES (?, ?)",
 | 
			
		||||
                List.of(name, ColorUtil.toHex(color))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void update(long id, String name, Color color) {
 | 
			
		||||
        DbUtil.doTransaction(conn, () -> {
 | 
			
		||||
            TransactionCategory category = findById(id).orElseThrow();
 | 
			
		||||
            if (!category.getName().equals(name)) {
 | 
			
		||||
                DbUtil.updateOne(
 | 
			
		||||
                        conn,
 | 
			
		||||
                        "UPDATE transaction_category SET name = ? WHERE id = ?",
 | 
			
		||||
                        name,
 | 
			
		||||
                        id
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            if (!category.getColor().equals(color)) {
 | 
			
		||||
                DbUtil.updateOne(
 | 
			
		||||
                        conn,
 | 
			
		||||
                        "UPDATE transaction_category SET color = ? WHERE id = ?",
 | 
			
		||||
                        ColorUtil.toHex(color),
 | 
			
		||||
                        id
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void deleteById(long id) {
 | 
			
		||||
        DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<CategoryTreeNode> findTree() {
 | 
			
		||||
        List<TransactionCategory> rootCategories = DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
        List<CategoryTreeNode> rootNodes = new ArrayList<>(rootCategories.size());
 | 
			
		||||
        for (var category : rootCategories) {
 | 
			
		||||
            rootNodes.add(findTreeRecursive(category));
 | 
			
		||||
        }
 | 
			
		||||
        return rootNodes;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
 | 
			
		||||
        CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
 | 
			
		||||
        List<TransactionCategory> childCategories = DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
 | 
			
		||||
                List.of(root.id),
 | 
			
		||||
                JdbcTransactionCategoryRepository::parseCategory
 | 
			
		||||
        );
 | 
			
		||||
        for (var childCategory : childCategories) {
 | 
			
		||||
            node.children().add(findTreeRecursive(childCategory));
 | 
			
		||||
        }
 | 
			
		||||
        return node;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void close() throws Exception {
 | 
			
		||||
        conn.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
 | 
			
		||||
        return new TransactionCategory(
 | 
			
		||||
                rs.getLong("id"),
 | 
			
		||||
                rs.getObject("parent_id", Long.class),
 | 
			
		||||
                rs.getString("name"),
 | 
			
		||||
                Color.valueOf("#" + rs.getString("color"))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,20 +2,21 @@ package com.andrewlalis.perfin.data.impl;
 | 
			
		|||
 | 
			
		||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.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<String> tags,
 | 
			
		||||
            List<Path> 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<Long> optionalId = DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT id FROM transaction_tag WHERE name = ?",
 | 
			
		||||
                List.of(name),
 | 
			
		||||
                rs -> rs.getLong(1)
 | 
			
		||||
        );
 | 
			
		||||
        return optionalId.orElseGet(() ->
 | 
			
		||||
                DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<Transaction> 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<String> findTags(long transactionId) {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                """
 | 
			
		||||
                        SELECT tt.name
 | 
			
		||||
                        FROM transaction_tag tt
 | 
			
		||||
                        LEFT JOIN transaction_tag_join ttj ON ttj.tag_id = tt.id
 | 
			
		||||
                        WHERE ttj.transaction_id = ?
 | 
			
		||||
                        ORDER BY tt.name ASC""",
 | 
			
		||||
                List.of(transactionId),
 | 
			
		||||
                rs -> rs.getString(1)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<String> findAllTags() {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT name FROM transaction_tag ORDER BY name ASC",
 | 
			
		||||
                rs -> rs.getString(1)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void deleteTag(String name) {
 | 
			
		||||
        DbUtil.update(
 | 
			
		||||
                conn,
 | 
			
		||||
                "DELETE FROM transaction_tag WHERE name = ?",
 | 
			
		||||
                name
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long countTagUsages(String name) {
 | 
			
		||||
        return DbUtil.count(
 | 
			
		||||
                conn,
 | 
			
		||||
                """
 | 
			
		||||
                    SELECT COUNT(transaction_id)
 | 
			
		||||
                    FROM transaction_tag_join
 | 
			
		||||
                    WHERE tag_id = (SELECT id FROM transaction_tag WHERE name = ?)""",
 | 
			
		||||
                name
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    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<String> tags,
 | 
			
		||||
            List<Attachment> existingAttachments,
 | 
			
		||||
            List<Path> newAttachmentPaths
 | 
			
		||||
    ) {
 | 
			
		||||
        DbUtil.doTransaction(conn, () -> {
 | 
			
		||||
            Transaction tx = findById(id).orElseThrow();
 | 
			
		||||
            CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
 | 
			
		||||
            List<Attachment> 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<String> currentTags = new HashSet<>(findTags(id));
 | 
			
		||||
            List<Attachment> currentAttachments = findAttachments(id);
 | 
			
		||||
 | 
			
		||||
            List<String> 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<String> tagsAdded = new HashSet<>(tags);
 | 
			
		||||
                tagsAdded.removeAll(currentTags);
 | 
			
		||||
                Set<String> tagsRemoved = new HashSet<>(currentTags);
 | 
			
		||||
                tagsRemoved.removeAll(tags);
 | 
			
		||||
 | 
			
		||||
                for (var t : tagsRemoved) removeTag(id, t);
 | 
			
		||||
                for (var t : tagsAdded) addTag(id, t);
 | 
			
		||||
 | 
			
		||||
                if (!tagsAdded.isEmpty()) {
 | 
			
		||||
                    updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded));
 | 
			
		||||
                }
 | 
			
		||||
                if (!tagsRemoved.isEmpty()) {
 | 
			
		||||
                    updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            // Manage attachments changes.
 | 
			
		||||
            List<Attachment> 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)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,102 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionVendor;
 | 
			
		||||
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Objects;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository {
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<TransactionVendor> findById(long id) {
 | 
			
		||||
        return DbUtil.findById(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_vendor WHERE id = ?",
 | 
			
		||||
                id,
 | 
			
		||||
                JdbcTransactionVendorRepository::parseVendor
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public Optional<TransactionVendor> findByName(String name) {
 | 
			
		||||
        return DbUtil.findOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_vendor WHERE name = ?",
 | 
			
		||||
                List.of(name),
 | 
			
		||||
                JdbcTransactionVendorRepository::parseVendor
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public List<TransactionVendor> findAll() {
 | 
			
		||||
        return DbUtil.findAll(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT * FROM transaction_vendor ORDER BY name ASC",
 | 
			
		||||
                JdbcTransactionVendorRepository::parseVendor
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long insert(String name, String description) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
 | 
			
		||||
                List.of(name, description)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long insert(String name) {
 | 
			
		||||
        return DbUtil.insertOne(
 | 
			
		||||
                conn,
 | 
			
		||||
                "INSERT INTO transaction_vendor (name) VALUES (?)",
 | 
			
		||||
                List.of(name)
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void update(long id, String name, String description) {
 | 
			
		||||
        DbUtil.doTransaction(conn, () -> {
 | 
			
		||||
            TransactionVendor vendor = findById(id).orElseThrow();
 | 
			
		||||
            if (!vendor.getName().equals(name)) {
 | 
			
		||||
                DbUtil.updateOne(
 | 
			
		||||
                        conn,
 | 
			
		||||
                        "UPDATE transaction_vendor SET name = ? WHERE id = ?",
 | 
			
		||||
                        name,
 | 
			
		||||
                        id
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            if (!Objects.equals(vendor.getDescription(), description)) {
 | 
			
		||||
                DbUtil.updateOne(
 | 
			
		||||
                        conn,
 | 
			
		||||
                        "UPDATE transaction_vendor SET description = ? WHERE id = ?",
 | 
			
		||||
                        description,
 | 
			
		||||
                        id
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void deleteById(long id) {
 | 
			
		||||
        DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void close() throws Exception {
 | 
			
		||||
        conn.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static TransactionVendor parseVendor(ResultSet rs) throws SQLException {
 | 
			
		||||
        return new TransactionVendor(
 | 
			
		||||
                rs.getLong("id"),
 | 
			
		||||
                rs.getString("name"),
 | 
			
		||||
                rs.getString("description")
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -4,10 +4,20 @@ import java.util.HashMap;
 | 
			
		|||
import java.util.List;
 | 
			
		||||
import java.util.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<Integer, Migration> getMigrations() {
 | 
			
		||||
        final Map<Integer, Migration> migrations = new HashMap<>();
 | 
			
		||||
        migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql"));
 | 
			
		||||
        migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
 | 
			
		||||
        migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
 | 
			
		||||
        return migrations;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -25,4 +35,14 @@ public class Migrations {
 | 
			
		|||
        }
 | 
			
		||||
        return selectedMigration;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Map<Integer, String> getSchemaVersionCompatibility() {
 | 
			
		||||
        final Map<Integer, String> compatibilities = new HashMap<>();
 | 
			
		||||
        compatibilities.put(1, "1.4.0");
 | 
			
		||||
        return compatibilities;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static String getLatestCompatibleVersion(int schemaVersion) {
 | 
			
		||||
        return getSchemaVersionCompatibility().get(schemaVersion);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.search;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * An entity searcher will search for entities matching a list of filters.
 | 
			
		||||
 * @param <T> The entity type to search over.
 | 
			
		||||
 */
 | 
			
		||||
public interface EntitySearcher<T> {
 | 
			
		||||
    /**
 | 
			
		||||
     * Gets a page of results that match the given filters.
 | 
			
		||||
     * @param pageRequest The page request.
 | 
			
		||||
     * @param filters The filters to apply.
 | 
			
		||||
     * @return A page of results.
 | 
			
		||||
     */
 | 
			
		||||
    Page<T> search(PageRequest pageRequest, List<SearchFilter> filters);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Gets the number of results that would be returned for a given set of
 | 
			
		||||
     * filters.
 | 
			
		||||
     * @param filters The filters to apply.
 | 
			
		||||
     * @return The number of entities that match.
 | 
			
		||||
     */
 | 
			
		||||
    long resultCount(List<SearchFilter> filters);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,118 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.search;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Page;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.Pair;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.ResultSetMapper;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.PreparedStatement;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.stream.Collectors;
 | 
			
		||||
 | 
			
		||||
public class JdbcEntitySearcher<T> implements EntitySearcher<T> {
 | 
			
		||||
    private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class);
 | 
			
		||||
 | 
			
		||||
    private final Connection conn;
 | 
			
		||||
    private final String countExpression;
 | 
			
		||||
    private final String selectExpression;
 | 
			
		||||
    private final ResultSetMapper<T> resultSetMapper;
 | 
			
		||||
 | 
			
		||||
    public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper<T> resultSetMapper) {
 | 
			
		||||
        this.conn = conn;
 | 
			
		||||
        this.countExpression = countExpression;
 | 
			
		||||
        this.selectExpression = selectExpression;
 | 
			
		||||
        this.resultSetMapper = resultSetMapper;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Pair<String, List<Pair<Integer, Object>>> buildSearchQuery(List<SearchFilter> filters) {
 | 
			
		||||
        if (filters.isEmpty()) return new Pair<>("", Collections.emptyList());
 | 
			
		||||
        StringBuilder sb = new StringBuilder();
 | 
			
		||||
        List<Pair<Integer, Object>> args = new ArrayList<>();
 | 
			
		||||
        for (var filter : filters) {
 | 
			
		||||
            args.addAll(filter.args());
 | 
			
		||||
            for (var joinClause : filter.joinClauses()) {
 | 
			
		||||
                sb.append(joinClause).append('\n');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        sb.append("WHERE\n");
 | 
			
		||||
        for (int i = 0; i < filters.size(); i++) {
 | 
			
		||||
            sb.append(filters.get(i).whereClause());
 | 
			
		||||
            if (i < filters.size() - 1) {
 | 
			
		||||
                sb.append(" AND");
 | 
			
		||||
            }
 | 
			
		||||
            sb.append('\n');
 | 
			
		||||
        }
 | 
			
		||||
        return new Pair<>(sb.toString(), args);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void applyArgs(PreparedStatement stmt, List<Pair<Integer, Object>> args) throws SQLException {
 | 
			
		||||
        for (int i = 1; i <= args.size(); i++) {
 | 
			
		||||
            Pair<Integer, Object> arg = args.get(i - 1);
 | 
			
		||||
            if (arg.second() == null) {
 | 
			
		||||
                stmt.setNull(i, arg.first());
 | 
			
		||||
            } else {
 | 
			
		||||
                stmt.setObject(i, arg.second(), arg.first());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public Page<T> search(PageRequest pageRequest, List<SearchFilter> filters) {
 | 
			
		||||
        var baseQueryAndArgs = buildSearchQuery(filters);
 | 
			
		||||
        StringBuilder sqlBuilder = new StringBuilder(selectExpression);
 | 
			
		||||
        if (baseQueryAndArgs.first() != null && !baseQueryAndArgs.first().isBlank()) {
 | 
			
		||||
            sqlBuilder.append('\n').append(baseQueryAndArgs.first());
 | 
			
		||||
        }
 | 
			
		||||
        String pagingSql = pageRequest.toSQL();
 | 
			
		||||
        if (pagingSql != null && !pagingSql.isBlank()) {
 | 
			
		||||
            sqlBuilder.append('\n').append(pagingSql);
 | 
			
		||||
        }
 | 
			
		||||
        String sql = sqlBuilder.toString();
 | 
			
		||||
        logger.debug(
 | 
			
		||||
                "Searching with query:\n{}\nWith arguments: {}",
 | 
			
		||||
                sql,
 | 
			
		||||
                baseQueryAndArgs.second().stream()
 | 
			
		||||
                        .map(Pair::second)
 | 
			
		||||
                        .map(Object::toString)
 | 
			
		||||
                        .collect(Collectors.joining(", "))
 | 
			
		||||
        );
 | 
			
		||||
        try (var stmt = conn.prepareStatement(sql)) {
 | 
			
		||||
            applyArgs(stmt, baseQueryAndArgs.second());
 | 
			
		||||
            ResultSet rs = stmt.executeQuery();
 | 
			
		||||
            List<T> results = new ArrayList<>(pageRequest.size());
 | 
			
		||||
            while (rs.next() && results.size() < pageRequest.size()) {
 | 
			
		||||
                results.add(resultSetMapper.map(rs));
 | 
			
		||||
            }
 | 
			
		||||
            return new Page<>(results, pageRequest);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            logger.error("Search failed.", e);
 | 
			
		||||
            return new Page<>(Collections.emptyList(), pageRequest);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public long resultCount(List<SearchFilter> filters) {
 | 
			
		||||
        var baseQueryAndArgs = buildSearchQuery(filters);
 | 
			
		||||
        String sql = countExpression + "\n" + baseQueryAndArgs.first();
 | 
			
		||||
        try (var stmt = conn.prepareStatement(sql)) {
 | 
			
		||||
            applyArgs(stmt, baseQueryAndArgs.second());
 | 
			
		||||
            ResultSet rs = stmt.executeQuery();
 | 
			
		||||
            if (!rs.next()) throw new SQLException("No count result.");
 | 
			
		||||
            return rs.getLong(1);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            logger.error("Failed to get search result count.", e);
 | 
			
		||||
            return 0L;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static class Builder {
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.search;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.Transaction;
 | 
			
		||||
 | 
			
		||||
import java.math.BigDecimal;
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.util.Currency;
 | 
			
		||||
 | 
			
		||||
public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
 | 
			
		||||
    public JdbcTransactionSearcher(Connection conn) {
 | 
			
		||||
        super(
 | 
			
		||||
                conn,
 | 
			
		||||
                "SELECT COUNT(transaction.id) FROM transaction",
 | 
			
		||||
                "SELECT transaction.* FROM transaction",
 | 
			
		||||
                JdbcTransactionSearcher::parseResultSet
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Transaction parseResultSet(ResultSet rs) throws SQLException {
 | 
			
		||||
        long id = rs.getLong(1);
 | 
			
		||||
        LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2));
 | 
			
		||||
        BigDecimal amount = rs.getBigDecimal(3);
 | 
			
		||||
        Currency currency = Currency.getInstance(rs.getString(4));
 | 
			
		||||
        String description = rs.getString(5);
 | 
			
		||||
        Long vendorId = rs.getLong(6);
 | 
			
		||||
        if (rs.wasNull()) vendorId = null;
 | 
			
		||||
        Long categoryId = rs.getLong(7);
 | 
			
		||||
        if (rs.wasNull()) categoryId = null;
 | 
			
		||||
        return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,61 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.search;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.Pair;
 | 
			
		||||
 | 
			
		||||
import java.sql.Types;
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
public interface SearchFilter {
 | 
			
		||||
    String whereClause();
 | 
			
		||||
    List<Pair<Integer, Object>> args();
 | 
			
		||||
    default List<String> joinClauses() {
 | 
			
		||||
        return Collections.emptyList();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    record Impl(String whereClause, List<Pair<Integer, Object>> args, List<String> joinClauses) implements SearchFilter {}
 | 
			
		||||
 | 
			
		||||
    class Builder {
 | 
			
		||||
        private String whereClause;
 | 
			
		||||
        private List<Pair<Integer, Object>> args = new ArrayList<>();
 | 
			
		||||
        private List<String> joinClauses = new ArrayList<>();
 | 
			
		||||
 | 
			
		||||
        public Builder where(String clause) {
 | 
			
		||||
            this.whereClause = clause;
 | 
			
		||||
            return this;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(int sqlType, Object value) {
 | 
			
		||||
            args.add(new Pair<>(sqlType, value));
 | 
			
		||||
            return this;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(int value) {
 | 
			
		||||
            return withArg(Types.INTEGER, value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(long value) {
 | 
			
		||||
            return withArg(Types.BIGINT, value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(String value) {
 | 
			
		||||
            return withArg(Types.VARCHAR, value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withArg(LocalDateTime utcTimestamp) {
 | 
			
		||||
            return withArg(Types.TIMESTAMP, DbUtil.timestampFromUtcLDT(utcTimestamp));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public Builder withJoin(String joinClause) {
 | 
			
		||||
            joinClauses.add(joinClause);
 | 
			
		||||
            return this;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public SearchFilter build() {
 | 
			
		||||
            return new Impl(whereClause, args, joinClauses);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.util;
 | 
			
		||||
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
public class ColorUtil {
 | 
			
		||||
    public static String toHex(Color color) {
 | 
			
		||||
        return formatColorDouble(color.getRed()) + formatColorDouble(color.getGreen()) + formatColorDouble(color.getBlue());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static String formatColorDouble(double val) {
 | 
			
		||||
        String in = Integer.toHexString((int) Math.round(val * 255));
 | 
			
		||||
        return in.length() == 1 ? "0" + in : in;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +31,15 @@ public final class DbUtil {
 | 
			
		|||
        setArgs(stmt, List.of(args));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static long getGeneratedId(PreparedStatement stmt) {
 | 
			
		||||
        try (ResultSet rs = stmt.getGeneratedKeys()) {
 | 
			
		||||
            if (!rs.next()) throw new SQLException("No generated keys available.");
 | 
			
		||||
            return rs.getLong(1);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            throw new UncheckedSqlException(e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
 | 
			
		||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
			
		||||
            setArgs(stmt, args);
 | 
			
		||||
| 
						 | 
				
			
			@ -58,6 +67,17 @@ public final class DbUtil {
 | 
			
		|||
        return findAll(conn, query, pagination, Collections.emptyList(), mapper);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static long count(Connection conn, String query, Object... args) {
 | 
			
		||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
			
		||||
            setArgs(stmt, args);
 | 
			
		||||
            var rs = stmt.executeQuery();
 | 
			
		||||
            if (!rs.next()) throw new UncheckedSqlException("No count result available.");
 | 
			
		||||
            return rs.getLong(1);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            throw new UncheckedSqlException(e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
 | 
			
		||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
			
		||||
            setArgs(stmt, args);
 | 
			
		||||
| 
						 | 
				
			
			@ -82,6 +102,10 @@ public final class DbUtil {
 | 
			
		|||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static int update(Connection conn, String query, Object... args) {
 | 
			
		||||
        return update(conn, query, List.of(args));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void updateOne(Connection conn, String query, List<Object> args) {
 | 
			
		||||
        try (var stmt = conn.prepareStatement(query)) {
 | 
			
		||||
            setArgs(stmt, args);
 | 
			
		||||
| 
						 | 
				
			
			@ -92,19 +116,25 @@ public final class DbUtil {
 | 
			
		|||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void updateOne(Connection conn, String query, Object... args) {
 | 
			
		||||
        updateOne(conn, query, List.of(args));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static long insertOne(Connection conn, String query, List<Object> args) {
 | 
			
		||||
        try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
 | 
			
		||||
            setArgs(stmt, args);
 | 
			
		||||
            int result = stmt.executeUpdate();
 | 
			
		||||
            if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
 | 
			
		||||
            var rs = stmt.getGeneratedKeys();
 | 
			
		||||
            rs.next();
 | 
			
		||||
            return rs.getLong(1);
 | 
			
		||||
            return getGeneratedId(stmt);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            throw new UncheckedSqlException(e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static long insertOne(Connection conn, String query, Object... args) {
 | 
			
		||||
        return insertOne(conn, query, List.of(args));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
 | 
			
		||||
        return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -132,7 +162,9 @@ public final class DbUtil {
 | 
			
		|||
    public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
 | 
			
		||||
        try {
 | 
			
		||||
            conn.setAutoCommit(false);
 | 
			
		||||
            return supplier.offer();
 | 
			
		||||
            T result = supplier.offer();
 | 
			
		||||
            conn.commit();
 | 
			
		||||
            return result;
 | 
			
		||||
        } catch (Exception e) {
 | 
			
		||||
            try {
 | 
			
		||||
                conn.rollback();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.util;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import javafx.stage.FileChooser;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
| 
						 | 
				
			
			@ -103,4 +104,14 @@ public class FileUtil {
 | 
			
		|||
            throw new RuntimeException(e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void copyResourceFile(String resource, Path dest) throws IOException {
 | 
			
		||||
        try (
 | 
			
		||||
                var in = Profile.class.getResourceAsStream(resource);
 | 
			
		||||
                var out = Files.newOutputStream(dest)
 | 
			
		||||
        ) {
 | 
			
		||||
            if (in == null) throw new IOException("Could not load resource " + resource);
 | 
			
		||||
            in.transferTo(out);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,19 +2,14 @@ package com.andrewlalis.perfin.model;
 | 
			
		|||
 | 
			
		||||
import com.andrewlalis.perfin.PerfinApp;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.nio.file.Files;
 | 
			
		||||
import java.lang.ref.WeakReference;
 | 
			
		||||
import java.nio.file.Path;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.HashSet;
 | 
			
		||||
import java.util.Properties;
 | 
			
		||||
import java.util.Set;
 | 
			
		||||
import java.util.function.Consumer;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			@ -33,35 +28,22 @@ import java.util.function.Consumer;
 | 
			
		|||
 *     class maintains a static <em>current</em> profile that can be loaded and
 | 
			
		||||
 *     unloaded.
 | 
			
		||||
 * </p>
 | 
			
		||||
 *
 | 
			
		||||
 * @param name The name of the profile.
 | 
			
		||||
 * @param settings The profile's settings.
 | 
			
		||||
 * @param dataSource The profile's data source.
 | 
			
		||||
 */
 | 
			
		||||
public class Profile {
 | 
			
		||||
public record Profile(String name, Properties settings, DataSource dataSource) {
 | 
			
		||||
    private static final Logger log = LoggerFactory.getLogger(Profile.class);
 | 
			
		||||
 | 
			
		||||
    private static Profile current;
 | 
			
		||||
    private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>();
 | 
			
		||||
    private static final Set<WeakReference<Consumer<Profile>>> currentProfileListeners = new HashSet<>();
 | 
			
		||||
 | 
			
		||||
    private final String name;
 | 
			
		||||
    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<Profile> consumer = ref.get();
 | 
			
		||||
            if (consumer != null) {
 | 
			
		||||
                consumer.accept(profile);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        currentProfileListeners.removeIf(ref -> ref.get() == null);
 | 
			
		||||
        log.debug("Current profile set to {}.", current.name());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void whenLoaded(Consumer<Profile> consumer) {
 | 
			
		||||
        if (current != null) {
 | 
			
		||||
            consumer.accept(current);
 | 
			
		||||
        } else {
 | 
			
		||||
            profileLoadListeners.add(consumer);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static List<String> getAvailableProfiles() {
 | 
			
		||||
        try (var files = Files.list(PerfinApp.APP_DIR)) {
 | 
			
		||||
            return files.filter(Files::isDirectory)
 | 
			
		||||
                    .map(path -> path.getFileName().toString())
 | 
			
		||||
                    .sorted().toList();
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to get a list of available profiles.", e);
 | 
			
		||||
            return Collections.emptyList();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static String getLastProfile() {
 | 
			
		||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
			
		||||
        if (Files.exists(lastProfileFile)) {
 | 
			
		||||
            try {
 | 
			
		||||
                String s = Files.readString(lastProfileFile).strip().toLowerCase();
 | 
			
		||||
                if (!s.isBlank()) return s;
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                log.error("Failed to read " + lastProfileFile, e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return "default";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void saveLastProfile(String name) {
 | 
			
		||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
			
		||||
        try {
 | 
			
		||||
            Files.writeString(lastProfileFile, name);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to write " + lastProfileFile, e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void loadLast() throws ProfileLoadException {
 | 
			
		||||
        load(getLastProfile());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void load(String name) throws ProfileLoadException {
 | 
			
		||||
        if (Files.notExists(getDir(name))) {
 | 
			
		||||
            try {
 | 
			
		||||
                initProfileDir(name);
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                FileUtil.deleteIfPossible(getDir(name));
 | 
			
		||||
                throw new ProfileLoadException("Failed to initialize new profile directory.", e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        Properties settings = new Properties();
 | 
			
		||||
        try (var in = Files.newInputStream(getSettingsFile(name))) {
 | 
			
		||||
            settings.load(in);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            throw new ProfileLoadException("Failed to load profile settings.", e);
 | 
			
		||||
        }
 | 
			
		||||
        current = new Profile(name, settings, new JdbcDataSourceFactory().getDataSource(name));
 | 
			
		||||
        saveLastProfile(current.getName());
 | 
			
		||||
        for (var c : profileLoadListeners) {
 | 
			
		||||
            c.accept(current);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static void initProfileDir(String name) throws IOException {
 | 
			
		||||
        Files.createDirectory(getDir(name));
 | 
			
		||||
        copyResourceFile("/text/profileDirReadme.txt", getDir(name).resolve("README.txt"));
 | 
			
		||||
        copyResourceFile("/text/defaultProfileSettings.properties", getSettingsFile(name));
 | 
			
		||||
        Files.createDirectory(getContentDir(name));
 | 
			
		||||
        copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt"));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static void copyResourceFile(String resource, Path dest) throws IOException {
 | 
			
		||||
        try (
 | 
			
		||||
                var in = Profile.class.getResourceAsStream(resource);
 | 
			
		||||
                var out = Files.newOutputStream(dest)
 | 
			
		||||
        ) {
 | 
			
		||||
            if (in == null) throw new IOException("Could not load resource " + resource);
 | 
			
		||||
            in.transferTo(out);
 | 
			
		||||
        }
 | 
			
		||||
        currentProfileListeners.add(new WeakReference<>(consumer));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static boolean validateName(String name) {
 | 
			
		||||
| 
						 | 
				
			
			@ -168,9 +84,4 @@ public class Profile {
 | 
			
		|||
                name.matches("\\w+") &&
 | 
			
		||||
                name.toLowerCase().equals(name);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public String toString() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,116 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.PerfinApp;
 | 
			
		||||
import com.andrewlalis.perfin.control.Popups;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSourceFactory;
 | 
			
		||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.migration.Migrations;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
			
		||||
import javafx.stage.Window;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.nio.file.Files;
 | 
			
		||||
import java.nio.file.Path;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Properties;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component responsible for loading a profile from storage, as well as some
 | 
			
		||||
 * other basic tasks concerning the set of stored profiles.
 | 
			
		||||
 */
 | 
			
		||||
public class ProfileLoader {
 | 
			
		||||
    private static final Logger log = LoggerFactory.getLogger(ProfileLoader.class);
 | 
			
		||||
 | 
			
		||||
    private final Window window;
 | 
			
		||||
    private final DataSourceFactory dataSourceFactory;
 | 
			
		||||
 | 
			
		||||
    public ProfileLoader(Window window, DataSourceFactory dataSourceFactory) {
 | 
			
		||||
        this.window = window;
 | 
			
		||||
        this.dataSourceFactory = dataSourceFactory;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Profile load(String name) throws ProfileLoadException {
 | 
			
		||||
        if (Files.notExists(Profile.getDir(name))) {
 | 
			
		||||
            try {
 | 
			
		||||
                initProfileDir(name);
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                FileUtil.deleteIfPossible(Profile.getDir(name));
 | 
			
		||||
                throw new ProfileLoadException("Failed to initialize new profile directory.", e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        Properties settings = new Properties();
 | 
			
		||||
        try (var in = Files.newInputStream(Profile.getSettingsFile(name))) {
 | 
			
		||||
            settings.load(in);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            throw new ProfileLoadException("Failed to load profile settings.", e);
 | 
			
		||||
        }
 | 
			
		||||
        try {
 | 
			
		||||
            DataSourceFactory.SchemaStatus status = dataSourceFactory.getSchemaStatus(name);
 | 
			
		||||
            if (status == DataSourceFactory.SchemaStatus.NEEDS_MIGRATION) {
 | 
			
		||||
                boolean confirm = Popups.confirm(window, "The profile \"" + name + "\" has an outdated data schema and needs to be migrated to the latest version. Is this okay?");
 | 
			
		||||
                if (!confirm) {
 | 
			
		||||
                    int existingSchemaVersion = dataSourceFactory.getSchemaVersion(name);
 | 
			
		||||
                    String compatibleVersion = Migrations.getLatestCompatibleVersion(existingSchemaVersion);
 | 
			
		||||
                    Popups.message(
 | 
			
		||||
                            window,
 | 
			
		||||
                            "The profile \"" + name + "\" is using schema version " + existingSchemaVersion + ", which is compatible with Perfin version " + compatibleVersion + ". Consider downgrading Perfin to access this profile safely."
 | 
			
		||||
                    );
 | 
			
		||||
                    throw new ProfileLoadException("User rejected the migration.");
 | 
			
		||||
                }
 | 
			
		||||
            } else if (status == DataSourceFactory.SchemaStatus.INCOMPATIBLE) {
 | 
			
		||||
                Popups.error(window, "The profile \"" + name + "\" has a data schema that's incompatible with this app. Update Perfin to access this profile safely.");
 | 
			
		||||
                throw new ProfileLoadException("Incompatible schema version.");
 | 
			
		||||
            }
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            throw new ProfileLoadException("Failed to get profile's schema status.", e);
 | 
			
		||||
        }
 | 
			
		||||
        return new Profile(name, settings, dataSourceFactory.getDataSource(name));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static List<String> getAvailableProfiles() {
 | 
			
		||||
        try (var files = Files.list(PerfinApp.APP_DIR)) {
 | 
			
		||||
            return files.filter(Files::isDirectory)
 | 
			
		||||
                    .map(path -> path.getFileName().toString())
 | 
			
		||||
                    .sorted().toList();
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to get a list of available profiles.", e);
 | 
			
		||||
            return Collections.emptyList();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static String getLastProfile() {
 | 
			
		||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
			
		||||
        if (Files.exists(lastProfileFile)) {
 | 
			
		||||
            try {
 | 
			
		||||
                String s = Files.readString(lastProfileFile).strip().toLowerCase();
 | 
			
		||||
                if (!s.isBlank()) return s;
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                log.error("Failed to read " + lastProfileFile, e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return "default";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void saveLastProfile(String name) {
 | 
			
		||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
			
		||||
        try {
 | 
			
		||||
            Files.writeString(lastProfileFile, name);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to write " + lastProfileFile, e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Deprecated
 | 
			
		||||
    private static void initProfileDir(String name) throws IOException {
 | 
			
		||||
        Files.createDirectory(Profile.getDir(name));
 | 
			
		||||
        copyResourceFile("/text/profileDirReadme.txt", Profile.getDir(name).resolve("README.txt"));
 | 
			
		||||
        copyResourceFile("/text/defaultProfileSettings.properties", Profile.getSettingsFile(name));
 | 
			
		||||
        Files.createDirectory(Profile.getContentDir(name));
 | 
			
		||||
        copyResourceFile("/text/contentDirReadme.txt", Profile.getContentDir(name).resolve("README.txt"));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -17,13 +17,17 @@ public class Transaction extends IdEntity {
 | 
			
		|||
    private final BigDecimal amount;
 | 
			
		||||
    private final 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);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
import javafx.scene.paint.Color;
 | 
			
		||||
 | 
			
		||||
public class TransactionCategory extends IdEntity {
 | 
			
		||||
    public static final int NAME_MAX_LENGTH = 63;
 | 
			
		||||
 | 
			
		||||
    private final Long parentId;
 | 
			
		||||
    private final String name;
 | 
			
		||||
    private final Color color;
 | 
			
		||||
 | 
			
		||||
    public TransactionCategory(long id, Long parentId, String name, Color color) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.parentId = parentId;
 | 
			
		||||
        this.name = name;
 | 
			
		||||
        this.color = color;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Long getParentId() {
 | 
			
		||||
        return parentId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getName() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Color getColor() {
 | 
			
		||||
        return color;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public String toString() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,65 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
import java.math.BigDecimal;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A line item that comprises part of a transaction. Its total value (value per
 | 
			
		||||
 * item * quantity) is part of the transaction's total value. It can be used to
 | 
			
		||||
 * record some transactions, like purchases and invoices, in more granular
 | 
			
		||||
 * detail.
 | 
			
		||||
 */
 | 
			
		||||
public class TransactionLineItem extends IdEntity {
 | 
			
		||||
    public static final int DESCRIPTION_MAX_LENGTH = 255;
 | 
			
		||||
 | 
			
		||||
    private final long transactionId;
 | 
			
		||||
    private final BigDecimal valuePerItem;
 | 
			
		||||
    private final int quantity;
 | 
			
		||||
    private final int idx;
 | 
			
		||||
    private final String description;
 | 
			
		||||
 | 
			
		||||
    public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.transactionId = transactionId;
 | 
			
		||||
        this.valuePerItem = valuePerItem;
 | 
			
		||||
        this.quantity = quantity;
 | 
			
		||||
        this.idx = idx;
 | 
			
		||||
        this.description = description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public long getTransactionId() {
 | 
			
		||||
        return transactionId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public BigDecimal getValuePerItem() {
 | 
			
		||||
        return valuePerItem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public int getQuantity() {
 | 
			
		||||
        return quantity;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public int getIdx() {
 | 
			
		||||
        return idx;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getDescription() {
 | 
			
		||||
        return description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public BigDecimal getTotalValue() {
 | 
			
		||||
        return valuePerItem.multiply(new BigDecimal(quantity));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public String toString() {
 | 
			
		||||
        return String.format(
 | 
			
		||||
                "TransactionLineItem(id=%d, transactionId=%d, valuePerItem=%s, quantity=%d, idx=%d, description=\"%s\")",
 | 
			
		||||
                id,
 | 
			
		||||
                transactionId,
 | 
			
		||||
                valuePerItem.toPlainString(),
 | 
			
		||||
                quantity,
 | 
			
		||||
                idx,
 | 
			
		||||
                description
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A tag that can be applied to a transaction to add some user-defined semantic
 | 
			
		||||
 * meaning to it.
 | 
			
		||||
 */
 | 
			
		||||
public class TransactionTag extends IdEntity {
 | 
			
		||||
    public static final int NAME_MAX_LENGTH = 63;
 | 
			
		||||
    private final String name;
 | 
			
		||||
 | 
			
		||||
    public TransactionTag(long id, String name) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.name = name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getName() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A vendor is a business establishment that can be linked to a transaction, to
 | 
			
		||||
 * denote the business that the transaction took place with.
 | 
			
		||||
 */
 | 
			
		||||
public class TransactionVendor extends IdEntity {
 | 
			
		||||
    public static final int NAME_MAX_LENGTH = 255;
 | 
			
		||||
    public static final int DESCRIPTION_MAX_LENGTH = 255;
 | 
			
		||||
 | 
			
		||||
    private final String name;
 | 
			
		||||
    private final String description;
 | 
			
		||||
 | 
			
		||||
    public TransactionVendor(long id, String name, String description) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.name = name;
 | 
			
		||||
        this.description = description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getName() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getDescription() {
 | 
			
		||||
        return description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public String toString() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,36 +0,0 @@
 | 
			
		|||
package com.andrewlalis.perfin.model.history;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.model.IdEntity;
 | 
			
		||||
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The base class representing account history items, a read-only record of an
 | 
			
		||||
 * account's data and changes over time. The type of history item determines
 | 
			
		||||
 * what exactly it means, and could be something like an account entry, balance
 | 
			
		||||
 * record, or modifications to the account's properties.
 | 
			
		||||
 */
 | 
			
		||||
public class AccountHistoryItem extends IdEntity {
 | 
			
		||||
    private final LocalDateTime timestamp;
 | 
			
		||||
    private final long accountId;
 | 
			
		||||
    private final AccountHistoryItemType type;
 | 
			
		||||
 | 
			
		||||
    public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.timestamp = timestamp;
 | 
			
		||||
        this.accountId = accountId;
 | 
			
		||||
        this.type = type;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public LocalDateTime getTimestamp() {
 | 
			
		||||
        return timestamp;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public long getAccountId() {
 | 
			
		||||
        return accountId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public AccountHistoryItemType getType() {
 | 
			
		||||
        return type;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +0,0 @@
 | 
			
		|||
package com.andrewlalis.perfin.model.history;
 | 
			
		||||
 | 
			
		||||
public enum AccountHistoryItemType {
 | 
			
		||||
    TEXT,
 | 
			
		||||
    ACCOUNT_ENTRY,
 | 
			
		||||
    BALANCE_RECORD
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
package com.andrewlalis.perfin.model.history;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.model.IdEntity;
 | 
			
		||||
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Represents a single polymorphic history item. The item's "type" attribute
 | 
			
		||||
 * tells where to find additional type-specific data.
 | 
			
		||||
 */
 | 
			
		||||
public abstract class HistoryItem extends IdEntity {
 | 
			
		||||
    public static final String TYPE_TEXT = "TEXT";
 | 
			
		||||
 | 
			
		||||
    private final long historyId;
 | 
			
		||||
    private final LocalDateTime timestamp;
 | 
			
		||||
    private final String type;
 | 
			
		||||
 | 
			
		||||
    public HistoryItem(long id, long historyId, LocalDateTime timestamp, String type) {
 | 
			
		||||
        super(id);
 | 
			
		||||
        this.historyId = historyId;
 | 
			
		||||
        this.timestamp = timestamp;
 | 
			
		||||
        this.type = type;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public long getHistoryId() {
 | 
			
		||||
        return historyId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public LocalDateTime getTimestamp() {
 | 
			
		||||
        return timestamp;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getType() {
 | 
			
		||||
        return type;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
package com.andrewlalis.perfin.model.history;
 | 
			
		||||
 | 
			
		||||
import java.time.LocalDateTime;
 | 
			
		||||
 | 
			
		||||
public class HistoryTextItem extends HistoryItem {
 | 
			
		||||
    private final String description;
 | 
			
		||||
 | 
			
		||||
    public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) {
 | 
			
		||||
        super(id, historyId, timestamp, HistoryItem.TYPE_TEXT);
 | 
			
		||||
        this.description = description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getDescription() {
 | 
			
		||||
        return description;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,8 +1,10 @@
 | 
			
		|||
package com.andrewlalis.perfin.view;
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<String> {
 | 
			
		||||
    private final List<ThrowableConsumer<Consumer<String>>> tasks;
 | 
			
		||||
    private final boolean delayTasks;
 | 
			
		||||
    private boolean startupSuccessful = false;
 | 
			
		||||
 | 
			
		||||
    private final TextArea textArea = new TextArea();
 | 
			
		||||
 | 
			
		||||
    public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks) {
 | 
			
		||||
    public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks, boolean delayTasks) {
 | 
			
		||||
        this.tasks = tasks;
 | 
			
		||||
        this.delayTasks = delayTasks;
 | 
			
		||||
        setTitle("Starting Perfin...");
 | 
			
		||||
        setResizable(false);
 | 
			
		||||
        initStyle(StageStyle.UNDECORATED);
 | 
			
		||||
| 
						 | 
				
			
			@ -60,37 +63,50 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
 | 
			
		|||
        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<Void> 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);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,36 +0,0 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.control.TransactionsViewController;
 | 
			
		||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.AccountEntry;
 | 
			
		||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
			
		||||
import javafx.scene.control.Hyperlink;
 | 
			
		||||
import javafx.scene.text.Text;
 | 
			
		||||
import javafx.scene.text.TextFlow;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class AccountHistoryAccountEntryTile extends AccountHistoryItemTile {
 | 
			
		||||
    public AccountHistoryAccountEntryTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
 | 
			
		||||
        super(item);
 | 
			
		||||
        AccountEntry entry = repo.getAccountEntryItem(item.id);
 | 
			
		||||
        if (entry == null) {
 | 
			
		||||
            setCenter(new TextFlow(new Text("Deleted account entry because of deleted transaction.")));
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue()));
 | 
			
		||||
        Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId());
 | 
			
		||||
        transactionLink.setOnAction(event -> router.navigate(
 | 
			
		||||
                "transactions",
 | 
			
		||||
                new TransactionsViewController.RouteContext(entry.getTransactionId())
 | 
			
		||||
        ));
 | 
			
		||||
        var text = new TextFlow(
 | 
			
		||||
                transactionLink,
 | 
			
		||||
                new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of "),
 | 
			
		||||
                amountText
 | 
			
		||||
        );
 | 
			
		||||
        setCenter(text);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,31 +0,0 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.control.AccountViewController;
 | 
			
		||||
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.BalanceRecord;
 | 
			
		||||
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
 | 
			
		||||
import javafx.scene.control.Hyperlink;
 | 
			
		||||
import javafx.scene.text.Text;
 | 
			
		||||
import javafx.scene.text.TextFlow;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile {
 | 
			
		||||
    public AccountHistoryBalanceRecordTile(AccountHistoryItem item, AccountHistoryItemRepository repo, AccountViewController controller) {
 | 
			
		||||
        super(item);
 | 
			
		||||
        BalanceRecord balanceRecord = repo.getBalanceRecordItem(item.id);
 | 
			
		||||
        if (balanceRecord == null) {
 | 
			
		||||
            setCenter(new TextFlow(new Text("Deleted balance record was added.")));
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount()));
 | 
			
		||||
        var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText);
 | 
			
		||||
        setCenter(text);
 | 
			
		||||
 | 
			
		||||
        Hyperlink viewLink = new Hyperlink("View this balance record");
 | 
			
		||||
        viewLink.setOnAction(event -> router.navigate("balance-record", balanceRecord));
 | 
			
		||||
        setBottom(viewLink);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,9 +1,8 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component;
 | 
			
		||||
 | 
			
		||||
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());
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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())));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox<Account> {
 | 
			
		|||
        showBalanceProperty.set(value);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static class CellFactory implements Callback<ListView<Account>, ListCell<Account>> {
 | 
			
		||||
        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<ListView<Account>, ListCell<Account>> {
 | 
			
		||||
        @Override
 | 
			
		||||
        public ListCell<Account> call(ListView<Account> 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<Account> {
 | 
			
		||||
        private final BooleanProperty showBalanceProp;
 | 
			
		||||
        private final Label nameLabel = new Label();
 | 
			
		||||
| 
						 | 
				
			
			@ -110,7 +112,7 @@ public class AccountSelectionBox extends ComboBox<Account> {
 | 
			
		|||
 | 
			
		||||
            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())));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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(() -> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -46,7 +46,7 @@ public class AttachmentPreview extends BorderPane {
 | 
			
		|||
        boolean showDocIcon = true;
 | 
			
		||||
        Set<String> 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());
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,81 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionCategory;
 | 
			
		||||
import javafx.geometry.Insets;
 | 
			
		||||
import javafx.scene.control.ComboBox;
 | 
			
		||||
import javafx.scene.control.Label;
 | 
			
		||||
import javafx.scene.control.ListCell;
 | 
			
		||||
import javafx.scene.layout.HBox;
 | 
			
		||||
import javafx.scene.shape.Circle;
 | 
			
		||||
 | 
			
		||||
import java.util.HashMap;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
 | 
			
		||||
public class CategorySelectionBox extends ComboBox<TransactionCategory> {
 | 
			
		||||
    private final Map<TransactionCategory, Integer> categoryIndentationLevels = new HashMap<>();
 | 
			
		||||
 | 
			
		||||
    public CategorySelectionBox() {
 | 
			
		||||
        setCellFactory(view -> new CategoryListCell(categoryIndentationLevels));
 | 
			
		||||
        setButtonCell(new CategoryListCell(null));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void loadCategories(List<TransactionCategoryRepository.CategoryTreeNode> treeNodes) {
 | 
			
		||||
        categoryIndentationLevels.clear();
 | 
			
		||||
        getItems().clear();
 | 
			
		||||
        populateCategories(treeNodes, 0);
 | 
			
		||||
        getItems().add(null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void populateCategories(
 | 
			
		||||
            List<TransactionCategoryRepository.CategoryTreeNode> treeNodes,
 | 
			
		||||
            int depth
 | 
			
		||||
    ) {
 | 
			
		||||
        for (var node : treeNodes) {
 | 
			
		||||
            getItems().add(node.category());
 | 
			
		||||
            categoryIndentationLevels.put(node.category(), depth);
 | 
			
		||||
            populateCategories(node.children(), depth + 1);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void select(TransactionCategory category) {
 | 
			
		||||
        setButtonCell(new CategoryListCell(null));
 | 
			
		||||
        getSelectionModel().select(category);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static class CategoryListCell extends ListCell<TransactionCategory> {
 | 
			
		||||
        private final Label nameLabel = new Label();
 | 
			
		||||
        private final Circle colorIndicator = new Circle(8);
 | 
			
		||||
        private final Map<TransactionCategory, Integer> categoryIndentationLevels;
 | 
			
		||||
 | 
			
		||||
        public CategoryListCell(Map<TransactionCategory, Integer> categoryIndentationLevels) {
 | 
			
		||||
            this.categoryIndentationLevels = categoryIndentationLevels;
 | 
			
		||||
            nameLabel.getStyleClass().add("normal-color-text-fill");
 | 
			
		||||
            colorIndicator.managedProperty().bind(colorIndicator.visibleProperty());
 | 
			
		||||
            HBox container = new HBox(colorIndicator, nameLabel);
 | 
			
		||||
            container.getStyleClass().add("std-spacing");
 | 
			
		||||
            setGraphic(container);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        @Override
 | 
			
		||||
        protected void updateItem(TransactionCategory item, boolean empty) {
 | 
			
		||||
            super.updateItem(item, empty);
 | 
			
		||||
            if (item == null || empty) {
 | 
			
		||||
                nameLabel.setText("None");
 | 
			
		||||
                colorIndicator.setVisible(false);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            nameLabel.setText(item.getName());
 | 
			
		||||
            if (categoryIndentationLevels != null) {
 | 
			
		||||
                HBox.setMargin(
 | 
			
		||||
                        colorIndicator,
 | 
			
		||||
                        new Insets(0, 0, 0, 10 * categoryIndentationLevels.getOrDefault(item, 0))
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            colorIndicator.setVisible(true);
 | 
			
		||||
            colorIndicator.setFill(item.getColor());
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,65 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.control.EditCategoryController;
 | 
			
		||||
import com.andrewlalis.perfin.control.Popups;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import javafx.scene.control.Button;
 | 
			
		||||
import javafx.scene.control.Label;
 | 
			
		||||
import javafx.scene.layout.BorderPane;
 | 
			
		||||
import javafx.scene.layout.HBox;
 | 
			
		||||
import javafx.scene.layout.VBox;
 | 
			
		||||
import javafx.scene.shape.Circle;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class CategoryTile extends VBox {
 | 
			
		||||
    public CategoryTile(
 | 
			
		||||
            TransactionCategoryRepository.CategoryTreeNode treeNode,
 | 
			
		||||
            Runnable categoriesRefresh
 | 
			
		||||
    ) {
 | 
			
		||||
        this.getStyleClass().addAll("tile", "spacing-extra", "hand-cursor");
 | 
			
		||||
        this.setStyle("-fx-border-width: 1px; -fx-border-color: grey;");
 | 
			
		||||
        this.setOnMouseClicked(event -> {
 | 
			
		||||
            event.consume();
 | 
			
		||||
            router.navigate(
 | 
			
		||||
                    "edit-category",
 | 
			
		||||
                    new EditCategoryController.CategoryRouteContext(treeNode.category())
 | 
			
		||||
            );
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        BorderPane borderPane = new BorderPane();
 | 
			
		||||
        borderPane.getStyleClass().addAll("std-padding");
 | 
			
		||||
        Label nameLabel = new Label(treeNode.category().getName());
 | 
			
		||||
        nameLabel.getStyleClass().addAll("bold-text");
 | 
			
		||||
        Circle colorCircle = new Circle(10, treeNode.category().getColor());
 | 
			
		||||
        HBox contentBox = new HBox(colorCircle, nameLabel);
 | 
			
		||||
        contentBox.getStyleClass().addAll("std-spacing");
 | 
			
		||||
        borderPane.setLeft(contentBox);
 | 
			
		||||
 | 
			
		||||
        Button addChildButton = new Button("Add Subcategory");
 | 
			
		||||
        addChildButton.setOnAction(event -> router.navigate(
 | 
			
		||||
                "edit-category",
 | 
			
		||||
                new EditCategoryController.AddSubcategoryRouteContext(treeNode.category())
 | 
			
		||||
        ));
 | 
			
		||||
        Button removeButton = new Button("Remove");
 | 
			
		||||
        removeButton.setOnAction(event -> {
 | 
			
		||||
            boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this category? It will permanently remove the category from all linked transactions, and all subcategories will also be removed. This cannot be undone.");
 | 
			
		||||
            if (confirm) {
 | 
			
		||||
                Profile.getCurrent().dataSource().useRepo(
 | 
			
		||||
                        TransactionCategoryRepository.class,
 | 
			
		||||
                        repo -> repo.deleteById(treeNode.category().id)
 | 
			
		||||
                );
 | 
			
		||||
                categoriesRefresh.run();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        HBox buttonsBox = new HBox(addChildButton, removeButton);
 | 
			
		||||
        buttonsBox.getStyleClass().addAll("std-spacing");
 | 
			
		||||
        borderPane.setRight(buttonsBox);
 | 
			
		||||
 | 
			
		||||
        this.getChildren().add(borderPane);
 | 
			
		||||
        for (var child : treeNode.children()) {
 | 
			
		||||
            this.getChildren().add(new CategoryTile(child, categoriesRefresh));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -100,7 +100,7 @@ public class TransactionTile extends BorderPane {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    private CompletableFuture<CreditAndDebitAccounts> getCreditAndDebitAccounts(Transaction transaction) {
 | 
			
		||||
        return Profile.getCurrent().getDataSource().mapRepoAsync(
 | 
			
		||||
        return Profile.getCurrent().dataSource().mapRepoAsync(
 | 
			
		||||
                TransactionRepository.class,
 | 
			
		||||
                repo -> repo.findLinkedAccounts(transaction.id)
 | 
			
		||||
        );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,46 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.control.Popups;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionVendor;
 | 
			
		||||
import javafx.geometry.Pos;
 | 
			
		||||
import javafx.scene.control.Button;
 | 
			
		||||
import javafx.scene.control.Label;
 | 
			
		||||
import javafx.scene.layout.BorderPane;
 | 
			
		||||
import javafx.scene.layout.VBox;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class VendorTile extends BorderPane {
 | 
			
		||||
    public VendorTile(TransactionVendor vendor, Runnable vendorRefresh) {
 | 
			
		||||
        this.getStyleClass().addAll("tile", "std-spacing", "hand-cursor");
 | 
			
		||||
        this.setOnMouseClicked(event -> router.navigate("edit-vendor", vendor));
 | 
			
		||||
 | 
			
		||||
        Label nameLabel = new Label(vendor.getName());
 | 
			
		||||
        nameLabel.getStyleClass().addAll("bold-text");
 | 
			
		||||
        Label descriptionLabel = new Label(vendor.getDescription());
 | 
			
		||||
        descriptionLabel.setWrapText(true);
 | 
			
		||||
        VBox contentVBox = new VBox(nameLabel, descriptionLabel);
 | 
			
		||||
        contentVBox.getStyleClass().addAll("std-spacing");
 | 
			
		||||
        this.setCenter(contentVBox);
 | 
			
		||||
        BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT);
 | 
			
		||||
 | 
			
		||||
        this.setRight(getRemoveButton(vendor, vendorRefresh));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Button getRemoveButton(TransactionVendor transactionVendor, Runnable vendorRefresh) {
 | 
			
		||||
        Button removeButton = new Button("Remove");
 | 
			
		||||
        removeButton.setOnAction(event -> {
 | 
			
		||||
            boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions assigned to this vendor will have their vendor field cleared. This cannot be undone.");
 | 
			
		||||
            if (confirm) {
 | 
			
		||||
                Profile.getCurrent().dataSource().useRepo(
 | 
			
		||||
                        TransactionVendorRepository.class,
 | 
			
		||||
                        repo -> repo.deleteById(transactionVendor.id)
 | 
			
		||||
                );
 | 
			
		||||
                vendorRefresh.run();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        return removeButton;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component.validation;
 | 
			
		||||
 | 
			
		||||
import java.util.concurrent.CompletableFuture;
 | 
			
		||||
 | 
			
		||||
public interface AsyncValidationFunction<T> {
 | 
			
		||||
    CompletableFuture<ValidationResult> validate(T input);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,24 +1,40 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component.validation;
 | 
			
		||||
 | 
			
		||||
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 <T> The value type.
 | 
			
		||||
 */
 | 
			
		||||
public class ValidationApplier<T> {
 | 
			
		||||
    private final ValidationFunction<T> validator;
 | 
			
		||||
    private final AsyncValidationFunction<T> validator;
 | 
			
		||||
    private ValidationDecorator decorator = new FieldSubtextDecorator();
 | 
			
		||||
    private boolean validateInitially = false;
 | 
			
		||||
 | 
			
		||||
    public ValidationApplier(ValidationFunction<T> validator) {
 | 
			
		||||
        this.validator = input -> CompletableFuture.completedFuture(validator.validate(input));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public ValidationApplier(AsyncValidationFunction<T> validator) {
 | 
			
		||||
        this.validator = validator;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static <T> ValidationApplier<T> of(ValidationFunction<T> validator) {
 | 
			
		||||
        return new ValidationApplier<>(validator);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static <T> ValidationApplier<T> ofAsync(AsyncValidationFunction<T> validator) {
 | 
			
		||||
        return new ValidationApplier<>(validator);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public ValidationApplier<T> decoratedWith(ValidationDecorator decorator) {
 | 
			
		||||
        this.decorator = decorator;
 | 
			
		||||
        return this;
 | 
			
		||||
| 
						 | 
				
			
			@ -29,24 +45,47 @@ public class ValidationApplier<T> {
 | 
			
		|||
        return this;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Attaches the configured validator and decorator to a node, so that when
 | 
			
		||||
     * the node's specified valueProperty changes, the validator will be called
 | 
			
		||||
     * and if the new value is invalid, the decorator will update the UI to
 | 
			
		||||
     * show the message(s) to the user.
 | 
			
		||||
     * @param node The node to attach to.
 | 
			
		||||
     * @param valueProperty The property to listen for changes and validate on.
 | 
			
		||||
     * @param triggerProperties Additional properties that, when changed, can
 | 
			
		||||
     *                          trigger validation.
 | 
			
		||||
     * @return A boolean expression that tells whether the given valueProperty
 | 
			
		||||
     * is valid at any given time.
 | 
			
		||||
     */
 | 
			
		||||
    public BooleanExpression attach(Node node, Property<T> valueProperty, Property<?>... triggerProperties) {
 | 
			
		||||
        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;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 <T> The value type.
 | 
			
		||||
 */
 | 
			
		||||
public class PredicateValidator<T> implements ValidationFunction<T> {
 | 
			
		||||
    private record ValidationStep<T>(Function<T, Boolean> predicate, String message, boolean terminal) {}
 | 
			
		||||
public class PredicateValidator<T> implements AsyncValidationFunction<T> {
 | 
			
		||||
    private static final Logger logger = LoggerFactory.getLogger(PredicateValidator.class);
 | 
			
		||||
 | 
			
		||||
    private record ValidationStep<T>(Function<T, CompletableFuture<Boolean>> predicate, String message, boolean terminal) {}
 | 
			
		||||
 | 
			
		||||
    private final List<ValidationStep<T>> steps = new ArrayList<>();
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
			
		||||
        steps.add(new ValidationStep<>(predicate, errorMessage, false));
 | 
			
		||||
    private PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage, boolean terminal) {
 | 
			
		||||
        steps.add(new ValidationStep<>(
 | 
			
		||||
                v -> CompletableFuture.completedFuture(predicate.apply(v)),
 | 
			
		||||
                errorMessage,
 | 
			
		||||
                terminal
 | 
			
		||||
        ));
 | 
			
		||||
        return this;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
			
		||||
        steps.add(new ValidationStep<>(predicate, errorMessage, true));
 | 
			
		||||
    private PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage, boolean terminal) {
 | 
			
		||||
        steps.add(new ValidationStep<>(asyncPredicate, errorMessage, terminal));
 | 
			
		||||
        return this;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
			
		||||
        return addPredicate(predicate, errorMessage, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
 | 
			
		||||
        return addAsyncPredicate(asyncPredicate, errorMessage, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Adds a terminal predicate, that is, if the given boolean function
 | 
			
		||||
     * evaluates to false, then no further predicates are evaluated.
 | 
			
		||||
     * @param predicate The predicate function.
 | 
			
		||||
     * @param errorMessage The error message to display if the predicate
 | 
			
		||||
     *                     evaluates to false for a given value.
 | 
			
		||||
     * @return A reference to the validator, for method chaining.
 | 
			
		||||
     */
 | 
			
		||||
    public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
			
		||||
        return addPredicate(predicate, errorMessage, true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addTerminalAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
 | 
			
		||||
        return addAsyncPredicate(asyncPredicate, errorMessage);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public ValidationResult validate(T input) {
 | 
			
		||||
        List<String> 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<ValidationResult> validate(T input) {
 | 
			
		||||
        CompletableFuture<ValidationResult> cf = new CompletableFuture<>();
 | 
			
		||||
        Thread.ofVirtual().start(() -> {
 | 
			
		||||
            List<String> 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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
 | 
			
		||||
<?import javafx.scene.layout.BorderPane?>
 | 
			
		||||
<?import javafx.scene.control.Label?>
 | 
			
		||||
<?import javafx.scene.layout.VBox?>
 | 
			
		||||
<?import javafx.scene.layout.HBox?>
 | 
			
		||||
<?import javafx.scene.control.Button?>
 | 
			
		||||
<?import javafx.scene.control.ScrollPane?>
 | 
			
		||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.CategoriesViewController"
 | 
			
		||||
>
 | 
			
		||||
    <top>
 | 
			
		||||
        <Label text="Transaction Categories" styleClass="large-font,bold-text,std-padding"/>
 | 
			
		||||
    </top>
 | 
			
		||||
    <center>
 | 
			
		||||
        <VBox>
 | 
			
		||||
            <StyledText maxWidth="500" styleClass="std-padding">
 | 
			
		||||
                Categories are used to group your transactions based on their
 | 
			
		||||
                purpose. It's helpful to categorize transactions in order to get
 | 
			
		||||
                a better view of your spending habits, and it makes it easier to
 | 
			
		||||
                lookup transactions later.
 | 
			
		||||
            </StyledText>
 | 
			
		||||
            <HBox styleClass="std-padding, std-spacing" VBox.vgrow="NEVER">
 | 
			
		||||
                <Button text="Add Category" onAction="#addCategory"/>
 | 
			
		||||
                <Button text="Add Default Categories" onAction="#addDefaultCategories"/>
 | 
			
		||||
            </HBox>
 | 
			
		||||
            <ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
 | 
			
		||||
                <VBox fx:id="categoriesVBox" styleClass="tile-container"/>
 | 
			
		||||
            </ScrollPane>
 | 
			
		||||
        </VBox>
 | 
			
		||||
    </center>
 | 
			
		||||
</BorderPane>
 | 
			
		||||
| 
						 | 
				
			
			@ -55,7 +55,7 @@
 | 
			
		|||
            </PropertiesPane>
 | 
			
		||||
 | 
			
		||||
            <Separator/>
 | 
			
		||||
            <HBox styleClass="std-padding,std-spacing">
 | 
			
		||||
            <HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
 | 
			
		||||
                <Button text="Save" fx:id="saveButton" onAction="#save"/>
 | 
			
		||||
                <Button text="Cancel" onAction="#cancel"/>
 | 
			
		||||
            </HBox>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
 | 
			
		||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
 | 
			
		||||
<?import javafx.scene.control.*?>
 | 
			
		||||
<?import javafx.scene.layout.*?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.EditCategoryController"
 | 
			
		||||
>
 | 
			
		||||
    <top>
 | 
			
		||||
        <Label text="Edit Transaction Category" styleClass="bold-text,large-font,std-padding"/>
 | 
			
		||||
    </top>
 | 
			
		||||
    <center>
 | 
			
		||||
        <VBox>
 | 
			
		||||
            <PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
 | 
			
		||||
                <columnConstraints>
 | 
			
		||||
                    <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
 | 
			
		||||
                    <ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
 | 
			
		||||
                </columnConstraints>
 | 
			
		||||
 | 
			
		||||
                <Label text="Name" labelFor="${nameField}"/>
 | 
			
		||||
                <TextField fx:id="nameField"/>
 | 
			
		||||
 | 
			
		||||
                <Label text="Color" labelFor="${colorPicker}"/>
 | 
			
		||||
                <ColorPicker fx:id="colorPicker"/>
 | 
			
		||||
            </PropertiesPane>
 | 
			
		||||
            <Separator/>
 | 
			
		||||
            <HBox styleClass="std-padding, std-spacing" alignment="CENTER_RIGHT">
 | 
			
		||||
                <Button text="Save" fx:id="saveButton" onAction="#save"/>
 | 
			
		||||
                <Button text="Cancel" onAction="#cancel"/>
 | 
			
		||||
            </HBox>
 | 
			
		||||
        </VBox>
 | 
			
		||||
    </center>
 | 
			
		||||
</BorderPane>
 | 
			
		||||
| 
						 | 
				
			
			@ -5,6 +5,8 @@
 | 
			
		|||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
 | 
			
		||||
<?import javafx.scene.control.*?>
 | 
			
		||||
<?import javafx.scene.layout.*?>
 | 
			
		||||
<?import com.andrewlalis.perfin.view.component.CategorySelectionBox?>
 | 
			
		||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
 | 
			
		||||
| 
						 | 
				
			
			@ -27,10 +29,10 @@
 | 
			
		|||
                    <TextField fx:id="timestampField" styleClass="mono-font"/>
 | 
			
		||||
 | 
			
		||||
                    <Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/>
 | 
			
		||||
                    <TextField fx:id="amountField" styleClass="mono-font"/>
 | 
			
		||||
 | 
			
		||||
                    <Label text="Currency" labelFor="${currencyChoiceBox}" styleClass="bold-text"/>
 | 
			
		||||
                    <ChoiceBox fx:id="currencyChoiceBox"/>
 | 
			
		||||
                    <HBox styleClass="std-spacing">
 | 
			
		||||
                        <TextField fx:id="amountField" styleClass="mono-font" HBox.hgrow="ALWAYS"/>
 | 
			
		||||
                        <ChoiceBox fx:id="currencyChoiceBox"/>
 | 
			
		||||
                    </HBox>
 | 
			
		||||
 | 
			
		||||
                    <Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
 | 
			
		||||
                    <TextArea
 | 
			
		||||
| 
						 | 
				
			
			@ -43,15 +45,78 @@
 | 
			
		|||
 | 
			
		||||
                <!-- Container for linked accounts -->
 | 
			
		||||
                <HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
 | 
			
		||||
                    <VBox>
 | 
			
		||||
                    <VBox HBox.hgrow="ALWAYS">
 | 
			
		||||
                        <Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
 | 
			
		||||
                        <AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                    <VBox>
 | 
			
		||||
                    <VBox HBox.hgrow="ALWAYS">
 | 
			
		||||
                        <Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
 | 
			
		||||
                        <AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                </HBox>
 | 
			
		||||
 | 
			
		||||
                <!-- Additional, mostly optional properties -->
 | 
			
		||||
                <PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
 | 
			
		||||
                    <columnConstraints>
 | 
			
		||||
                        <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
 | 
			
		||||
                        <ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
 | 
			
		||||
                    </columnConstraints>
 | 
			
		||||
 | 
			
		||||
                    <VBox>
 | 
			
		||||
                        <Label text="Vendor" labelFor="${vendorComboBox}" styleClass="bold-text"/>
 | 
			
		||||
                        <Hyperlink fx:id="vendorsHyperlink" text="Manage vendors" styleClass="small-font"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                    <ComboBox fx:id="vendorComboBox" editable="true" maxWidth="Infinity"/>
 | 
			
		||||
 | 
			
		||||
                    <VBox>
 | 
			
		||||
                        <Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
 | 
			
		||||
                        <Hyperlink fx:id="categoriesHyperlink" text="Manage categories" styleClass="small-font"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                    <CategorySelectionBox fx:id="categoryComboBox" maxWidth="Infinity"/>
 | 
			
		||||
 | 
			
		||||
                    <VBox>
 | 
			
		||||
                        <Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
 | 
			
		||||
                        <Hyperlink fx:id="tagsHyperlink" text="Manage tags" styleClass="small-font"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                    <VBox maxWidth="Infinity">
 | 
			
		||||
                        <HBox styleClass="std-spacing">
 | 
			
		||||
                            <ComboBox fx:id="tagsComboBox" editable="true" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
 | 
			
		||||
                            <Button fx:id="addTagButton" text="Add" HBox.hgrow="NEVER"/>
 | 
			
		||||
                        </HBox>
 | 
			
		||||
                        <VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
 | 
			
		||||
                    <Label text="Line Items" styleClass="bold-text"/>
 | 
			
		||||
                    <VBox maxWidth="Infinity">
 | 
			
		||||
                        <Button text="Add Line Item" fx:id="addLineItemButton" disable="true"/>
 | 
			
		||||
                        <StyledText styleClass="small-font">
 | 
			
		||||
                            Line items aren't yet supported. I'm working on it!
 | 
			
		||||
                        </StyledText>
 | 
			
		||||
                        <VBox styleClass="std-spacing" fx:id="addLineItemForm">
 | 
			
		||||
                            <HBox styleClass="std-spacing">
 | 
			
		||||
                                <VBox>
 | 
			
		||||
                                    <Label text="Quantity" styleClass="bold-text,small-font"/>
 | 
			
		||||
                                    <Spinner fx:id="lineItemQuantitySpinner" minWidth="60" maxWidth="60"/>
 | 
			
		||||
                                </VBox>
 | 
			
		||||
                                <VBox HBox.hgrow="ALWAYS">
 | 
			
		||||
                                    <Label text="Value per Item" styleClass="bold-text,small-font"/>
 | 
			
		||||
                                    <TextField fx:id="lineItemValueField"/>
 | 
			
		||||
                                </VBox>
 | 
			
		||||
                            </HBox>
 | 
			
		||||
                            <VBox>
 | 
			
		||||
                                <Label text="Description" styleClass="bold-text,small-font"/>
 | 
			
		||||
                                <TextField fx:id="lineItemDescriptionField"/>
 | 
			
		||||
                            </VBox>
 | 
			
		||||
                            <HBox styleClass="std-spacing" alignment="CENTER_RIGHT">
 | 
			
		||||
                                <Button text="Add" fx:id="addLineItemAddButton"/>
 | 
			
		||||
                                <Button text="Cancel" fx:id="addLineItemCancelButton"/>
 | 
			
		||||
                            </HBox>
 | 
			
		||||
                        </VBox>
 | 
			
		||||
 | 
			
		||||
                        <VBox fx:id="lineItemsVBox"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                </PropertiesPane>
 | 
			
		||||
 | 
			
		||||
                <!-- Container for attachments -->
 | 
			
		||||
                <VBox styleClass="std-padding">
 | 
			
		||||
                    <Label text="Attachments" styleClass="bold-text"/>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
 | 
			
		||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
 | 
			
		||||
<?import javafx.scene.control.*?>
 | 
			
		||||
<?import javafx.scene.layout.*?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.EditVendorController"
 | 
			
		||||
>
 | 
			
		||||
    <top>
 | 
			
		||||
        <Label text="Edit Vendor" styleClass="bold-text,large-font,std-padding"/>
 | 
			
		||||
    </top>
 | 
			
		||||
    <center>
 | 
			
		||||
        <VBox>
 | 
			
		||||
            <PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
 | 
			
		||||
                <columnConstraints>
 | 
			
		||||
                    <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
 | 
			
		||||
                    <ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
 | 
			
		||||
                </columnConstraints>
 | 
			
		||||
 | 
			
		||||
                <Label text="Name" labelFor="${nameField}"/>
 | 
			
		||||
                <TextField fx:id="nameField"/>
 | 
			
		||||
 | 
			
		||||
                <Label text="Description" labelFor="${descriptionField}"/>
 | 
			
		||||
                <TextArea fx:id="descriptionField" wrapText="true"/>
 | 
			
		||||
            </PropertiesPane>
 | 
			
		||||
            <Separator/>
 | 
			
		||||
            <HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
 | 
			
		||||
                <Button text="Save" fx:id="saveButton" onAction="#save"/>
 | 
			
		||||
                <Button text="Cancel" onAction="#cancel"/>
 | 
			
		||||
            </HBox>
 | 
			
		||||
        </VBox>
 | 
			
		||||
    </center>
 | 
			
		||||
</BorderPane>
 | 
			
		||||
| 
						 | 
				
			
			@ -28,7 +28,7 @@
 | 
			
		|||
            <!-- App footer -->
 | 
			
		||||
            <bottom>
 | 
			
		||||
                <HBox styleClass="std-padding,std-spacing">
 | 
			
		||||
                    <Label text="Perfin Version 1.4.0"/>
 | 
			
		||||
                    <Label text="Perfin Version 1.5.0"/>
 | 
			
		||||
                    <AnchorPane>
 | 
			
		||||
                        <Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
 | 
			
		||||
                    </AnchorPane>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,82 @@
 | 
			
		|||
[
 | 
			
		||||
  {
 | 
			
		||||
    "name": "Food & Drink",
 | 
			
		||||
    "color": "#10C600",
 | 
			
		||||
    "children": [
 | 
			
		||||
      {"name": "Groceries"},
 | 
			
		||||
      {"name": "Restaurants"},
 | 
			
		||||
      {"name": "Alcohol & Bars"}
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "name": "Transportation",
 | 
			
		||||
    "color": "#4688FF",
 | 
			
		||||
    "children": [
 | 
			
		||||
      {"name": "Car & Fuel"},
 | 
			
		||||
      {"name": "Public Transport"},
 | 
			
		||||
      {"name": "Air Travel"},
 | 
			
		||||
      {"name": "Taxi & Rideshare"}
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "name": "Household",
 | 
			
		||||
    "color": "#E5DF00",
 | 
			
		||||
    "children": [
 | 
			
		||||
      {"name": "Rent & Mortgage"},
 | 
			
		||||
      {"name": "Utilities"},
 | 
			
		||||
      {"name": "Insurance & Fees"},
 | 
			
		||||
      {"name": "Home Improvements & Renovation"},
 | 
			
		||||
      {"name": "Household Supplies"},
 | 
			
		||||
      {"name": "Pets"},
 | 
			
		||||
      {"name": "Garden"}
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "name": "Shopping",
 | 
			
		||||
    "color": "#BF2484",
 | 
			
		||||
    "children": [
 | 
			
		||||
      {"name":  "Clothes & Accessories"},
 | 
			
		||||
      {"name": "Electronics"},
 | 
			
		||||
      {"name": "Hobbies & Crafts"},
 | 
			
		||||
      {"name": "Gifts"}
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "name": "Leisure",
 | 
			
		||||
    "color": "#C7271C",
 | 
			
		||||
    "children": [
 | 
			
		||||
      {"name": "Culture & Events"},
 | 
			
		||||
      {"name": "Movies & Media"},
 | 
			
		||||
      {"name": "Vacation"}
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "name": "Health & Wellness",
 | 
			
		||||
    "color": "#BE11D7",
 | 
			
		||||
    "children": [
 | 
			
		||||
      {"name": "Healthcare"},
 | 
			
		||||
      {"name": "Medication"},
 | 
			
		||||
      {"name": "Vision"},
 | 
			
		||||
      {"name": "Dental"},
 | 
			
		||||
      {"name": "Fitness & Sports"}
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "name": "Income",
 | 
			
		||||
    "color": "#83F25C",
 | 
			
		||||
    "children": [
 | 
			
		||||
      {"name": "Cash Deposits"},
 | 
			
		||||
      {"name": "Salary"},
 | 
			
		||||
      {"name": "Pension"},
 | 
			
		||||
      {"name": "Investments"}
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "name": "Transfers",
 | 
			
		||||
    "color": "#F7AE39"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "name": "Charity",
 | 
			
		||||
    "color": "#9AE5FF"
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,73 @@
 | 
			
		|||
/*
 | 
			
		||||
Migration to add additional properties to transactions as per this GitHub issue:
 | 
			
		||||
https://github.com/andrewlalis/perfin/issues/10
 | 
			
		||||
 | 
			
		||||
Adds the following:
 | 
			
		||||
- An optional "vendor" field and associated vendor entity.
 | 
			
		||||
- An optional "category" field and associated category entity.
 | 
			
		||||
- An optional set of "tags" that are user-defined strings.
 | 
			
		||||
- An optional set of "line items" that comprise some subtotal of the transaction
 | 
			
		||||
  and can be used to specify that X amount of the total was spent on some
 | 
			
		||||
  specific item.
 | 
			
		||||
- An optional address of the purchase.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_vendor (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    name VARCHAR(255) NOT NULL UNIQUE,
 | 
			
		||||
    description VARCHAR(255)
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_category (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    parent_id BIGINT DEFAULT NULL,
 | 
			
		||||
    name VARCHAR(63) NOT NULL UNIQUE,
 | 
			
		||||
    color VARCHAR(6) NOT NULL DEFAULT 'FFFFFF',
 | 
			
		||||
    CONSTRAINT fk_transaction_category_parent
 | 
			
		||||
        FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_tag (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    name VARCHAR(63) NOT NULL UNIQUE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_tag_join (
 | 
			
		||||
    transaction_id BIGINT NOT NULL,
 | 
			
		||||
    tag_id BIGINT NOT NULL,
 | 
			
		||||
    PRIMARY KEY (transaction_id, tag_id),
 | 
			
		||||
    CONSTRAINT fk_transaction_tag_join_transaction
 | 
			
		||||
        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
			
		||||
    CONSTRAINT fk_transaction_tag_join_tag
 | 
			
		||||
        FOREIGN KEY (tag_id) REFERENCES transaction_tag(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_line_item (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    transaction_id BIGINT NOT NULL,
 | 
			
		||||
    value_per_item NUMERIC(12, 4) NOT NULL,
 | 
			
		||||
    quantity INT NOT NULL DEFAULT 1,
 | 
			
		||||
    description VARCHAR(255) NOT NULL,
 | 
			
		||||
    CONSTRAINT fk_transaction_line_item_transaction
 | 
			
		||||
        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
			
		||||
    CONSTRAINT ck_transaction_line_item_quantity_positive
 | 
			
		||||
        CHECK quantity > 0
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
ALTER TABLE transaction
 | 
			
		||||
    ADD COLUMN vendor_id BIGINT DEFAULT NULL AFTER description;
 | 
			
		||||
ALTER TABLE transaction
 | 
			
		||||
    ADD COLUMN category_id BIGINT DEFAULT NULL AFTER vendor_id;
 | 
			
		||||
ALTER TABLE transaction
 | 
			
		||||
    ADD CONSTRAINT fk_transaction_vendor
 | 
			
		||||
        FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE SET NULL;
 | 
			
		||||
ALTER TABLE transaction
 | 
			
		||||
    ADD CONSTRAINT fk_transaction_category
 | 
			
		||||
        FOREIGN KEY (category_id) REFERENCES transaction_category(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE SET NULL;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,61 @@
 | 
			
		|||
/*
 | 
			
		||||
Migration to clean up history entities so that they are easier to work with, and
 | 
			
		||||
less prone to errors.
 | 
			
		||||
 | 
			
		||||
- Removes existing account history items.
 | 
			
		||||
- Adds a generic history table and history items that are linked to a history.
 | 
			
		||||
- Adds history links to accounts and transactions.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
CREATE TABLE history (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE history_item (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    history_id BIGINT NOT NULL,
 | 
			
		||||
    timestamp TIMESTAMP NOT NULL,
 | 
			
		||||
    type VARCHAR(63) NOT NULL,
 | 
			
		||||
    CONSTRAINT fk_history_item_history
 | 
			
		||||
        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE history_item_text (
 | 
			
		||||
    id BIGINT NOT NULL,
 | 
			
		||||
    description VARCHAR(255) NOT NULL,
 | 
			
		||||
    CONSTRAINT fk_history_item_text_pk
 | 
			
		||||
        FOREIGN KEY (id) REFERENCES history_item(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE history_account (
 | 
			
		||||
    account_id BIGINT NOT NULL,
 | 
			
		||||
    history_id BIGINT NOT NULL,
 | 
			
		||||
    PRIMARY KEY (account_id, history_id),
 | 
			
		||||
    CONSTRAINT fk_history_account_account
 | 
			
		||||
        FOREIGN KEY (account_id) REFERENCES account(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
			
		||||
    CONSTRAINT fk_history_account_history
 | 
			
		||||
        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE history_transaction (
 | 
			
		||||
    transaction_id BIGINT NOT NULL,
 | 
			
		||||
    history_id BIGINT NOT NULL,
 | 
			
		||||
    PRIMARY KEY (transaction_id, history_id),
 | 
			
		||||
    CONSTRAINT fk_history_transaction_transaction
 | 
			
		||||
        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
			
		||||
    CONSTRAINT fk_history_transaction_history
 | 
			
		||||
        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
DROP TABLE IF EXISTS account_history_item_text;
 | 
			
		||||
DROP TABLE IF EXISTS account_history_item_account_entry;
 | 
			
		||||
DROP TABLE IF EXISTS account_history_item_balance_record;
 | 
			
		||||
DROP TABLE IF EXISTS account_history_item;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -8,14 +8,6 @@ CREATE TABLE account (
 | 
			
		|||
    currency VARCHAR(3) NOT NULL
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    timestamp TIMESTAMP NOT NULL,
 | 
			
		||||
    amount NUMERIC(12, 4) NOT NULL,
 | 
			
		||||
    currency VARCHAR(3) NOT NULL,
 | 
			
		||||
    description VARCHAR(255) NULL
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE attachment (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    uploaded_at TIMESTAMP NOT NULL,
 | 
			
		||||
| 
						 | 
				
			
			@ -24,6 +16,45 @@ CREATE TABLE attachment (
 | 
			
		|||
    content_type VARCHAR(255) NOT NULL
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
/* TRANSACTION ENTITIES */
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_vendor (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    name VARCHAR(255) NOT NULL UNIQUE,
 | 
			
		||||
    description VARCHAR(255)
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_category (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    parent_id BIGINT DEFAULT NULL,
 | 
			
		||||
    name VARCHAR(63) NOT NULL UNIQUE,
 | 
			
		||||
    color VARCHAR(6) NOT NULL DEFAULT 'FFFFFF',
 | 
			
		||||
    CONSTRAINT fk_transaction_category_parent
 | 
			
		||||
        FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_tag (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    name VARCHAR(63) NOT NULL UNIQUE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    timestamp TIMESTAMP NOT NULL,
 | 
			
		||||
    amount NUMERIC(12, 4) NOT NULL,
 | 
			
		||||
    currency VARCHAR(3) NOT NULL,
 | 
			
		||||
    description VARCHAR(255) NULL,
 | 
			
		||||
    vendor_id BIGINT DEFAULT NULL,
 | 
			
		||||
    category_id BIGINT DEFAULT NULL,
 | 
			
		||||
    CONSTRAINT fk_transaction_vendor
 | 
			
		||||
        FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE SET NULL,
 | 
			
		||||
    CONSTRAINT fk_transaction_category
 | 
			
		||||
        FOREIGN KEY (category_id) REFERENCES transaction_category(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE SET NULL
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE account_entry (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    timestamp TIMESTAMP NOT NULL,
 | 
			
		||||
| 
						 | 
				
			
			@ -52,6 +83,34 @@ CREATE TABLE transaction_attachment (
 | 
			
		|||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_tag_join (
 | 
			
		||||
    transaction_id BIGINT NOT NULL,
 | 
			
		||||
    tag_id BIGINT NOT NULL,
 | 
			
		||||
    PRIMARY KEY (transaction_id, tag_id),
 | 
			
		||||
    CONSTRAINT fk_transaction_tag_join_transaction
 | 
			
		||||
        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
			
		||||
    CONSTRAINT fk_transaction_tag_join_tag
 | 
			
		||||
        FOREIGN KEY (tag_id) REFERENCES transaction_tag(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE transaction_line_item (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    transaction_id BIGINT NOT NULL,
 | 
			
		||||
    value_per_item NUMERIC(12, 4) NOT NULL,
 | 
			
		||||
    quantity INT NOT NULL DEFAULT 1,
 | 
			
		||||
    idx INT NOT NULL DEFAULT 0,
 | 
			
		||||
    description VARCHAR(255) NOT NULL,
 | 
			
		||||
    CONSTRAINT fk_transaction_line_item_transaction
 | 
			
		||||
        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
			
		||||
    CONSTRAINT ck_transaction_line_item_quantity_positive
 | 
			
		||||
        CHECK quantity > 0
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
/* BALANCE RECORD ENTITIES */
 | 
			
		||||
 | 
			
		||||
CREATE TABLE balance_record (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    timestamp TIMESTAMP NOT NULL,
 | 
			
		||||
| 
						 | 
				
			
			@ -75,42 +134,49 @@ CREATE TABLE balance_record_attachment (
 | 
			
		|||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE account_history_item (
 | 
			
		||||
/* HISTORY */
 | 
			
		||||
CREATE TABLE history (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE history_item (
 | 
			
		||||
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
 | 
			
		||||
    history_id BIGINT NOT NULL,
 | 
			
		||||
    timestamp TIMESTAMP NOT NULL,
 | 
			
		||||
    account_id BIGINT NOT NULL,
 | 
			
		||||
    type VARCHAR(63) NOT NULL,
 | 
			
		||||
    CONSTRAINT fk_account_history_item_account
 | 
			
		||||
        FOREIGN KEY (account_id) REFERENCES account(id)
 | 
			
		||||
    CONSTRAINT fk_history_item_history
 | 
			
		||||
        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE account_history_item_text (
 | 
			
		||||
    item_id BIGINT NOT NULL PRIMARY KEY,
 | 
			
		||||
CREATE TABLE history_item_text (
 | 
			
		||||
    id BIGINT PRIMARY KEY,
 | 
			
		||||
    description VARCHAR(255) NOT NULL,
 | 
			
		||||
    CONSTRAINT fk_account_history_item_text_pk
 | 
			
		||||
        FOREIGN KEY (item_id) REFERENCES account_history_item(id)
 | 
			
		||||
    CONSTRAINT fk_history_item_text_pk
 | 
			
		||||
        FOREIGN KEY (id) REFERENCES history_item(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE account_history_item_account_entry (
 | 
			
		||||
    item_id BIGINT NOT NULL PRIMARY KEY,
 | 
			
		||||
    entry_id BIGINT NOT NULL,
 | 
			
		||||
    CONSTRAINT fk_account_history_item_account_entry_pk
 | 
			
		||||
        FOREIGN KEY (item_id) REFERENCES account_history_item(id)
 | 
			
		||||
CREATE TABLE history_account (
 | 
			
		||||
    account_id BIGINT NOT NULL,
 | 
			
		||||
    history_id BIGINT NOT NULL,
 | 
			
		||||
    PRIMARY KEY (account_id, history_id),
 | 
			
		||||
    CONSTRAINT fk_history_account_account
 | 
			
		||||
        FOREIGN KEY (account_id) REFERENCES account(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
			
		||||
    CONSTRAINT fk_account_history_item_account_entry
 | 
			
		||||
        FOREIGN KEY (entry_id) REFERENCES account_entry(id)
 | 
			
		||||
    CONSTRAINT fk_history_account_history
 | 
			
		||||
        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
CREATE TABLE account_history_item_balance_record (
 | 
			
		||||
    item_id BIGINT NOT NULL PRIMARY KEY,
 | 
			
		||||
    record_id BIGINT NOT NULL,
 | 
			
		||||
    CONSTRAINT fk_account_history_item_balance_record_pk
 | 
			
		||||
        FOREIGN KEY (item_id) REFERENCES account_history_item(id)
 | 
			
		||||
CREATE TABLE history_transaction (
 | 
			
		||||
    transaction_id BIGINT NOT NULL,
 | 
			
		||||
    history_id BIGINT NOT NULL,
 | 
			
		||||
    PRIMARY KEY (transaction_id, history_id),
 | 
			
		||||
    CONSTRAINT fk_history_transaction_transaction
 | 
			
		||||
        FOREIGN KEY (transaction_id) REFERENCES transaction(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE,
 | 
			
		||||
    CONSTRAINT fk_account_history_item_balance_record
 | 
			
		||||
        FOREIGN KEY (record_id) REFERENCES balance_record(id)
 | 
			
		||||
    CONSTRAINT fk_history_transaction_history
 | 
			
		||||
        FOREIGN KEY (history_id) REFERENCES history(id)
 | 
			
		||||
            ON UPDATE CASCADE ON DELETE CASCADE
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
 | 
			
		||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
 | 
			
		||||
<?import javafx.scene.control.Label?>
 | 
			
		||||
<?import javafx.scene.control.ScrollPane?>
 | 
			
		||||
<?import javafx.scene.layout.BorderPane?>
 | 
			
		||||
<?import javafx.scene.layout.VBox?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.TagsViewController"
 | 
			
		||||
>
 | 
			
		||||
    <top>
 | 
			
		||||
        <Label text="Transaction Tags" styleClass="large-font,bold-text,std-padding"/>
 | 
			
		||||
    </top>
 | 
			
		||||
    <center>
 | 
			
		||||
        <VBox>
 | 
			
		||||
            <StyledText maxWidth="500" styleClass="std-padding">
 | 
			
		||||
                Transaction tags are just bits of text that can be applied to a
 | 
			
		||||
                transaction to give it additional meaning or make searching for
 | 
			
		||||
                certain transactions easier.
 | 
			
		||||
                --
 | 
			
		||||
                Tags are automatically created if you add a new one to a
 | 
			
		||||
                transaction, and they'll show up here. When you remove a tag,
 | 
			
		||||
                it will be permanently removed from **all** transactions that it
 | 
			
		||||
                was previously associated with.
 | 
			
		||||
            </StyledText>
 | 
			
		||||
            <ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
 | 
			
		||||
                <VBox fx:id="tagsVBox" styleClass="tile-container"/>
 | 
			
		||||
            </ScrollPane>
 | 
			
		||||
        </VBox>
 | 
			
		||||
    </center>
 | 
			
		||||
</BorderPane>
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +6,7 @@
 | 
			
		|||
<?import javafx.scene.layout.*?>
 | 
			
		||||
<?import javafx.scene.text.Text?>
 | 
			
		||||
<?import javafx.scene.text.TextFlow?>
 | 
			
		||||
<?import javafx.scene.shape.Circle?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.TransactionViewController"
 | 
			
		||||
| 
						 | 
				
			
			@ -32,6 +33,37 @@
 | 
			
		|||
                    <Label text="Description" styleClass="bold-text"/>
 | 
			
		||||
                    <Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/>
 | 
			
		||||
                </PropertiesPane>
 | 
			
		||||
 | 
			
		||||
                <PropertiesPane vgap="5" hgap="5">
 | 
			
		||||
                    <columnConstraints>
 | 
			
		||||
                        <ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
 | 
			
		||||
                        <ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
 | 
			
		||||
                    </columnConstraints>
 | 
			
		||||
                    <Label text="Vendor" styleClass="bold-text"/>
 | 
			
		||||
                    <Label fx:id="vendorLabel"/>
 | 
			
		||||
                </PropertiesPane>
 | 
			
		||||
 | 
			
		||||
                <PropertiesPane vgap="5" hgap="5">
 | 
			
		||||
                    <columnConstraints>
 | 
			
		||||
                        <ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
 | 
			
		||||
                        <ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
 | 
			
		||||
                    </columnConstraints>
 | 
			
		||||
                    <Label text="Category" styleClass="bold-text"/>
 | 
			
		||||
                    <HBox styleClass="std-spacing">
 | 
			
		||||
                        <Circle radius="8" fx:id="categoryColorIndicator"/>
 | 
			
		||||
                        <Label fx:id="categoryLabel"/>
 | 
			
		||||
                    </HBox>
 | 
			
		||||
                </PropertiesPane>
 | 
			
		||||
 | 
			
		||||
                <PropertiesPane vgap="5" hgap="5">
 | 
			
		||||
                    <columnConstraints>
 | 
			
		||||
                        <ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
 | 
			
		||||
                        <ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
 | 
			
		||||
                    </columnConstraints>
 | 
			
		||||
                    <Label text="Tags" styleClass="bold-text"/>
 | 
			
		||||
                    <Label fx:id="tagsLabel"/>
 | 
			
		||||
                </PropertiesPane>
 | 
			
		||||
 | 
			
		||||
                <VBox>
 | 
			
		||||
                    <TextFlow>
 | 
			
		||||
                        <Text text="Debited to"/>
 | 
			
		||||
| 
						 | 
				
			
			@ -45,7 +77,7 @@
 | 
			
		|||
                <AttachmentsViewPane fx:id="attachmentsViewPane"/>
 | 
			
		||||
                <HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
 | 
			
		||||
                    <Button text="Edit" onAction="#editTransaction"/>
 | 
			
		||||
                    <Button text="Delete this transaction" onAction="#deleteTransaction"/>
 | 
			
		||||
                    <Button text="Delete" onAction="#deleteTransaction"/>
 | 
			
		||||
                </HBox>
 | 
			
		||||
            </VBox>
 | 
			
		||||
        </ScrollPane>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,7 @@
 | 
			
		|||
<?import javafx.scene.control.Label?>
 | 
			
		||||
<?import javafx.scene.control.ScrollPane?>
 | 
			
		||||
<?import javafx.scene.layout.*?>
 | 
			
		||||
<?import javafx.scene.control.TextField?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
 | 
			
		||||
| 
						 | 
				
			
			@ -20,7 +21,8 @@
 | 
			
		|||
        <HBox>
 | 
			
		||||
            <BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
 | 
			
		||||
                <top>
 | 
			
		||||
                    <HBox styleClass="std-padding,std-spacing">
 | 
			
		||||
                    <HBox styleClass="padding-extra,std-spacing">
 | 
			
		||||
                        <TextField fx:id="searchField" promptText="Search"/>
 | 
			
		||||
                        <PropertiesPane hgap="5" vgap="5">
 | 
			
		||||
                            <Label text="Filter by Account"/>
 | 
			
		||||
                            <AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
 | 
			
		||||
<?import javafx.scene.control.Button?>
 | 
			
		||||
<?import javafx.scene.control.Label?>
 | 
			
		||||
<?import javafx.scene.control.ScrollPane?>
 | 
			
		||||
<?import javafx.scene.layout.*?>
 | 
			
		||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.VendorsViewController"
 | 
			
		||||
>
 | 
			
		||||
    <top>
 | 
			
		||||
        <Label text="Vendors" styleClass="large-font,bold-text,std-padding"/>
 | 
			
		||||
    </top>
 | 
			
		||||
    <center>
 | 
			
		||||
        <VBox>
 | 
			
		||||
            <StyledText maxWidth="500" styleClass="std-padding">
 | 
			
		||||
                Vendors are businesses or other financial entities with which
 | 
			
		||||
                you do transactions. By tagging a vendor on your transactions,
 | 
			
		||||
                it becomes easier to find out just how much money you're
 | 
			
		||||
                spending at certain shops, and how often. It can also make it a
 | 
			
		||||
                lot easier to look up past transactions.
 | 
			
		||||
            </StyledText>
 | 
			
		||||
            <HBox styleClass="std-padding,std-spacing" VBox.vgrow="NEVER">
 | 
			
		||||
                <Button text="Add Vendor" onAction="#addVendor"/>
 | 
			
		||||
            </HBox>
 | 
			
		||||
            <ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
 | 
			
		||||
                <VBox fx:id="vendorsVBox" styleClass="tile-container"/>
 | 
			
		||||
            </ScrollPane>
 | 
			
		||||
        </VBox>
 | 
			
		||||
    </center>
 | 
			
		||||
</BorderPane>
 | 
			
		||||
		Loading…
	
		Reference in New Issue