Add Transaction Properties #15

Merged
andrewlalis merged 18 commits from transaction-properties into main 2024-02-04 04:31:04 +00:00
88 changed files with 3321 additions and 720 deletions

View File

@ -37,3 +37,30 @@ to set the version everywhere that it needs to be.
Once that's done, the workflow will start, and you should see a release appear Once that's done, the workflow will start, and you should see a release appear
in the next few minutes. in the next few minutes.
## Migration Procedure
Because this application relies on a structured relational database schema,
changes to the schema must be handled with care to avoid destroying users' data.
Specifically, when changes are made to the schema, a *migration* must be defined
which provides instructions for Perfin to safely apply changes to an old schema.
The database schema is versioned using whole-number versions (1, 2, 3, ...), and
a migration is defined for each transition from version to version, such that
any older version can be incrementally upgraded, step by step, to the latest
schema version.
Perfin only supports the latest schema version, as defined by `JdbcDataSourceFactory.SCHEMA_VERSION`.
When the app loads a profile, it'll check that profile's schema version by
reading a `.jdbc-schema-version.txt` file in the profile's main directory. If
the profile's schema version is **less than** the current, Perfin will
ask the user if they want to upgrade. If the profile's schema version is
**greater than** the current, Perfin will tell the user that it can't load a
schema from a newer version, and will prompt the user to upgrade.
### Writing a Migration
1. Write your migration. This can be plain SQL (placed in `resources/sql/migration`), or Java code.
2. Add your migration to `com.andrewlalis.perfin.data.impl.migration.Migrations#getMigrations()`.
3. Increment the schema version defined in `JdbcDataSourceFactory`.
4. Test the migration yourself on a profile with data.

View File

@ -6,7 +6,7 @@
<groupId>com.andrewlalis</groupId> <groupId>com.andrewlalis</groupId>
<artifactId>perfin</artifactId> <artifactId>perfin</artifactId>
<version>1.4.0</version> <version>1.5.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar"
jpackage \ jpackage \
--name "Perfin" \ --name "Perfin" \
--app-version "1.4.0" \ --app-version "1.5.0" \
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \ --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
--icon design/perfin-logo_256.png \ --icon design/perfin-logo_256.png \
--vendor "Andrew Lalis" \ --vendor "Andrew Lalis" \

View File

@ -12,7 +12,7 @@ $modulePath = "$modulePath;target\modules\h2-2.2.224.jar"
jpackage ` jpackage `
--name "Perfin" ` --name "Perfin" `
--app-version "1.4.0" ` --app-version "1.5.0" `
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." ` --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
--icon design\perfin-logo_256.ico ` --icon design\perfin-logo_256.ico `
--vendor "Andrew Lalis" ` --vendor "Andrew Lalis" `

View File

@ -3,7 +3,9 @@ package com.andrewlalis.perfin;
import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView; import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
import com.andrewlalis.javafx_scene_router.SceneRouter; import com.andrewlalis.javafx_scene_router.SceneRouter;
import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.ProfileLoader;
import com.andrewlalis.perfin.view.ImageCache; import com.andrewlalis.perfin.view.ImageCache;
import com.andrewlalis.perfin.view.SceneUtil; import com.andrewlalis.perfin.view.SceneUtil;
import com.andrewlalis.perfin.view.StartupSplashScreen; import com.andrewlalis.perfin.view.StartupSplashScreen;
@ -29,6 +31,7 @@ public class PerfinApp extends Application {
private static final Logger log = LoggerFactory.getLogger(PerfinApp.class); private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin"); public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
public static PerfinApp instance; public static PerfinApp instance;
public static ProfileLoader profileLoader;
/** /**
* The router that's used for navigating between different "pages" in the application. * The router that's used for navigating between different "pages" in the application.
@ -48,13 +51,14 @@ public class PerfinApp extends Application {
@Override @Override
public void start(Stage stage) { public void start(Stage stage) {
instance = this; instance = this;
profileLoader = new ProfileLoader(stage, new JdbcDataSourceFactory());
loadFonts(); loadFonts();
var splashScreen = new StartupSplashScreen(List.of( var splashScreen = new StartupSplashScreen(List.of(
PerfinApp::defineRoutes, PerfinApp::defineRoutes,
PerfinApp::initAppDir, PerfinApp::initAppDir,
c -> initMainScreen(stage, c), c -> initMainScreen(stage, c),
PerfinApp::loadLastUsedProfile PerfinApp::loadLastUsedProfile
)); ), false);
splashScreen.showAndWait(); splashScreen.showAndWait();
if (splashScreen.isStartupSuccessful()) { if (splashScreen.isStartupSuccessful()) {
stage.show(); stage.show();
@ -87,6 +91,11 @@ public class PerfinApp extends Application {
router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml")); router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml"));
router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml")); router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml"));
router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml")); router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml"));
router.map("vendors", PerfinApp.class.getResource("/vendors-view.fxml"));
router.map("edit-vendor", PerfinApp.class.getResource("/edit-vendor.fxml"));
router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
// Help pages. // Help pages.
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml")); helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
@ -112,9 +121,10 @@ public class PerfinApp extends Application {
} }
private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception { private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
msgConsumer.accept("Loading the most recent profile."); String lastProfile = ProfileLoader.getLastProfile();
msgConsumer.accept("Loading the most recent profile: \"" + lastProfile + "\".");
try { try {
Profile.loadLast(); Profile.setCurrent(profileLoader.load(lastProfile));
} catch (ProfileLoadException e) { } catch (ProfileLoadException e) {
msgConsumer.accept("Failed to load the profile: " + e.getMessage()); msgConsumer.accept("Failed to load the profile: " + e.getMessage());
throw e; throw e;

View File

@ -1,12 +1,12 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.history.AccountHistoryItem; import com.andrewlalis.perfin.model.history.HistoryItem;
import com.andrewlalis.perfin.view.component.AccountHistoryItemTile; import com.andrewlalis.perfin.view.component.AccountHistoryItemTile;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
@ -64,7 +64,7 @@ public class AccountViewController implements RouteSelectionListener {
accountNumberLabel.setText(account.getAccountNumber()); accountNumberLabel.setText(account.getAccountNumber());
accountCurrencyLabel.setText(account.getCurrency().getDisplayName()); accountCurrencyLabel.setText(account.getCurrency().getDisplayName());
accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt())); accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
Profile.getCurrent().getDataSource().getAccountBalanceText(account) Profile.getCurrent().dataSource().getAccountBalanceText(account)
.thenAccept(accountBalanceLabel::setText); .thenAccept(accountBalanceLabel::setText);
reloadHistory(); reloadHistory();
@ -89,6 +89,7 @@ public class AccountViewController implements RouteSelectionListener {
@FXML @FXML
public void archiveAccount() { public void archiveAccount() {
boolean confirmResult = Popups.confirm( boolean confirmResult = Popups.confirm(
titleLabel,
"Are you sure you want to archive this account? It will no " + "Are you sure you want to archive this account? It will no " +
"longer show up in the app normally, and you won't be " + "longer show up in the app normally, and you won't be " +
"able to add new transactions to it. You'll still be " + "able to add new transactions to it. You'll still be " +
@ -96,18 +97,19 @@ public class AccountViewController implements RouteSelectionListener {
"later if you need to." "later if you need to."
); );
if (confirmResult) { if (confirmResult) {
Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id)); Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id));
router.replace("accounts"); router.replace("accounts");
} }
} }
@FXML public void unarchiveAccount() { @FXML public void unarchiveAccount() {
boolean confirm = Popups.confirm( boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to restore this account from its archived " + "Are you sure you want to restore this account from its archived " +
"status?" "status?"
); );
if (confirm) { if (confirm) {
Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id)); Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id));
router.replace("accounts"); router.replace("accounts");
} }
} }
@ -115,6 +117,7 @@ public class AccountViewController implements RouteSelectionListener {
@FXML @FXML
public void deleteAccount() { public void deleteAccount() {
boolean confirm = Popups.confirm( boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to permanently delete this account and " + "Are you sure you want to permanently delete this account and " +
"all data directly associated with it? This cannot be " + "all data directly associated with it? This cannot be " +
"undone; deleted accounts are not recoverable at all. " + "undone; deleted accounts are not recoverable at all. " +
@ -122,26 +125,21 @@ public class AccountViewController implements RouteSelectionListener {
"want to hide it." "want to hide it."
); );
if (confirm) { if (confirm) {
Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.delete(account)); Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(account));
router.replace("accounts"); router.replace("accounts");
} }
} }
@FXML public void loadMoreHistory() { @FXML public void loadMoreHistory() {
Profile.getCurrent().getDataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(HistoryRepository.class, repo -> {
List<AccountHistoryItem> historyItems = repo.findMostRecentForAccount( long historyId = repo.getOrCreateHistoryForAccount(account.id);
account.id, List<HistoryItem> items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom);
loadHistoryFrom, if (items.size() < historyLoadSize) {
historyLoadSize
);
if (historyItems.size() < historyLoadSize) {
Platform.runLater(() -> loadMoreHistoryButton.setDisable(true)); Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
} else { } else {
loadHistoryFrom = historyItems.getLast().getTimestamp(); loadHistoryFrom = items.getLast().getTimestamp();
} }
List<? extends Node> nodes = historyItems.stream() List<? extends Node> nodes = items.stream().map(AccountHistoryItemTile::forItem).toList();
.map(item -> AccountHistoryItemTile.forItem(item, repo, this))
.toList();
Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes)); Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
}); });
} }

View File

@ -49,7 +49,7 @@ public class AccountsViewController implements RouteSelectionListener {
public void refreshAccounts() { public void refreshAccounts() {
Profile.whenLoaded(profile -> { Profile.whenLoaded(profile -> {
profile.getDataSource().useRepoAsync(AccountRepository.class, repo -> { profile.dataSource().useRepoAsync(AccountRepository.class, repo -> {
List<Account> accounts = repo.findAllOrderedByRecentHistory(); List<Account> accounts = repo.findAllOrderedByRecentHistory();
Platform.runLater(() -> accountsPane.getChildren() Platform.runLater(() -> accountsPane.getChildren()
.setAll(accounts.stream() .setAll(accounts.stream()
@ -59,7 +59,7 @@ public class AccountsViewController implements RouteSelectionListener {
}); });
// Compute grand totals! // Compute grand totals!
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
var totals = profile.getDataSource().getCombinedAccountBalances(); var totals = profile.dataSource().getCombinedAccountBalances();
StringBuilder sb = new StringBuilder("Totals: "); StringBuilder sb = new StringBuilder("Totals: ");
for (var entry : totals.entrySet()) { for (var entry : totals.entrySet()) {
sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey()))); sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey())));

View File

@ -41,16 +41,19 @@ public class BalanceRecordViewController implements RouteSelectionListener {
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp())); timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount())); balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
currencyLabel.setText(balanceRecord.getCurrency().getDisplayName()); currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
Profile.getCurrent().getDataSource().useRepoAsync(BalanceRecordRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
List<Attachment> attachments = repo.findAttachments(balanceRecord.id); List<Attachment> attachments = repo.findAttachments(balanceRecord.id);
Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments)); Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments));
}); });
} }
@FXML public void delete() { @FXML public void delete() {
boolean confirm = Popups.confirm("Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin."); boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin."
);
if (confirm) { if (confirm) {
Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id)); Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
router.navigateBackAndClear(); router.navigateBackAndClear();
} }
} }

View File

@ -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);
}
}
}

View File

@ -11,6 +11,7 @@ import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.PropertiesPane; import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
import com.andrewlalis.perfin.view.component.validation.ValidationResult; import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import javafx.application.Platform; import javafx.application.Platform;
@ -39,7 +40,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
private Account account; private Account account;
@FXML public void initialize() { @FXML public void initialize() {
var timestampValid = new ValidationApplier<String>(input -> { var timestampValid = new ValidationApplier<>((ValidationFunction<String>) input -> {
try { try {
DateUtil.DEFAULT_DATETIME_FORMAT.parse(input); DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
return ValidationResult.valid(); return ValidationResult.valid();
@ -60,7 +61,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
return; return;
} }
BigDecimal reportedBalance = new BigDecimal(newValue); BigDecimal reportedBalance = new BigDecimal(newValue);
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
BigDecimal derivedBalance = repo.deriveCurrentBalance(account.id); BigDecimal derivedBalance = repo.deriveCurrentBalance(account.id);
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set( Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(
!reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance) !reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance)
@ -76,7 +77,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
public void onRouteSelected(Object context) { public void onRouteSelected(Object context) {
this.account = (Account) context; this.account = (Account) context;
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
BigDecimal value = repo.deriveCurrentBalance(account.id); BigDecimal value = repo.deriveCurrentBalance(account.id);
Platform.runLater(() -> balanceField.setText( Platform.runLater(() -> balanceField.setText(
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency())) CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
@ -89,13 +90,13 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
BigDecimal reportedBalance = new BigDecimal(balanceField.getText()); BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
boolean confirm = Popups.confirm("Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted( boolean confirm = Popups.confirm(timestampField, "Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted(
account.getShortName(), account.getShortName(),
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())), CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE) localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
)); ));
if (confirm && confirmIfInconsistentBalance(reportedBalance)) { if (confirm && confirmIfInconsistentBalance(reportedBalance)) {
Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> { Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
repo.insert( repo.insert(
DateUtil.localToUTC(localTimestamp), DateUtil.localToUTC(localTimestamp),
account.id, account.id,
@ -113,7 +114,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
} }
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) { private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) {
BigDecimal currentDerivedBalance = Profile.getCurrent().getDataSource().mapRepo( BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
AccountRepository.class, AccountRepository.class,
repo -> repo.deriveCurrentBalance(account.id) repo -> repo.deriveCurrentBalance(account.id)
); );
@ -122,7 +123,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())), CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())),
CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency())) CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency()))
); );
return Popups.confirm(msg); return Popups.confirm(timestampField, msg);
} }
return true; return true;
} }

View File

@ -106,8 +106,8 @@ public class EditAccountController implements RouteSelectionListener {
@FXML @FXML
public void save() { public void save() {
try ( try (
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository(); var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
var balanceRepo = Profile.getCurrent().getDataSource().getBalanceRecordRepository() var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
) { ) {
if (creatingNewAccount.get()) { if (creatingNewAccount.get()) {
String name = accountNameField.getText().strip(); String name = accountNameField.getText().strip();
@ -117,7 +117,7 @@ public class EditAccountController implements RouteSelectionListener {
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip()); BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
List<Path> attachments = Collections.emptyList(); List<Path> attachments = Collections.emptyList();
boolean success = Popups.confirm("Are you sure you want to create this account?"); boolean success = Popups.confirm(accountNameField, "Are you sure you want to create this account?");
if (success) { if (success) {
long id = accountRepo.insert(type, number, name, currency); long id = accountRepo.insert(type, number, name, currency);
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments); balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
@ -138,7 +138,7 @@ public class EditAccountController implements RouteSelectionListener {
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to save (or update) account " + account.id, e); log.error("Failed to save (or update) account " + account.id, e);
Popups.error("Failed to save the account: " + e.getMessage()); Popups.error(accountNameField, "Failed to save the account: " + e.getMessage());
} }
} }

View File

@ -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();
}
}

View File

@ -1,24 +1,36 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort; import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.CategorySelectionBox;
import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property; import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -27,13 +39,14 @@ import java.nio.file.Path;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Collections; import java.util.*;
import java.util.Comparator;
import java.util.Currency;
import java.util.List;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
/**
* Controller for the "edit-transaction" view, which is where the user can
* create or edit transactions.
*/
public class EditTransactionController implements RouteSelectionListener { public class EditTransactionController implements RouteSelectionListener {
private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class); private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
@ -49,6 +62,25 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public AccountSelectionBox debitAccountSelector; @FXML public AccountSelectionBox debitAccountSelector;
@FXML public AccountSelectionBox creditAccountSelector; @FXML public AccountSelectionBox creditAccountSelector;
@FXML public ComboBox<String> vendorComboBox;
@FXML public Hyperlink vendorsHyperlink;
@FXML public CategorySelectionBox categoryComboBox;
@FXML public Hyperlink categoriesHyperlink;
@FXML public ComboBox<String> tagsComboBox;
@FXML public Hyperlink tagsHyperlink;
@FXML public Button addTagButton;
@FXML public VBox tagsVBox;
private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
@FXML public Spinner<Integer> lineItemQuantitySpinner;
@FXML public TextField lineItemValueField;
@FXML public TextField lineItemDescriptionField;
@FXML public Button addLineItemButton;
@FXML public VBox addLineItemForm;
@FXML public Button addLineItemAddButton;
@FXML public Button addLineItemCancelButton;
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
@FXML public FileSelectionArea attachmentsSelectionArea; @FXML public FileSelectionArea attachmentsSelectionArea;
@FXML public Button saveButton; @FXML public Button saveButton;
@ -70,32 +102,32 @@ public class EditTransactionController implements RouteSelectionListener {
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>() var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.") .addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
).validatedInitially().attach(descriptionField, descriptionField.textProperty()); ).validatedInitially().attach(descriptionField, descriptionField.textProperty());
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
initializeTagSelectionUi();
// Linked accounts will use a property derived from both the debit and credit selections. vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts()); categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); tagsHyperlink.setOnAction(event -> router.navigate("tags"));
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator<CreditAndDebitAccounts>() // Initialize line item stuff.
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.") addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
.addPredicate( addLineItemCancelButton.setOnAction(event -> {
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()), lineItemQuantitySpinner.getValueFactory().setValue(1);
"The credit and debit accounts cannot be the same." lineItemValueField.setText(null);
) lineItemDescriptionField.setText(null);
.addPredicate( addingLineItemProperty.set(false);
accounts -> ( });
(!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) && BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
(!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue())) BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
), lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
"Linked accounts must use the same currency." var lineItemValueValid = new ValidationApplier<>(
) new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
.addPredicate( ).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
accounts -> ( var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
(!accounts.hasCredit() || !accounts.creditAccount().isArchived()) && .addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
(!accounts.hasDebit() || !accounts.debitAccount().isArchived()) ).attachToTextField(lineItemDescriptionField);
), var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
"Linked accounts must not be archived." addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
)
).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty);
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not()); saveButton.disableProperty().bind(formValid.not());
@ -107,11 +139,14 @@ public class EditTransactionController implements RouteSelectionListener {
Currency currency = currencyChoiceBox.getValue(); Currency currency = currencyChoiceBox.getValue();
String description = getSanitizedDescription(); String description = getSanitizedDescription();
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
String vendor = vendorComboBox.getValue();
String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName();
Set<String> tags = new HashSet<>(selectedTags);
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths(); List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments(); List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
final long idToNavigate; final long idToNavigate;
if (transaction == null) { if (transaction == null) {
idToNavigate = Profile.getCurrent().getDataSource().mapRepo( idToNavigate = Profile.getCurrent().dataSource().mapRepo(
TransactionRepository.class, TransactionRepository.class,
repo -> repo.insert( repo -> repo.insert(
utcTimestamp, utcTimestamp,
@ -119,11 +154,14 @@ public class EditTransactionController implements RouteSelectionListener {
currency, currency,
description, description,
linkedAccounts, linkedAccounts,
vendor,
category,
tags,
newAttachmentPaths newAttachmentPaths
) )
); );
} else { } else {
Profile.getCurrent().getDataSource().useRepo( Profile.getCurrent().dataSource().useRepo(
TransactionRepository.class, TransactionRepository.class,
repo -> repo.update( repo -> repo.update(
transaction.id, transaction.id,
@ -132,6 +170,9 @@ public class EditTransactionController implements RouteSelectionListener {
currency, currency,
description, description,
linkedAccounts, linkedAccounts,
vendor,
category,
tags,
existingAttachments, existingAttachments,
newAttachmentPaths newAttachmentPaths
) )
@ -149,6 +190,11 @@ public class EditTransactionController implements RouteSelectionListener {
public void onRouteSelected(Object context) { public void onRouteSelected(Object context) {
transaction = (Transaction) context; transaction = (Transaction) context;
// Clear some initial fields immediately:
tagsComboBox.setValue(null);
vendorComboBox.setValue(null);
categoryComboBox.select(null);
if (transaction == null) { if (transaction == null) {
titleLabel.setText("Create New Transaction"); titleLabel.setText("Create New Transaction");
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
@ -163,10 +209,13 @@ public class EditTransactionController implements RouteSelectionListener {
// Fetch some account-specific data. // Fetch some account-specific data.
container.setDisable(true); container.setDisable(true);
DataSource ds = Profile.getCurrent().dataSource();
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
try ( try (
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository(); var accountRepo = ds.getAccountRepository();
var transactionRepo = Profile.getCurrent().getDataSource().getTransactionRepository() var transactionRepo = ds.getTransactionRepository();
var vendorRepo = ds.getTransactionVendorRepository();
var categoryRepo = ds.getTransactionCategoryRepository()
) { ) {
// First fetch all the data. // First fetch all the data.
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream() List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
@ -174,23 +223,50 @@ public class EditTransactionController implements RouteSelectionListener {
.toList(); .toList();
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
final List<Attachment> attachments; final List<Attachment> attachments;
final var categoryTreeNodes = categoryRepo.findTree();
final List<String> availableTags = transactionRepo.findAllTags();
final List<String> tags;
final CreditAndDebitAccounts linkedAccounts; final CreditAndDebitAccounts linkedAccounts;
final String vendorName;
final TransactionCategory category;
if (transaction == null) { if (transaction == null) {
attachments = Collections.emptyList(); attachments = Collections.emptyList();
tags = Collections.emptyList();
linkedAccounts = new CreditAndDebitAccounts(null, null); linkedAccounts = new CreditAndDebitAccounts(null, null);
vendorName = null;
category = null;
} else { } else {
attachments = transactionRepo.findAttachments(transaction.id); attachments = transactionRepo.findAttachments(transaction.id);
tags = transactionRepo.findTags(transaction.id);
linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id); linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
if (transaction.getVendorId() != null) {
vendorName = vendorRepo.findById(transaction.getVendorId())
.map(TransactionVendor::getName).orElse(null);
} else {
vendorName = null;
}
if (transaction.getCategoryId() != null) {
category = categoryRepo.findById(transaction.getCategoryId()).orElse(null);
} else {
category = null;
}
} }
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
// Then make updates to the view. // Then make updates to the view.
Platform.runLater(() -> { Platform.runLater(() -> {
currencyChoiceBox.getItems().setAll(currencies);
creditAccountSelector.setAccounts(accounts); creditAccountSelector.setAccounts(accounts);
debitAccountSelector.setAccounts(accounts); debitAccountSelector.setAccounts(accounts);
currencyChoiceBox.getItems().setAll(currencies); vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList());
vendorComboBox.setValue(vendorName);
categoryComboBox.loadCategories(categoryTreeNodes);
categoryComboBox.select(category);
tagsComboBox.getItems().setAll(availableTags);
attachmentsSelectionArea.clear(); attachmentsSelectionArea.clear();
attachmentsSelectionArea.addAttachments(attachments); attachmentsSelectionArea.addAttachments(attachments);
selectedTags.clear();
selectedTags.addAll(tags);
if (transaction == null) { if (transaction == null) {
// TODO: Allow user to select a default currency.
currencyChoiceBox.getSelectionModel().selectFirst(); currencyChoiceBox.getSelectionModel().selectFirst();
creditAccountSelector.select(null); creditAccountSelector.select(null);
debitAccountSelector.select(null); debitAccountSelector.select(null);
@ -203,11 +279,53 @@ public class EditTransactionController implements RouteSelectionListener {
}); });
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to get repositories.", e); log.error("Failed to get repositories.", e);
Popups.error("Failed to fetch account-specific data: " + e.getMessage()); Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()));
router.navigateBackAndClear();
} }
}); });
} }
private BooleanExpression initializeLinkedAccountsValidationUi() {
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
return new ValidationApplier<>(getLinkedAccountsValidator())
.validatedInitially()
.attach(linkedAccountsContainer, linkedAccountsProperty);
}
private void initializeTagSelectionUi() {
addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
addTagButton.setOnAction(event -> {
if (tagsComboBox.getValue() == null) return;
String tag = tagsComboBox.getValue().strip();
if (!selectedTags.contains(tag)) {
selectedTags.add(tag);
selectedTags.sort(String::compareToIgnoreCase);
}
tagsComboBox.setValue(null);
});
tagsComboBox.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
addTagButton.fire();
}
});
BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, this::createTagListTile);
}
private Node createTagListTile(String tag) {
Label label = new Label(tag);
label.setMaxWidth(Double.POSITIVE_INFINITY);
label.getStyleClass().addAll("bold-text");
Button removeButton = new Button("Remove");
removeButton.setOnAction(event -> selectedTags.remove(tag));
BorderPane tile = new BorderPane(label);
tile.setRight(removeButton);
tile.getStyleClass().addAll("std-spacing");
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
return tile;
}
private CreditAndDebitAccounts getSelectedAccounts() { private CreditAndDebitAccounts getSelectedAccounts() {
return new CreditAndDebitAccounts( return new CreditAndDebitAccounts(
creditAccountSelector.getValue(), creditAccountSelector.getValue(),
@ -215,6 +333,29 @@ public class EditTransactionController implements RouteSelectionListener {
); );
} }
private PredicateValidator<CreditAndDebitAccounts> getLinkedAccountsValidator() {
return new PredicateValidator<CreditAndDebitAccounts>()
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
.addPredicate(
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
"The credit and debit accounts cannot be the same."
)
.addPredicate(
accounts -> (
(!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
(!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
),
"Linked accounts must use the same currency."
)
.addPredicate(
accounts -> (
(!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
(!accounts.hasDebit() || !accounts.debitAccount().isArchived())
),
"Linked accounts must not be archived."
);
}
private LocalDateTime parseTimestamp() { private LocalDateTime parseTimestamp() {
List<DateTimeFormatter> formatters = List.of( List<DateTimeFormatter> formatters = List.of(
DateTimeFormatter.ISO_LOCAL_DATE_TIME, DateTimeFormatter.ISO_LOCAL_DATE_TIME,

View File

@ -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();
}
}

View File

@ -1,30 +1,70 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Alert; import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType; import javafx.scene.control.ButtonType;
import javafx.stage.Modality; import javafx.stage.Modality;
import javafx.stage.Window;
/** /**
* Helper class for standardized popups and confirmation dialogs for the app. * Helper class for standardized popups and confirmation dialogs for the app.
*/ */
public class Popups { public class Popups {
public static boolean confirm(String text) { public static boolean confirm(Window owner, String text) {
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text); Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text);
alert.initOwner(owner);
alert.initModality(Modality.APPLICATION_MODAL); alert.initModality(Modality.APPLICATION_MODAL);
var result = alert.showAndWait(); var result = alert.showAndWait();
return result.isPresent() && result.get() == ButtonType.OK; return result.isPresent() && result.get() == ButtonType.OK;
} }
public static void message(String text) { public static boolean confirm(Node node, String text) {
return confirm(getWindowFromNode(node), text);
}
public static void message(Window owner, String text) {
Alert alert = new Alert(Alert.AlertType.NONE, text); Alert alert = new Alert(Alert.AlertType.NONE, text);
alert.initOwner(owner);
alert.initModality(Modality.APPLICATION_MODAL); alert.initModality(Modality.APPLICATION_MODAL);
alert.getButtonTypes().setAll(ButtonType.OK); alert.getButtonTypes().setAll(ButtonType.OK);
alert.showAndWait(); alert.showAndWait();
} }
public static void error(String text) { public static void message(Node node, String text) {
message(getWindowFromNode(node), text);
}
public static void error(Window owner, String text) {
Alert alert = new Alert(Alert.AlertType.WARNING, text); Alert alert = new Alert(Alert.AlertType.WARNING, text);
alert.initOwner(owner);
alert.initModality(Modality.APPLICATION_MODAL); alert.initModality(Modality.APPLICATION_MODAL);
alert.showAndWait(); alert.showAndWait();
} }
public static void error(Node node, String text) {
error(getWindowFromNode(node), text);
}
public static void error(Window owner, Exception e) {
error(owner, "An " + e.getClass().getSimpleName() + " occurred: " + e.getMessage());
}
public static void error(Node node, Exception e) {
error(getWindowFromNode(node), e);
}
public static void errorLater(Node node, Exception e) {
Platform.runLater(() -> error(node, e));
}
private static Window getWindowFromNode(Node n) {
Window owner = null;
Scene scene = n.getScene();
if (scene != null) {
owner = scene.getWindow();
}
return owner;
}
} }

View File

@ -4,6 +4,7 @@ import com.andrewlalis.perfin.PerfinApp;
import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.ProfileLoader;
import com.andrewlalis.perfin.view.ProfilesStage; import com.andrewlalis.perfin.view.ProfilesStage;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
@ -44,11 +45,11 @@ public class ProfilesViewController {
@FXML public void addProfile() { @FXML public void addProfile() {
String name = newProfileNameField.getText(); String name = newProfileNameField.getText();
boolean valid = Profile.validateName(name); boolean valid = Profile.validateName(name);
if (valid && !Profile.getAvailableProfiles().contains(name)) { if (valid && !ProfileLoader.getAvailableProfiles().contains(name)) {
boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?"); boolean confirm = Popups.confirm(profilesVBox, "Are you sure you want to add a new profile named \"" + name + "\"?");
if (confirm) { if (confirm) {
if (openProfile(name, false)) { if (openProfile(name, false)) {
Popups.message("Created new profile \"" + name + "\" and loaded it."); Popups.message(profilesVBox, "Created new profile \"" + name + "\" and loaded it.");
} }
newProfileNameField.clear(); newProfileNameField.clear();
} }
@ -56,8 +57,8 @@ public class ProfilesViewController {
} }
private void refreshAvailableProfiles() { private void refreshAvailableProfiles() {
List<String> profileNames = Profile.getAvailableProfiles(); List<String> profileNames = ProfileLoader.getAvailableProfiles();
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName(); String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name();
List<Node> nodes = new ArrayList<>(profileNames.size()); List<Node> nodes = new ArrayList<>(profileNames.size());
for (String profileName : profileNames) { for (String profileName : profileNames) {
boolean isCurrent = profileName.equals(currentProfile); boolean isCurrent = profileName.equals(currentProfile);
@ -104,30 +105,31 @@ public class ProfilesViewController {
private boolean openProfile(String name, boolean showPopup) { private boolean openProfile(String name, boolean showPopup) {
log.info("Opening profile \"{}\".", name); log.info("Opening profile \"{}\".", name);
try { try {
Profile.load(name); Profile.setCurrent(PerfinApp.profileLoader.load(name));
ProfileLoader.saveLastProfile(name);
ProfilesStage.closeView(); ProfilesStage.closeView();
router.replace("accounts"); router.replace("accounts");
if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded."); if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");
return true; return true;
} catch (ProfileLoadException e) { } catch (ProfileLoadException e) {
Popups.error("Failed to load the profile: " + e.getMessage()); Popups.error(profilesVBox, "Failed to load the profile: " + e.getMessage());
return false; return false;
} }
} }
private void deleteProfile(String name) { private void deleteProfile(String name) {
boolean confirmA = Popups.confirm("Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered."); boolean confirmA = Popups.confirm(profilesVBox, "Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered.");
if (confirmA) { if (confirmA) {
boolean confirmB = Popups.confirm("Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back."); boolean confirmB = Popups.confirm(profilesVBox, "Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back.");
if (confirmB) { if (confirmB) {
try { try {
FileUtil.deleteDirRecursive(Profile.getDir(name)); FileUtil.deleteDirRecursive(Profile.getDir(name));
// Reset the app's "last profile" to the default if it was the deleted profile. // Reset the app's "last profile" to the default if it was the deleted profile.
if (Profile.getLastProfile().equals(name)) { if (ProfileLoader.getLastProfile().equals(name)) {
Profile.saveLastProfile("default"); ProfileLoader.saveLastProfile("default");
} }
// If the current profile was deleted, switch to the default. // If the current profile was deleted, switch to the default.
if (Profile.getCurrent() != null && Profile.getCurrent().getName().equals(name)) { if (Profile.getCurrent() != null && Profile.getCurrent().name().equals(name)) {
openProfile("default", true); openProfile("default", true);
} }
refreshAvailableProfiles(); refreshAvailableProfiles();

View File

@ -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;
}
}

View File

@ -3,23 +3,37 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Attachment; import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts; import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.view.component.AttachmentsViewPane; import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Hyperlink; import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.shape.Circle;
import javafx.scene.text.TextFlow; import javafx.scene.text.TextFlow;
import org.slf4j.Logger;
import java.util.List; import org.slf4j.LoggerFactory;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
public class TransactionViewController { public class TransactionViewController {
private Transaction transaction; private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
@FXML public Label titleLabel; @FXML public Label titleLabel;
@ -27,51 +41,108 @@ public class TransactionViewController {
@FXML public Label timestampLabel; @FXML public Label timestampLabel;
@FXML public Label descriptionLabel; @FXML public Label descriptionLabel;
@FXML public Label vendorLabel;
@FXML public Circle categoryColorIndicator;
@FXML public Label categoryLabel;
@FXML public Label tagsLabel;
@FXML public Hyperlink debitAccountLink; @FXML public Hyperlink debitAccountLink;
@FXML public Hyperlink creditAccountLink; @FXML public Hyperlink creditAccountLink;
@FXML public AttachmentsViewPane attachmentsViewPane; @FXML public AttachmentsViewPane attachmentsViewPane;
@FXML public void initialize() { @FXML public void initialize() {
configureAccountLinkBindings(debitAccountLink); titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id));
configureAccountLinkBindings(creditAccountLink); amountLabel.textProperty().bind(transactionProperty.map(t -> CurrencyUtil.formatMoney(t.getMoneyAmount())));
timestampLabel.textProperty().bind(transactionProperty.map(t -> DateUtil.formatUTCAsLocalWithZone(t.getTimestamp())));
descriptionLabel.textProperty().bind(transactionProperty.map(Transaction::getDescription));
PropertiesPane vendorPane = (PropertiesPane) vendorLabel.getParent();
BindingUtil.bindManagedAndVisible(vendorPane, vendorProperty.isNotNull());
vendorLabel.textProperty().bind(vendorProperty.map(TransactionVendor::getName));
PropertiesPane categoryPane = (PropertiesPane) categoryLabel.getParent().getParent();
BindingUtil.bindManagedAndVisible(categoryPane, categoryProperty.isNotNull());
categoryLabel.textProperty().bind(categoryProperty.map(TransactionCategory::getName));
categoryColorIndicator.fillProperty().bind(categoryProperty.map(TransactionCategory::getColor));
PropertiesPane tagsPane = (PropertiesPane) tagsLabel.getParent();
BindingUtil.bindManagedAndVisible(tagsPane, tagsListProperty.emptyProperty().not());
tagsLabel.textProperty().bind(tagsListProperty.map(tags -> String.join(", ", tags)));
TextFlow debitText = (TextFlow) debitAccountLink.getParent();
BindingUtil.bindManagedAndVisible(debitText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasDebit));
debitAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasDebit() ? la.debitAccount().getShortName() : null));
debitAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
if (la.hasDebit()) {
return event -> router.navigate("account", la.debitAccount());
}
return event -> {};
}));
TextFlow creditText = (TextFlow) creditAccountLink.getParent();
BindingUtil.bindManagedAndVisible(creditText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasCredit));
creditAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasCredit() ? la.creditAccount().getShortName() : null));
creditAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
if (la.hasCredit()) {
return event -> router.navigate("account", la.creditAccount());
}
return event -> {};
}));
attachmentsViewPane.hideIfEmpty(); attachmentsViewPane.hideIfEmpty();
attachmentsViewPane.listProperty().bindContent(attachmentsList);
transactionProperty.addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
linkedAccountsProperty.set(null);
vendorProperty.set(null);
categoryProperty.set(null);
tagsList.clear();
attachmentsList.clear();
} else {
updateLinkedData(newValue);
}
});
} }
public void setTransaction(Transaction transaction) { public void setTransaction(Transaction transaction) {
this.transaction = transaction; this.transactionProperty.set(transaction);
if (transaction == null) return; }
titleLabel.setText("Transaction #" + transaction.id);
amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount())); private void updateLinkedData(Transaction tx) {
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp())); var ds = Profile.getCurrent().dataSource();
descriptionLabel.setText(transaction.getDescription()); Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> { try (
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id); var transactionRepo = ds.getTransactionRepository();
List<Attachment> attachments = repo.findAttachments(transaction.id); var vendorRepo = ds.getTransactionVendorRepository();
Platform.runLater(() -> { var categoryRepo = ds.getTransactionCategoryRepository()
if (accounts.hasDebit()) { ) {
debitAccountLink.setText(accounts.debitAccount().getShortName()); final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount())); final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
} else { final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
debitAccountLink.setText(null); final var attachments = transactionRepo.findAttachments(tx.id);
} final var tags = transactionRepo.findTags(tx.id);
if (accounts.hasCredit()) { Platform.runLater(() -> {
creditAccountLink.setText(accounts.creditAccount().getShortName()); linkedAccountsProperty.set(linkedAccounts);
creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount())); vendorProperty.set(vendor);
} else { categoryProperty.set(category);
creditAccountLink.setText(null); attachmentsList.setAll(attachments);
} tagsList.setAll(tags);
attachmentsViewPane.setAttachments(attachments); });
}); } catch (Exception e) {
log.error("Failed to fetch additional transaction data.", e);
Popups.errorLater(titleLabel, e);
}
}); });
} }
@FXML public void editTransaction() { @FXML public void editTransaction() {
router.navigate("edit-transaction", this.transaction); router.navigate("edit-transaction", this.transactionProperty.get());
} }
@FXML public void deleteTransaction() { @FXML public void deleteTransaction() {
boolean confirm = Popups.confirm( boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to delete this transaction? This will " + "Are you sure you want to delete this transaction? This will " +
"permanently remove the transaction and its effects on any linked " + "permanently remove the transaction and its effects on any linked " +
"accounts, as well as remove any attachments from storage within " + "accounts, as well as remove any attachments from storage within " +
@ -81,15 +152,8 @@ public class TransactionViewController {
"it's derived from the most recent balance-record, and transactions." "it's derived from the most recent balance-record, and transactions."
); );
if (confirm) { if (confirm) {
Profile.getCurrent().getDataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id)); Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(this.transactionProperty.get().id));
router.replace("transactions"); router.replace("transactions");
} }
} }
private void configureAccountLinkBindings(Hyperlink link) {
TextFlow parent = (TextFlow) link.getParent();
parent.managedProperty().bind(parent.visibleProperty());
parent.visibleProperty().bind(link.textProperty().isNotEmpty());
link.setText(null);
}
} }

View File

@ -3,14 +3,18 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort; import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
import com.andrewlalis.perfin.data.search.SearchFilter;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.Pair; import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction; import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.SceneUtil; import com.andrewlalis.perfin.view.SceneUtil;
import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.DataSourcePaginationControls; import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
@ -21,6 +25,7 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
@ -29,8 +34,9 @@ import javafx.stage.FileChooser;
import java.io.File; import java.io.File;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Set;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
@ -45,6 +51,7 @@ public class TransactionsViewController implements RouteSelectionListener {
public record RouteContext(Long selectedTransactionId) {} public record RouteContext(Long selectedTransactionId) {}
@FXML public BorderPane transactionsListBorderPane; @FXML public BorderPane transactionsListBorderPane;
@FXML public TextField searchField;
@FXML public AccountSelectionBox filterByAccountComboBox; @FXML public AccountSelectionBox filterByAccountComboBox;
@FXML public VBox transactionsVBox; @FXML public VBox transactionsVBox;
private DataSourcePaginationControls paginationControls; private DataSourcePaginationControls paginationControls;
@ -59,33 +66,30 @@ public class TransactionsViewController implements RouteSelectionListener {
paginationControls.setPage(1); paginationControls.setPage(1);
selectedTransaction.set(null); selectedTransaction.set(null);
}); });
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
paginationControls.setPage(1);
selectedTransaction.set(null);
});
this.paginationControls = new DataSourcePaginationControls( this.paginationControls = new DataSourcePaginationControls(
transactionsVBox.getChildren(), transactionsVBox.getChildren(),
new DataSourcePaginationControls.PageFetcherFunction() { new DataSourcePaginationControls.PageFetcherFunction() {
@Override @Override
public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception { public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
Account accountFilter = filterByAccountComboBox.getValue(); JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) { try (var conn = ds.getConnection()) {
Page<Transaction> result; JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
if (accountFilter == null) { return searcher.search(pagination, getCurrentSearchFilters())
result = repo.findAll(pagination); .map(TransactionsViewController.this::makeTile);
} else {
result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination);
}
return result.map(TransactionsViewController.this::makeTile);
} }
} }
@Override @Override
public int getTotalCount() throws Exception { public int getTotalCount() throws Exception {
Account accountFilter = filterByAccountComboBox.getValue(); JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) { try (var conn = ds.getConnection()) {
if (accountFilter == null) { JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
return (int) repo.countAll(); return (int) searcher.resultCount(getCurrentSearchFilters());
} else {
return (int) repo.countAllByAccounts(Set.of(accountFilter.id));
}
} }
} }
} }
@ -98,18 +102,13 @@ public class TransactionsViewController implements RouteSelectionListener {
detailPanel.minWidthProperty().bind(halfWidthProp); detailPanel.minWidthProperty().bind(halfWidthProp);
detailPanel.maxWidthProperty().bind(halfWidthProp); detailPanel.maxWidthProperty().bind(halfWidthProp);
detailPanel.prefWidthProperty().bind(halfWidthProp); detailPanel.prefWidthProperty().bind(halfWidthProp);
detailPanel.managedProperty().bind(detailPanel.visibleProperty()); BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull());
detailPanel.visibleProperty().bind(selectedTransaction.isNotNull());
Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml"); Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
TransactionViewController transactionViewController = detailComponents.second(); TransactionViewController transactionViewController = detailComponents.second();
BorderPane transactionDetailView = detailComponents.first(); BorderPane transactionDetailView = detailComponents.first();
transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty());
transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull());
detailPanel.getChildren().add(transactionDetailView); detailPanel.getChildren().add(transactionDetailView);
selectedTransaction.addListener((observable, oldValue, newValue) -> { selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue));
transactionViewController.setTransaction(newValue);
});
// Clear the transactions when a new profile is loaded. // Clear the transactions when a new profile is loaded.
Profile.whenLoaded(profile -> { Profile.whenLoaded(profile -> {
@ -121,10 +120,10 @@ public class TransactionsViewController implements RouteSelectionListener {
@Override @Override
public void onRouteSelected(Object context) { public void onRouteSelected(Object context) {
paginationControls.sorts.setAll(DEFAULT_SORTS); paginationControls.sorts.setAll(DEFAULT_SORTS);
transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially. selectedTransaction.set(null); // Initially set the selected transaction as null.
// Refresh account filter options. // Refresh account filter options.
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
Platform.runLater(() -> { Platform.runLater(() -> {
filterByAccountComboBox.setAccounts(accounts); filterByAccountComboBox.setAccounts(accounts);
@ -135,18 +134,19 @@ public class TransactionsViewController implements RouteSelectionListener {
// If a transaction id is given in the route context, navigate to the page it's on and select it. // If a transaction id is given in the route context, navigate to the page it's on and select it.
if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) { if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(
repo.findById(ctx.selectedTransactionId).ifPresent(tx -> { TransactionRepository.class,
long offset = repo.countAllAfter(tx.id); repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1; long offset = repo.countAllAfter(tx.id);
Platform.runLater(() -> { int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx)); Platform.runLater(() -> {
}); paginationControls.setPage(pageNumber);
}); selectedTransaction.set(tx);
}); });
})
);
} else { } else {
paginationControls.setPage(1); paginationControls.setPage(1);
selectedTransaction.set(null);
} }
} }
@ -161,7 +161,7 @@ public class TransactionsViewController implements RouteSelectionListener {
File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow()); File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow());
if (file != null) { if (file != null) {
try ( try (
var repo = Profile.getCurrent().getDataSource().getTransactionRepository(); var repo = Profile.getCurrent().dataSource().getTransactionRepository();
var out = new PrintWriter(file, StandardCharsets.UTF_8) var out = new PrintWriter(file, StandardCharsets.UTF_8)
) { ) {
out.println("id,utc-timestamp,amount,currency,description"); out.println("id,utc-timestamp,amount,currency,description");
@ -177,11 +177,42 @@ public class TransactionsViewController implements RouteSelectionListener {
)); ));
} }
} catch (Exception e) { } catch (Exception e) {
Popups.error("An error occurred: " + e.getMessage()); Popups.error(transactionsListBorderPane, e);
} }
} }
} }
private List<SearchFilter> getCurrentSearchFilters() {
List<SearchFilter> filters = new ArrayList<>();
if (searchField.getText() != null && !searchField.getText().isBlank()) {
var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+"))
.map(t -> '%'+t+'%')
.toList();
var builder = new SearchFilter.Builder();
List<String> orClauses = new ArrayList<>(likeTerms.size());
for (var term : likeTerms) {
orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?");
builder.withArg(term);
builder.withArg(term);
builder.withArg(term);
}
builder.where(String.join(" OR ", orClauses));
builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id");
builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id");
filters.add(builder.build());
}
if (filterByAccountComboBox.getValue() != null) {
Account filteredAccount = filterByAccountComboBox.getValue();
var filter = new SearchFilter.Builder()
.where("fae.account_id = ?")
.withArg(filteredAccount.id)
.withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id")
.build();
filters.add(filter);
}
return filters;
}
private TransactionTile makeTile(Transaction transaction) { private TransactionTile makeTile(Transaction transaction) {
var tile = new TransactionTile(transaction); var tile = new TransactionTile(transaction);
tile.setOnMouseClicked(event -> { tile.setOnMouseClicked(event -> {

View File

@ -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);
});
});
}
}

View File

@ -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);
}

View File

@ -30,8 +30,10 @@ public interface DataSource {
AccountRepository getAccountRepository(); AccountRepository getAccountRepository();
BalanceRecordRepository getBalanceRecordRepository(); BalanceRecordRepository getBalanceRecordRepository();
TransactionRepository getTransactionRepository(); TransactionRepository getTransactionRepository();
TransactionVendorRepository getTransactionVendorRepository();
TransactionCategoryRepository getTransactionCategoryRepository();
AttachmentRepository getAttachmentRepository(); AttachmentRepository getAttachmentRepository();
AccountHistoryItemRepository getAccountHistoryItemRepository(); HistoryRepository getHistoryRepository();
// Repository helper methods: // Repository helper methods:
@ -81,8 +83,10 @@ public interface DataSource {
AccountRepository.class, this::getAccountRepository, AccountRepository.class, this::getAccountRepository,
BalanceRecordRepository.class, this::getBalanceRecordRepository, BalanceRecordRepository.class, this::getBalanceRecordRepository,
TransactionRepository.class, this::getTransactionRepository, TransactionRepository.class, this::getTransactionRepository,
TransactionVendorRepository.class, this::getTransactionVendorRepository,
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
AttachmentRepository.class, this::getAttachmentRepository, AttachmentRepository.class, this::getAttachmentRepository,
AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository HistoryRepository.class, this::getHistoryRepository
); );
return (Supplier<R>) repoSuppliers.get(type); return (Supplier<R>) repoSuppliers.get(type);
} }

View File

@ -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;
}

View File

@ -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));
}
}

View File

@ -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();
}

View File

@ -21,6 +21,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Currency currency, Currency currency,
String description, String description,
CreditAndDebitAccounts linkedAccounts, CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Path> attachments List<Path> attachments
); );
Optional<Transaction> findById(long id); Optional<Transaction> findById(long id);
@ -31,6 +34,10 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination); Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
CreditAndDebitAccounts findLinkedAccounts(long transactionId); CreditAndDebitAccounts findLinkedAccounts(long transactionId);
List<Attachment> findAttachments(long transactionId); List<Attachment> findAttachments(long transactionId);
List<String> findTags(long transactionId);
List<String> findAllTags();
void deleteTag(String name);
long countTagUsages(String name);
void delete(long transactionId); void delete(long transactionId);
void update( void update(
long id, long id,
@ -39,6 +46,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Currency currency, Currency currency,
String description, String description,
CreditAndDebitAccounts linkedAccounts, CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Attachment> existingAttachments, List<Attachment> existingAttachments,
List<Path> newAttachmentPaths List<Path> newAttachmentPaths
); );

View File

@ -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);
}

View File

@ -1,7 +1,7 @@
package com.andrewlalis.perfin.data.impl; package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository; import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.AccountEntry;
@ -31,8 +31,9 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
) )
); );
// Insert an entry into the account's history. // Insert an entry into the account's history.
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
historyRepo.recordAccountEntry(timestamp, accountId, entryId); long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, timestamp, "Entry #" + entryId + " added as a " + type.name() + " from Transaction #" + transactionId + ".");
return entryId; return entryId;
} }

View File

@ -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()
)
);
}
}

View File

@ -1,12 +1,8 @@
package com.andrewlalis.perfin.data.impl; package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository; import com.andrewlalis.perfin.data.*;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.EntityNotFoundException;
import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.AccountEntry;
@ -43,8 +39,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
) )
); );
// Insert a history item indicating the creation of the account. // Insert a history item indicating the creation of the account.
var historyRepo = new JdbcAccountHistoryItemRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile."); long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, "Account added to your Perfin profile.");
return accountId; return accountId;
}); });
} }
@ -59,11 +56,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
return DbUtil.findAll( return DbUtil.findAll(
conn, conn,
""" """
SELECT DISTINCT ON (account.id) account.*, ahi.timestamp AS _ SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
FROM account FROM account
LEFT OUTER JOIN account_history_item ahi ON ahi.account_id = account.id LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
WHERE NOT account.archived WHERE NOT account.archived
ORDER BY ahi.timestamp DESC, account.created_at DESC""", ORDER BY hi.timestamp DESC, account.created_at DESC""",
JdbcAccountRepository::parseAccount JdbcAccountRepository::parseAccount
); );
} }
@ -160,7 +158,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
public void archive(long accountId) { public void archive(long accountId) {
DbUtil.doTransaction(conn, () -> { DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId)); DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId));
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been archived."); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, "Account has been archived.");
}); });
} }
@ -168,7 +168,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
public void unarchive(long accountId) { public void unarchive(long accountId) {
DbUtil.doTransaction(conn, () -> { DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId)); DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId));
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been unarchived."); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, "Account has been unarchived.");
}); });
} }

View File

@ -1,11 +1,13 @@
package com.andrewlalis.perfin.data.impl; package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.AttachmentRepository; import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository; import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Attachment; import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.BalanceRecord; import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.MoneyValue;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Path; import java.nio.file.Path;
@ -36,8 +38,9 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
} }
} }
// Add a history item entry. // Add a history item entry.
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId); long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, utcTimestamp, "Balance Record #" + recordId + " added with a value of " + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(balance, currency)));
return recordId; return recordId;
}); });
} }

View File

@ -49,13 +49,23 @@ public class JdbcDataSource implements DataSource {
return new JdbcTransactionRepository(getConnection(), contentDir); return new JdbcTransactionRepository(getConnection(), contentDir);
} }
@Override
public TransactionVendorRepository getTransactionVendorRepository() {
return new JdbcTransactionVendorRepository(getConnection());
}
@Override
public TransactionCategoryRepository getTransactionCategoryRepository() {
return new JdbcTransactionCategoryRepository(getConnection());
}
@Override @Override
public AttachmentRepository getAttachmentRepository() { public AttachmentRepository getAttachmentRepository() {
return new JdbcAttachmentRepository(getConnection(), contentDir); return new JdbcAttachmentRepository(getConnection(), contentDir);
} }
@Override @Override
public AccountHistoryItemRepository getAccountHistoryItemRepository() { public HistoryRepository getHistoryRepository() {
return new JdbcAccountHistoryItemRepository(getConnection()); return new JdbcHistoryRepository(getConnection());
} }
} }

View File

@ -1,11 +1,16 @@
package com.andrewlalis.perfin.data.impl; package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.DataSource; import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.DataSourceFactory;
import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.migration.Migration; import com.andrewlalis.perfin.data.impl.migration.Migration;
import com.andrewlalis.perfin.data.impl.migration.Migrations; import com.andrewlalis.perfin.data.impl.migration.Migrations;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -14,16 +19,14 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.sql.Connection; import java.sql.*;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
/** /**
* Component that's responsible for obtaining a JDBC data source for a profile. * Component that's responsible for obtaining a JDBC data source for a profile.
*/ */
public class JdbcDataSourceFactory { public class JdbcDataSourceFactory implements DataSourceFactory {
private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class); private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class);
/** /**
@ -32,7 +35,7 @@ public class JdbcDataSourceFactory {
* the profile has a newer schema version, we'll exit and prompt the user * the profile has a newer schema version, we'll exit and prompt the user
* to update their app. * to update their app.
*/ */
public static final int SCHEMA_VERSION = 1; public static final int SCHEMA_VERSION = 3;
public DataSource getDataSource(String profileName) throws ProfileLoadException { public DataSource getDataSource(String profileName) throws ProfileLoadException {
final boolean dbExists = Files.exists(getDatabaseFile(profileName)); final boolean dbExists = Files.exists(getDatabaseFile(profileName));
@ -59,6 +62,13 @@ public class JdbcDataSourceFactory {
return new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName)); return new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
} }
public SchemaStatus getSchemaStatus(String profileName) throws IOException {
int existingSchemaVersion = getSchemaVersion(profileName);
if (existingSchemaVersion == SCHEMA_VERSION) return SchemaStatus.UP_TO_DATE;
if (existingSchemaVersion < SCHEMA_VERSION) return SchemaStatus.NEEDS_MIGRATION;
return SchemaStatus.INCOMPATIBLE;
}
private void createNewDatabase(String profileName) throws ProfileLoadException { private void createNewDatabase(String profileName) throws ProfileLoadException {
log.info("Creating new database for profile {}.", profileName); log.info("Creating new database for profile {}.", profileName);
JdbcDataSource dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName)); JdbcDataSource dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
@ -69,6 +79,7 @@ public class JdbcDataSourceFactory {
if (in == null) throw new IOException("Could not load database schema SQL file."); if (in == null) throw new IOException("Could not load database schema SQL file.");
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8); String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
executeSqlScript(schemaStr, conn); executeSqlScript(schemaStr, conn);
insertDefaultData(conn);
try { try {
writeCurrentSchemaVersion(profileName); writeCurrentSchemaVersion(profileName);
} catch (IOException e) { } catch (IOException e) {
@ -89,6 +100,53 @@ public class JdbcDataSourceFactory {
} }
} }
/**
* Inserts all default data into the database, using static content found in
* various locations on the classpath.
* @param conn The connection to use to insert data.
* @throws IOException If resources couldn't be read.
* @throws SQLException If SQL fails.
*/
public void insertDefaultData(Connection conn) throws IOException, SQLException {
insertDefaultCategories(conn);
}
public void insertDefaultCategories(Connection conn) throws IOException, SQLException {
try (
var categoriesIn = JdbcDataSourceFactory.class.getResourceAsStream("/sql/data/default-categories.json");
var stmt = conn.prepareStatement(
"INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
)
) {
if (categoriesIn == null) throw new IOException("Couldn't load default categories file.");
ObjectMapper mapper = new ObjectMapper();
ArrayNode categoriesArray = mapper.readValue(categoriesIn, ArrayNode.class);
insertCategoriesRecursive(stmt, categoriesArray, null, "#FFFFFF");
}
}
private void insertCategoriesRecursive(PreparedStatement stmt, ArrayNode categoriesArray, Long parentId, String parentColorHex) throws SQLException {
for (JsonNode obj : categoriesArray) {
String name = obj.get("name").asText();
String colorHex = parentColorHex;
if (obj.hasNonNull("color")) colorHex = obj.get("color").asText(parentColorHex);
if (parentId == null) {
stmt.setNull(1, Types.BIGINT);
} else {
stmt.setLong(1, parentId);
}
stmt.setString(2, name);
stmt.setString(3, colorHex.substring(1));
int result = stmt.executeUpdate();
if (result != 1) throw new SQLException("Failed to insert category.");
long id = DbUtil.getGeneratedId(stmt);
if (obj.hasNonNull("children") && obj.get("children").isArray()) {
insertCategoriesRecursive(stmt, obj.withArray("children"), id, colorHex);
}
}
}
private boolean testConnection(JdbcDataSource dataSource) { private boolean testConnection(JdbcDataSource dataSource) {
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) { try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
return stmt.execute("SELECT 1;"); return stmt.execute("SELECT 1;");
@ -168,7 +226,7 @@ public class JdbcDataSourceFactory {
return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt"); return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt");
} }
private static int getSchemaVersion(String profileName) throws IOException { public int getSchemaVersion(String profileName) throws IOException {
if (Files.exists(getSchemaVersionFile(profileName))) { if (Files.exists(getSchemaVersionFile(profileName))) {
try { try {
return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip()); return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip());

View File

@ -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);
}
}

View File

@ -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"))
);
}
}

View File

@ -2,20 +2,21 @@ package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository; import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AttachmentRepository; import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.model.*;
import javafx.scene.paint.Color;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.nio.file.Path; import java.nio.file.Path;
import java.sql.Connection; import java.sql.*;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -28,29 +29,104 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
Currency currency, Currency currency,
String description, String description,
CreditAndDebitAccounts linkedAccounts, CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Path> attachments List<Path> attachments
) { ) {
return DbUtil.doTransaction(conn, () -> { return DbUtil.doTransaction(conn, () -> {
// 1. Insert the transaction. Long vendorId = null;
long txId = DbUtil.insertOne( if (vendor != null && !vendor.isBlank()) {
conn, vendorId = getOrCreateVendorId(vendor.strip());
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)", }
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description) Long categoryId = null;
); if (category != null && !category.isBlank()) {
// 2. Insert linked account entries. categoryId = getOrCreateCategoryId(category.strip());
}
// Insert the transaction, using a custom JDBC statement to deal with nullables.
long txId;
try (var stmt = conn.prepareStatement(
"INSERT INTO transaction (timestamp, amount, currency, description, vendor_id, category_id) VALUES (?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
)) {
stmt.setTimestamp(1, DbUtil.timestampFromUtcLDT(utcTimestamp));
stmt.setBigDecimal(2, amount);
stmt.setString(3, currency.getCurrencyCode());
if (description != null && !description.isBlank()) {
stmt.setString(4, description.strip());
} else {
stmt.setNull(4, Types.VARCHAR);
}
if (vendorId != null) {
stmt.setLong(5, vendorId);
} else {
stmt.setNull(5, Types.BIGINT);
}
if (categoryId != null) {
stmt.setLong(6, categoryId);
} else {
stmt.setNull(6, Types.BIGINT);
}
int result = stmt.executeUpdate();
if (result != 1) throw new UncheckedSqlException("Transaction insert returned " + result);
var rs = stmt.getGeneratedKeys();
if (!rs.next()) throw new UncheckedSqlException("Transaction insert didn't generate any keys.");
txId = rs.getLong(1);
}
// Insert linked account entries.
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn); AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency)); linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency)); linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
// 3. Add attachments. // Add attachments.
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
for (Path attachmentPath : attachments) { for (Path attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath); Attachment attachment = attachmentRepo.insert(attachmentPath);
insertAttachmentLink(txId, attachment.id); insertAttachmentLink(txId, attachment.id);
} }
// Add tags.
for (String tag : tags) {
try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)")) {
long tagId = getOrCreateTagId(tag.toLowerCase().strip());
stmt.setLong(1, txId);
stmt.setLong(2, tagId);
stmt.executeUpdate();
}
}
return txId; return txId;
}); });
} }
private long getOrCreateVendorId(String name) {
var repo = new JdbcTransactionVendorRepository(conn);
TransactionVendor vendor = repo.findByName(name).orElse(null);
if (vendor != null) {
return vendor.id;
}
return repo.insert(name);
}
private long getOrCreateCategoryId(String name) {
var repo = new JdbcTransactionCategoryRepository(conn);
TransactionCategory category = repo.findByName(name).orElse(null);
if (category != null) {
return category.id;
}
return repo.insert(name, Color.WHITE);
}
private long getOrCreateTagId(String name) {
Optional<Long> optionalId = DbUtil.findOne(
conn,
"SELECT id FROM transaction_tag WHERE name = ?",
List.of(name),
rs -> rs.getLong(1)
);
return optionalId.orElseGet(() ->
DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name))
);
}
@Override @Override
public Optional<Transaction> findById(long id) { public Optional<Transaction> findById(long id) {
return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction); return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
@ -147,6 +223,51 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
); );
} }
@Override
public List<String> findTags(long transactionId) {
return DbUtil.findAll(
conn,
"""
SELECT tt.name
FROM transaction_tag tt
LEFT JOIN transaction_tag_join ttj ON ttj.tag_id = tt.id
WHERE ttj.transaction_id = ?
ORDER BY tt.name ASC""",
List.of(transactionId),
rs -> rs.getString(1)
);
}
@Override
public List<String> findAllTags() {
return DbUtil.findAll(
conn,
"SELECT name FROM transaction_tag ORDER BY name ASC",
rs -> rs.getString(1)
);
}
@Override
public void deleteTag(String name) {
DbUtil.update(
conn,
"DELETE FROM transaction_tag WHERE name = ?",
name
);
}
@Override
public long countTagUsages(String name) {
return DbUtil.count(
conn,
"""
SELECT COUNT(transaction_id)
FROM transaction_tag_join
WHERE tag_id = (SELECT id FROM transaction_tag WHERE name = ?)""",
name
);
}
@Override @Override
public void delete(long transactionId) { public void delete(long transactionId) {
DbUtil.doTransaction(conn, () -> { DbUtil.doTransaction(conn, () -> {
@ -164,44 +285,93 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
Currency currency, Currency currency,
String description, String description,
CreditAndDebitAccounts linkedAccounts, CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Attachment> existingAttachments, List<Attachment> existingAttachments,
List<Path> newAttachmentPaths List<Path> newAttachmentPaths
) { ) {
DbUtil.doTransaction(conn, () -> { DbUtil.doTransaction(conn, () -> {
Transaction tx = findById(id).orElseThrow();
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
List<Attachment> currentAttachments = findAttachments(id);
var entryRepo = new JdbcAccountEntryRepository(conn); var entryRepo = new JdbcAccountEntryRepository(conn);
var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
var vendorRepo = new JdbcTransactionVendorRepository(conn);
var categoryRepo = new JdbcTransactionCategoryRepository(conn);
Transaction tx = findById(id).orElseThrow();
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
TransactionVendor currentVendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElseThrow();
String currentVendorName = currentVendor == null ? null : currentVendor.getName();
TransactionCategory currentCategory = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElseThrow();
String currentCategoryName = currentCategory == null ? null : currentCategory.getName();
Set<String> currentTags = new HashSet<>(findTags(id));
List<Attachment> currentAttachments = findAttachments(id);
List<String> updateMessages = new ArrayList<>(); List<String> updateMessages = new ArrayList<>();
if (!tx.getTimestamp().equals(utcTimestamp)) { if (!tx.getTimestamp().equals(utcTimestamp)) {
DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), id)); DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", DbUtil.timestampFromUtcLDT(utcTimestamp), id);
updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + "."); updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
} }
BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP); BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
if (!tx.getAmount().equals(scaledAmount)) { if (!tx.getAmount().equals(scaledAmount)) {
DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", List.of(scaledAmount, id)); DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", scaledAmount, id);
updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + "."); updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
} }
if (!tx.getCurrency().equals(currency)) { if (!tx.getCurrency().equals(currency)) {
DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", List.of(currency.getCurrencyCode(), id)); DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", currency.getCurrencyCode(), id);
updateMessages.add("Updated currency to " + currency.getCurrencyCode() + "."); updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
} }
if (!Objects.equals(tx.getDescription(), description)) { if (!Objects.equals(tx.getDescription(), description)) {
DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", List.of(description, id)); DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", description, id);
updateMessages.add("Updated description."); updateMessages.add("Updated description.");
} }
boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) || boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
!tx.getCurrency().equals(currency) || !tx.getCurrency().equals(currency) ||
!tx.getTimestamp().equals(utcTimestamp) || !tx.getTimestamp().equals(utcTimestamp) ||
!currentLinkedAccounts.equals(linkedAccounts); !currentLinkedAccounts.equals(linkedAccounts);
if (updateAccountEntries) { if (shouldUpdateAccountEntries) {
// Delete all entries and re-write them correctly? // Delete all entries and re-write them correctly.
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id)); DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", id);
linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency)); linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency)); linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
updateMessages.add("Updated linked accounts."); updateMessages.add("Updated linked accounts.");
} }
// Manage vendor change.
if (!Objects.equals(vendor, currentVendorName)) {
if (vendor == null || vendor.isBlank()) {
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = NULL WHERE id = ?", id);
} else {
long newVendorId = getOrCreateVendorId(vendor);
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = ? WHERE id = ?", newVendorId, id);
}
updateMessages.add("Updated vendor name to \"" + vendor + "\".");
}
// Manage category change.
if (!Objects.equals(category, currentCategoryName)) {
if (category == null || category.isBlank()) {
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = NULL WHERE id = ?", id);
} else {
long newCategoryId = getOrCreateCategoryId(category);
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = ? WHERE id = ?", newCategoryId, id);
}
updateMessages.add("Updated category name to \"" + category + "\".");
}
// Manage tags changes.
if (!currentTags.equals(tags)) {
Set<String> tagsAdded = new HashSet<>(tags);
tagsAdded.removeAll(currentTags);
Set<String> tagsRemoved = new HashSet<>(currentTags);
tagsRemoved.removeAll(tags);
for (var t : tagsRemoved) removeTag(id, t);
for (var t : tagsAdded) addTag(id, t);
if (!tagsAdded.isEmpty()) {
updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded));
}
if (!tagsRemoved.isEmpty()) {
updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved));
}
}
// Manage attachments changes. // Manage attachments changes.
List<Attachment> removedAttachments = new ArrayList<>(currentAttachments); List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
removedAttachments.removeAll(existingAttachments); removedAttachments.removeAll(existingAttachments);
@ -214,10 +384,12 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
insertAttachmentLink(tx.id, attachment.id); insertAttachmentLink(tx.id, attachment.id);
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\"."); updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
} }
// Add a text history item to any linked accounts detailing the changes.
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages); String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
var historyRepo = new JdbcAccountHistoryItemRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); long historyId = historyRepo.getOrCreateHistoryForTransaction(id);
linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); historyRepo.addTextItem(historyId, updateMessageStr);
}); });
} }
@ -226,16 +398,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
conn.close(); conn.close();
} }
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
return new Transaction(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getBigDecimal("amount"),
Currency.getInstance(rs.getString("currency")),
rs.getString("description")
);
}
private void insertAttachmentLink(long transactionId, long attachmentId) { private void insertAttachmentLink(long transactionId, long attachmentId) {
DbUtil.insertOne( DbUtil.insertOne(
conn, conn,
@ -243,4 +405,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
List.of(transactionId, attachmentId) List.of(transactionId, attachmentId)
); );
} }
private long getTagId(String name) {
return DbUtil.findOne(
conn,
"SELECT id FROM transaction_tag WHERE name = ?",
List.of(name),
rs -> rs.getLong(1)
).orElse(-1L);
}
private void removeTag(long transactionId, String tag) {
long id = getTagId(tag);
if (id != -1) {
DbUtil.update(conn, "DELETE FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", transactionId, id);
}
}
private void addTag(long transactionId, String tag) {
long id = getOrCreateTagId(tag);
boolean exists = DbUtil.count(
conn,
"SELECT COUNT(tag_id) FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?",
transactionId,
id
) > 0;
if (!exists) {
DbUtil.insertOne(
conn,
"INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)",
transactionId,
id
);
}
}
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
return new Transaction(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getBigDecimal("amount"),
Currency.getInstance(rs.getString("currency")),
rs.getString("description"),
rs.getObject("vendor_id", Long.class),
rs.getObject("category_id", Long.class)
);
}
} }

View File

@ -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")
);
}
}

View File

@ -4,10 +4,20 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* Utility class for defining and using all known migrations.
*/
public class Migrations { public class Migrations {
/**
* Gets a list of migrations, as a map with the key being the version to
* migrate from. For example, a migration that takes us from version 42 to
* 43 would exist in the map with key 42.
* @return The map of all migrations.
*/
public static Map<Integer, Migration> getMigrations() { public static Map<Integer, Migration> getMigrations() {
final Map<Integer, Migration> migrations = new HashMap<>(); final Map<Integer, Migration> migrations = new HashMap<>();
migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql")); migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
return migrations; return migrations;
} }
@ -25,4 +35,14 @@ public class Migrations {
} }
return selectedMigration; return selectedMigration;
} }
public static Map<Integer, String> getSchemaVersionCompatibility() {
final Map<Integer, String> compatibilities = new HashMap<>();
compatibilities.put(1, "1.4.0");
return compatibilities;
}
public static String getLatestCompatibleVersion(int schemaVersion) {
return getSchemaVersionCompatibility().get(schemaVersion);
}
} }

View File

@ -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);
}

View File

@ -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 {
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -31,6 +31,15 @@ public final class DbUtil {
setArgs(stmt, List.of(args)); setArgs(stmt, List.of(args));
} }
public static long getGeneratedId(PreparedStatement stmt) {
try (ResultSet rs = stmt.getGeneratedKeys()) {
if (!rs.next()) throw new SQLException("No generated keys available.");
return rs.getLong(1);
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) { public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
try (var stmt = conn.prepareStatement(query)) { try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args); setArgs(stmt, args);
@ -58,6 +67,17 @@ public final class DbUtil {
return findAll(conn, query, pagination, Collections.emptyList(), mapper); return findAll(conn, query, pagination, Collections.emptyList(), mapper);
} }
public static long count(Connection conn, String query, Object... args) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);
var rs = stmt.executeQuery();
if (!rs.next()) throw new UncheckedSqlException("No count result available.");
return rs.getLong(1);
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) { public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
try (var stmt = conn.prepareStatement(query)) { try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args); setArgs(stmt, args);
@ -82,6 +102,10 @@ public final class DbUtil {
} }
} }
public static int update(Connection conn, String query, Object... args) {
return update(conn, query, List.of(args));
}
public static void updateOne(Connection conn, String query, List<Object> args) { public static void updateOne(Connection conn, String query, List<Object> args) {
try (var stmt = conn.prepareStatement(query)) { try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args); setArgs(stmt, args);
@ -92,19 +116,25 @@ public final class DbUtil {
} }
} }
public static void updateOne(Connection conn, String query, Object... args) {
updateOne(conn, query, List.of(args));
}
public static long insertOne(Connection conn, String query, List<Object> args) { public static long insertOne(Connection conn, String query, List<Object> args) {
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
setArgs(stmt, args); setArgs(stmt, args);
int result = stmt.executeUpdate(); int result = stmt.executeUpdate();
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row."); if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
var rs = stmt.getGeneratedKeys(); return getGeneratedId(stmt);
rs.next();
return rs.getLong(1);
} catch (SQLException e) { } catch (SQLException e) {
throw new UncheckedSqlException(e); throw new UncheckedSqlException(e);
} }
} }
public static long insertOne(Connection conn, String query, Object... args) {
return insertOne(conn, query, List.of(args));
}
public static Timestamp timestampFromUtcLDT(LocalDateTime utc) { public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
return Timestamp.from(utc.toInstant(ZoneOffset.UTC)); return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
} }
@ -132,7 +162,9 @@ public final class DbUtil {
public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) { public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
try { try {
conn.setAutoCommit(false); conn.setAutoCommit(false);
return supplier.offer(); T result = supplier.offer();
conn.commit();
return result;
} catch (Exception e) { } catch (Exception e) {
try { try {
conn.rollback(); conn.rollback();

View File

@ -1,5 +1,6 @@
package com.andrewlalis.perfin.data.util; package com.andrewlalis.perfin.data.util;
import com.andrewlalis.perfin.model.Profile;
import javafx.stage.FileChooser; import javafx.stage.FileChooser;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -103,4 +104,14 @@ public class FileUtil {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
public static void copyResourceFile(String resource, Path dest) throws IOException {
try (
var in = Profile.class.getResourceAsStream(resource);
var out = Files.newOutputStream(dest)
) {
if (in == null) throw new IOException("Could not load resource " + resource);
in.transferTo(out);
}
}
} }

View File

@ -2,19 +2,14 @@ package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.PerfinApp; import com.andrewlalis.perfin.PerfinApp;
import com.andrewlalis.perfin.data.DataSource; import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
import com.andrewlalis.perfin.data.util.FileUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.IOException; import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.HashSet;
import java.util.Collections;
import java.util.List;
import java.util.Properties; import java.util.Properties;
import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
@ -33,35 +28,22 @@ import java.util.function.Consumer;
* class maintains a static <em>current</em> profile that can be loaded and * class maintains a static <em>current</em> profile that can be loaded and
* unloaded. * unloaded.
* </p> * </p>
*
* @param name The name of the profile.
* @param settings The profile's settings.
* @param dataSource The profile's data source.
*/ */
public class Profile { public record Profile(String name, Properties settings, DataSource dataSource) {
private static final Logger log = LoggerFactory.getLogger(Profile.class); private static final Logger log = LoggerFactory.getLogger(Profile.class);
private static Profile current; private static Profile current;
private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>(); private static final Set<WeakReference<Consumer<Profile>>> currentProfileListeners = new HashSet<>();
private final String name; @Override
private final Properties settings; public String toString() {
private final DataSource dataSource;
private Profile(String name, Properties settings, DataSource dataSource) {
this.name = name;
this.settings = settings;
this.dataSource = dataSource;
}
public String getName() {
return name; return name;
} }
public Properties getSettings() {
return settings;
}
public DataSource getDataSource() {
return dataSource;
}
public static Path getDir(String name) { public static Path getDir(String name) {
return PerfinApp.APP_DIR.resolve(name); return PerfinApp.APP_DIR.resolve(name);
} }
@ -78,89 +60,23 @@ public class Profile {
return current; return current;
} }
public static void setCurrent(Profile profile) {
current = profile;
for (var ref : currentProfileListeners) {
Consumer<Profile> consumer = ref.get();
if (consumer != null) {
consumer.accept(profile);
}
}
currentProfileListeners.removeIf(ref -> ref.get() == null);
log.debug("Current profile set to {}.", current.name());
}
public static void whenLoaded(Consumer<Profile> consumer) { public static void whenLoaded(Consumer<Profile> consumer) {
if (current != null) { if (current != null) {
consumer.accept(current); consumer.accept(current);
} else {
profileLoadListeners.add(consumer);
}
}
public static List<String> getAvailableProfiles() {
try (var files = Files.list(PerfinApp.APP_DIR)) {
return files.filter(Files::isDirectory)
.map(path -> path.getFileName().toString())
.sorted().toList();
} catch (IOException e) {
log.error("Failed to get a list of available profiles.", e);
return Collections.emptyList();
}
}
public static String getLastProfile() {
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
if (Files.exists(lastProfileFile)) {
try {
String s = Files.readString(lastProfileFile).strip().toLowerCase();
if (!s.isBlank()) return s;
} catch (IOException e) {
log.error("Failed to read " + lastProfileFile, e);
}
}
return "default";
}
public static void saveLastProfile(String name) {
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
try {
Files.writeString(lastProfileFile, name);
} catch (IOException e) {
log.error("Failed to write " + lastProfileFile, e);
}
}
public static void loadLast() throws ProfileLoadException {
load(getLastProfile());
}
public static void load(String name) throws ProfileLoadException {
if (Files.notExists(getDir(name))) {
try {
initProfileDir(name);
} catch (IOException e) {
FileUtil.deleteIfPossible(getDir(name));
throw new ProfileLoadException("Failed to initialize new profile directory.", e);
}
}
Properties settings = new Properties();
try (var in = Files.newInputStream(getSettingsFile(name))) {
settings.load(in);
} catch (IOException e) {
throw new ProfileLoadException("Failed to load profile settings.", e);
}
current = new Profile(name, settings, new JdbcDataSourceFactory().getDataSource(name));
saveLastProfile(current.getName());
for (var c : profileLoadListeners) {
c.accept(current);
}
}
private static void initProfileDir(String name) throws IOException {
Files.createDirectory(getDir(name));
copyResourceFile("/text/profileDirReadme.txt", getDir(name).resolve("README.txt"));
copyResourceFile("/text/defaultProfileSettings.properties", getSettingsFile(name));
Files.createDirectory(getContentDir(name));
copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt"));
}
private static void copyResourceFile(String resource, Path dest) throws IOException {
try (
var in = Profile.class.getResourceAsStream(resource);
var out = Files.newOutputStream(dest)
) {
if (in == null) throw new IOException("Could not load resource " + resource);
in.transferTo(out);
} }
currentProfileListeners.add(new WeakReference<>(consumer));
} }
public static boolean validateName(String name) { public static boolean validateName(String name) {
@ -168,9 +84,4 @@ public class Profile {
name.matches("\\w+") && name.matches("\\w+") &&
name.toLowerCase().equals(name); name.toLowerCase().equals(name);
} }
@Override
public String toString() {
return name;
}
} }

View File

@ -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"));
}
}

View File

@ -17,13 +17,17 @@ public class Transaction extends IdEntity {
private final BigDecimal amount; private final BigDecimal amount;
private final Currency currency; private final Currency currency;
private final String description; private final String description;
private final Long vendorId;
private final Long categoryId;
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) { public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) {
super(id); super(id);
this.timestamp = timestamp; this.timestamp = timestamp;
this.amount = amount; this.amount = amount;
this.currency = currency; this.currency = currency;
this.description = description; this.description = description;
this.vendorId = vendorId;
this.categoryId = categoryId;
} }
public LocalDateTime getTimestamp() { public LocalDateTime getTimestamp() {
@ -42,6 +46,14 @@ public class Transaction extends IdEntity {
return description; return description;
} }
public Long getVendorId() {
return vendorId;
}
public Long getCategoryId() {
return categoryId;
}
public MoneyValue getMoneyAmount() { public MoneyValue getMoneyAmount() {
return new MoneyValue(amount, currency); return new MoneyValue(amount, currency);
} }

View File

@ -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;
}
}

View File

@ -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
);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -1,7 +0,0 @@
package com.andrewlalis.perfin.model.history;
public enum AccountHistoryItemType {
TEXT,
ACCOUNT_ENTRY,
BALANCE_RECORD
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -1,8 +1,10 @@
package com.andrewlalis.perfin.view; package com.andrewlalis.perfin.view;
import javafx.beans.WeakListener; import javafx.beans.WeakListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener; import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.scene.Node;
import java.lang.ref.WeakReference; import java.lang.ref.WeakReference;
import java.util.List; import java.util.List;
@ -86,4 +88,9 @@ public class BindingUtil {
return false; return false;
} }
} }
public static void bindManagedAndVisible(Node node, ObservableValue<? extends Boolean> value) {
node.managedProperty().bind(node.visibleProperty());
node.visibleProperty().bind(value);
}
} }

View File

@ -9,6 +9,7 @@ import javafx.stage.Stage;
import javafx.stage.StageStyle; import javafx.stage.StageStyle;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
@ -17,12 +18,14 @@ import java.util.function.Consumer;
*/ */
public class StartupSplashScreen extends Stage implements Consumer<String> { public class StartupSplashScreen extends Stage implements Consumer<String> {
private final List<ThrowableConsumer<Consumer<String>>> tasks; private final List<ThrowableConsumer<Consumer<String>>> tasks;
private final boolean delayTasks;
private boolean startupSuccessful = false; private boolean startupSuccessful = false;
private final TextArea textArea = new TextArea(); private final TextArea textArea = new TextArea();
public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks) { public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks, boolean delayTasks) {
this.tasks = tasks; this.tasks = tasks;
this.delayTasks = delayTasks;
setTitle("Starting Perfin..."); setTitle("Starting Perfin...");
setResizable(false); setResizable(false);
initStyle(StageStyle.UNDECORATED); initStyle(StageStyle.UNDECORATED);
@ -60,37 +63,50 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
return scene; return scene;
} }
/**
* Runs all tasks sequentially, invoking each one on the JavaFX main thread,
* and quitting if there's any exception thrown.
*/
private void runTasks() { private void runTasks() {
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
try { if (delayTasks) sleepOrThrowRE(1000);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
for (var task : tasks) { for (var task : tasks) {
try { try {
task.accept(this); CompletableFuture<Void> future = new CompletableFuture<>();
Thread.sleep(500); Platform.runLater(() -> {
try {
task.accept(this);
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
future.join();
if (delayTasks) sleepOrThrowRE(500);
} catch (Exception e) { } catch (Exception e) {
accept("Startup failed: " + e.getMessage()); accept("Startup failed: " + e.getMessage());
e.printStackTrace(System.err); e.printStackTrace(System.err);
try { sleepOrThrowRE(5000);
Thread.sleep(5000);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
Platform.runLater(this::close); Platform.runLater(this::close);
return; return;
} }
} }
accept("Startup successful!"); accept("Startup successful!");
try { if (delayTasks) sleepOrThrowRE(1000);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
startupSuccessful = true; startupSuccessful = true;
Platform.runLater(this::close); Platform.runLater(this::close);
}); });
} }
/**
* Helper method to sleep the current thread or throw a runtime exception.
* @param ms The number of milliseconds to sleep for.
*/
private static void sleepOrThrowRE(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
} }

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -1,9 +1,8 @@
package com.andrewlalis.perfin.view.component; package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.control.AccountViewController;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.history.AccountHistoryItem; import com.andrewlalis.perfin.model.history.HistoryItem;
import com.andrewlalis.perfin.model.history.HistoryTextItem;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
@ -11,7 +10,7 @@ import javafx.scene.layout.BorderPane;
* A tile that shows a brief bit of information about an account history item. * A tile that shows a brief bit of information about an account history item.
*/ */
public abstract class AccountHistoryItemTile extends BorderPane { public abstract class AccountHistoryItemTile extends BorderPane {
public AccountHistoryItemTile(AccountHistoryItem item) { public AccountHistoryItemTile(HistoryItem item) {
getStyleClass().add("tile"); getStyleClass().add("tile");
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp())); Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp()));
@ -20,14 +19,11 @@ public abstract class AccountHistoryItemTile extends BorderPane {
} }
public static AccountHistoryItemTile forItem( public static AccountHistoryItemTile forItem(
AccountHistoryItem item, HistoryItem item
AccountHistoryItemRepository repo,
AccountViewController controller
) { ) {
return switch (item.getType()) { if (item instanceof HistoryTextItem t) {
case TEXT -> new AccountHistoryTextTile(item, repo); return new AccountHistoryTextTile(t);
case ACCOUNT_ENTRY -> new AccountHistoryAccountEntryTile(item, repo); }
case BALANCE_RECORD -> new AccountHistoryBalanceRecordTile(item, repo, controller); throw new RuntimeException("Unsupported history item type: " + item.getType());
};
} }
} }

View File

@ -1,14 +1,12 @@
package com.andrewlalis.perfin.view.component; package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.model.history.HistoryTextItem;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import javafx.scene.text.Text; import javafx.scene.text.Text;
import javafx.scene.text.TextFlow; import javafx.scene.text.TextFlow;
public class AccountHistoryTextTile extends AccountHistoryItemTile { public class AccountHistoryTextTile extends AccountHistoryItemTile {
public AccountHistoryTextTile(AccountHistoryItem item, AccountHistoryItemRepository repo) { public AccountHistoryTextTile(HistoryTextItem item) {
super(item); super(item);
String text = repo.getTextItem(item.id); setCenter(new TextFlow(new Text(item.getDescription())));
setCenter(new TextFlow(new Text(text)));
} }
} }

View File

@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox<Account> {
showBalanceProperty.set(value); showBalanceProperty.set(value);
} }
private static class CellFactory implements Callback<ListView<Account>, ListCell<Account>> { /**
private final BooleanProperty showBalanceProp; * A simple cell factory that just returns instances of {@link AccountListCell}.
* @param showBalanceProp Whether to show the account's balance.
private CellFactory(BooleanProperty showBalanceProp) { */
this.showBalanceProp = showBalanceProp; private record CellFactory(BooleanProperty showBalanceProp) implements Callback<ListView<Account>, ListCell<Account>> {
}
@Override @Override
public ListCell<Account> call(ListView<Account> param) { public ListCell<Account> call(ListView<Account> param) {
return new AccountListCell(showBalanceProp); return new AccountListCell(showBalanceProp);
} }
} }
/**
* A list cell implementation which shows an account's name, and optionally,
* its current derived balance underneath.
*/
private static class AccountListCell extends ListCell<Account> { private static class AccountListCell extends ListCell<Account> {
private final BooleanProperty showBalanceProp; private final BooleanProperty showBalanceProp;
private final Label nameLabel = new Label(); private final Label nameLabel = new Label();
@ -110,7 +112,7 @@ public class AccountSelectionBox extends ComboBox<Account> {
nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")"); nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
if (showBalanceProp.get()) { if (showBalanceProp.get()) {
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
BigDecimal balance = repo.deriveCurrentBalance(item.id); BigDecimal balance = repo.deriveCurrentBalance(item.id);
Platform.runLater(() -> { Platform.runLater(() -> {
balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency()))); balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));

View File

@ -81,7 +81,7 @@ public class AccountTile extends BorderPane {
Label balanceLabel = new Label("Computing balance..."); Label balanceLabel = new Label("Computing balance...");
balanceLabel.getStyleClass().addAll("mono-font"); balanceLabel.getStyleClass().addAll("mono-font");
balanceLabel.setDisable(true); balanceLabel.setDisable(true);
Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
BigDecimal balance = repo.deriveCurrentBalance(account.id); BigDecimal balance = repo.deriveCurrentBalance(account.id);
String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency())); String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()));
Platform.runLater(() -> { Platform.runLater(() -> {

View File

@ -46,7 +46,7 @@ public class AttachmentPreview extends BorderPane {
boolean showDocIcon = true; boolean showDocIcon = true;
Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp"); Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp");
if (imageTypes.contains(attachment.getContentType())) { if (imageTypes.contains(attachment.getContentType())) {
try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName())))) { try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().name())))) {
Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true); Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
contentContainer.setCenter(new ImageView(img)); contentContainer.setCenter(new ImageView(img));
showDocIcon = false; showDocIcon = false;
@ -69,7 +69,7 @@ public class AttachmentPreview extends BorderPane {
this.setCenter(stackPane); this.setCenter(stackPane);
this.setOnMouseClicked(event -> { this.setOnMouseClicked(event -> {
if (this.isHover()) { if (this.isHover()) {
Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName())); Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().name()));
PerfinApp.instance.getHostServices().showDocument(filePath.toAbsolutePath().toUri().toString()); PerfinApp.instance.getHostServices().showDocument(filePath.toAbsolutePath().toUri().toString());
} }
}); });

View File

@ -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());
}
}
}

View File

@ -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));
}
}
}

View File

@ -100,7 +100,7 @@ public class TransactionTile extends BorderPane {
} }
private CompletableFuture<CreditAndDebitAccounts> getCreditAndDebitAccounts(Transaction transaction) { private CompletableFuture<CreditAndDebitAccounts> getCreditAndDebitAccounts(Transaction transaction) {
return Profile.getCurrent().getDataSource().mapRepoAsync( return Profile.getCurrent().dataSource().mapRepoAsync(
TransactionRepository.class, TransactionRepository.class,
repo -> repo.findLinkedAccounts(transaction.id) repo -> repo.findLinkedAccounts(transaction.id)
); );

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -1,24 +1,40 @@
package com.andrewlalis.perfin.view.component.validation; package com.andrewlalis.perfin.view.component.validation;
import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator; import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator;
import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.Property; import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import java.util.concurrent.CompletableFuture;
/** /**
* Fluent interface for applying a validator to one or more controls. * Fluent interface for applying a validator to one or more controls.
* @param <T> The value type. * @param <T> The value type.
*/ */
public class ValidationApplier<T> { public class ValidationApplier<T> {
private final ValidationFunction<T> validator; private final AsyncValidationFunction<T> validator;
private ValidationDecorator decorator = new FieldSubtextDecorator(); private ValidationDecorator decorator = new FieldSubtextDecorator();
private boolean validateInitially = false; private boolean validateInitially = false;
public ValidationApplier(ValidationFunction<T> validator) { public ValidationApplier(ValidationFunction<T> validator) {
this.validator = input -> CompletableFuture.completedFuture(validator.validate(input));
}
public ValidationApplier(AsyncValidationFunction<T> validator) {
this.validator = validator; this.validator = validator;
} }
public static <T> ValidationApplier<T> of(ValidationFunction<T> validator) {
return new ValidationApplier<>(validator);
}
public static <T> ValidationApplier<T> ofAsync(AsyncValidationFunction<T> validator) {
return new ValidationApplier<>(validator);
}
public ValidationApplier<T> decoratedWith(ValidationDecorator decorator) { public ValidationApplier<T> decoratedWith(ValidationDecorator decorator) {
this.decorator = decorator; this.decorator = decorator;
return this; return this;
@ -29,24 +45,47 @@ public class ValidationApplier<T> {
return this; return this;
} }
/**
* Attaches the configured validator and decorator to a node, so that when
* the node's specified valueProperty changes, the validator will be called
* and if the new value is invalid, the decorator will update the UI to
* show the message(s) to the user.
* @param node The node to attach to.
* @param valueProperty The property to listen for changes and validate on.
* @param triggerProperties Additional properties that, when changed, can
* trigger validation.
* @return A boolean expression that tells whether the given valueProperty
* is valid at any given time.
*/
public BooleanExpression attach(Node node, Property<T> valueProperty, Property<?>... triggerProperties) { public BooleanExpression attach(Node node, Property<T> valueProperty, Property<?>... triggerProperties) {
BooleanExpression validProperty = BooleanExpression.booleanExpression( final SimpleBooleanProperty validProperty = new SimpleBooleanProperty();
valueProperty.map(value -> validator.validate(value).isValid())
);
valueProperty.addListener((observable, oldValue, newValue) -> { valueProperty.addListener((observable, oldValue, newValue) -> {
ValidationResult result = validator.validate(newValue); validProperty.set(false); // Always set valid to false before we start validation.
decorator.decorate(node, result); validator.validate(newValue)
.thenAccept(result -> Platform.runLater(() -> {
validProperty.set(result.isValid());
decorator.decorate(node, result);
}));
}); });
for (Property<?> influencingProperty : triggerProperties) { for (Property<?> influencingProperty : triggerProperties) {
influencingProperty.addListener((observable, oldValue, newValue) -> { influencingProperty.addListener((observable, oldValue, newValue) -> {
ValidationResult result = validator.validate(valueProperty.getValue()); validProperty.set(false); // Always set valid to false before we start validation.
decorator.decorate(node, result); validator.validate(valueProperty.getValue())
.thenAccept(result -> Platform.runLater(() -> {
validProperty.set(result.isValid());
decorator.decorate(node, result);
}));
}); });
} }
if (validateInitially) { if (validateInitially) {
// Call the decorator once to perform validation right away. // Call the decorator once to perform validation right away.
decorator.decorate(node, validator.validate(valueProperty.getValue())); validProperty.set(false); // Always set valid to false before we start validation.
validator.validate(valueProperty.getValue())
.thenAccept(result -> Platform.runLater(() -> {
validProperty.set(result.isValid());
decorator.decorate(node, result);
}));
} }
return validProperty; return validProperty;
} }

View File

@ -4,6 +4,7 @@ import com.andrewlalis.perfin.view.component.validation.ValidationDecorator;
import com.andrewlalis.perfin.view.component.validation.ValidationResult; import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane; import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -55,6 +56,9 @@ public class FieldSubtextDecorator implements ValidationDecorator {
errorLabel.getStyleClass().addAll("small-font", "negative-color-text-fill"); errorLabel.getStyleClass().addAll("small-font", "negative-color-text-fill");
errorLabel.setWrapText(true); errorLabel.setWrapText(true);
VBox validationContainer = new VBox(node, errorLabel); VBox validationContainer = new VBox(node, errorLabel);
if (trueParent instanceof HBox) {
HBox.setHgrow(validationContainer, HBox.getHgrow(node));
}
validationContainer.setUserData(WRAP_KEY); validationContainer.setUserData(WRAP_KEY);
trueParent.getChildren().add(idx, validationContainer); trueParent.getChildren().add(idx, validationContainer);
return errorLabel; return errorLabel;

View File

@ -1,10 +1,14 @@
package com.andrewlalis.perfin.view.component.validation.validators; package com.andrewlalis.perfin.view.component.validation.validators;
import com.andrewlalis.perfin.view.component.validation.ValidationFunction; import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction;
import com.andrewlalis.perfin.view.component.validation.ValidationResult; import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.function.Function; import java.util.function.Function;
/** /**
@ -12,32 +16,73 @@ import java.util.function.Function;
* determine if it's valid. If invalid, a message is added. * determine if it's valid. If invalid, a message is added.
* @param <T> The value type. * @param <T> The value type.
*/ */
public class PredicateValidator<T> implements ValidationFunction<T> { public class PredicateValidator<T> implements AsyncValidationFunction<T> {
private record ValidationStep<T>(Function<T, Boolean> predicate, String message, boolean terminal) {} private static final Logger logger = LoggerFactory.getLogger(PredicateValidator.class);
private record ValidationStep<T>(Function<T, CompletableFuture<Boolean>> predicate, String message, boolean terminal) {}
private final List<ValidationStep<T>> steps = new ArrayList<>(); private final List<ValidationStep<T>> steps = new ArrayList<>();
public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) { private PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage, boolean terminal) {
steps.add(new ValidationStep<>(predicate, errorMessage, false)); steps.add(new ValidationStep<>(
v -> CompletableFuture.completedFuture(predicate.apply(v)),
errorMessage,
terminal
));
return this; return this;
} }
public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) { private PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage, boolean terminal) {
steps.add(new ValidationStep<>(predicate, errorMessage, true)); steps.add(new ValidationStep<>(asyncPredicate, errorMessage, terminal));
return this; return this;
} }
public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
return addPredicate(predicate, errorMessage, false);
}
public PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
return addAsyncPredicate(asyncPredicate, errorMessage, false);
}
/**
* Adds a terminal predicate, that is, if the given boolean function
* evaluates to false, then no further predicates are evaluated.
* @param predicate The predicate function.
* @param errorMessage The error message to display if the predicate
* evaluates to false for a given value.
* @return A reference to the validator, for method chaining.
*/
public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
return addPredicate(predicate, errorMessage, true);
}
public PredicateValidator<T> addTerminalAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
return addAsyncPredicate(asyncPredicate, errorMessage);
}
@Override @Override
public ValidationResult validate(T input) { public CompletableFuture<ValidationResult> validate(T input) {
List<String> messages = new ArrayList<>(); CompletableFuture<ValidationResult> cf = new CompletableFuture<>();
for (var step : steps) { Thread.ofVirtual().start(() -> {
if (!step.predicate().apply(input)) { List<String> messages = new ArrayList<>();
messages.add(step.message()); for (var step : steps) {
if (step.terminal()) { try {
return new ValidationResult(messages); boolean success = step.predicate().apply(input).get();
if (!success) {
messages.add(step.message());
if (step.terminal()) {
cf.complete(new ValidationResult(messages));
return; // Exit if this is a terminal step and it failed.
}
}
} catch (InterruptedException | ExecutionException e) {
logger.error("Applying a predicate to input failed.", e);
cf.completeExceptionally(e);
} }
} }
} cf.complete(new ValidationResult(messages));
return new ValidationResult(messages); });
return cf;
} }
} }

View File

@ -19,4 +19,5 @@ module com.andrewlalis.perfin {
opens com.andrewlalis.perfin.view to javafx.fxml; opens com.andrewlalis.perfin.view to javafx.fxml;
opens com.andrewlalis.perfin.view.component to javafx.fxml; opens com.andrewlalis.perfin.view.component to javafx.fxml;
opens com.andrewlalis.perfin.view.component.validation to javafx.fxml; opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
exports com.andrewlalis.perfin.model.history to javafx.graphics;
} }

View File

@ -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>

View File

@ -55,7 +55,7 @@
</PropertiesPane> </PropertiesPane>
<Separator/> <Separator/>
<HBox styleClass="std-padding,std-spacing"> <HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
<Button text="Save" fx:id="saveButton" onAction="#save"/> <Button text="Save" fx:id="saveButton" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/> <Button text="Cancel" onAction="#cancel"/>
</HBox> </HBox>

View File

@ -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>

View File

@ -5,6 +5,8 @@
<?import com.andrewlalis.perfin.view.component.PropertiesPane?> <?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?> <?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<?import com.andrewlalis.perfin.view.component.CategorySelectionBox?>
<?import com.andrewlalis.perfin.view.component.StyledText?>
<BorderPane xmlns="http://javafx.com/javafx" <BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.EditTransactionController" fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
@ -27,10 +29,10 @@
<TextField fx:id="timestampField" styleClass="mono-font"/> <TextField fx:id="timestampField" styleClass="mono-font"/>
<Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/> <Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/>
<TextField fx:id="amountField" styleClass="mono-font"/> <HBox styleClass="std-spacing">
<TextField fx:id="amountField" styleClass="mono-font" HBox.hgrow="ALWAYS"/>
<Label text="Currency" labelFor="${currencyChoiceBox}" styleClass="bold-text"/> <ChoiceBox fx:id="currencyChoiceBox"/>
<ChoiceBox fx:id="currencyChoiceBox"/> </HBox>
<Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/> <Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
<TextArea <TextArea
@ -43,15 +45,78 @@
<!-- Container for linked accounts --> <!-- Container for linked accounts -->
<HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer"> <HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
<VBox> <VBox HBox.hgrow="ALWAYS">
<Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/> <Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
<AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/> <AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
</VBox> </VBox>
<VBox> <VBox HBox.hgrow="ALWAYS">
<Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/> <Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
<AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/> <AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
</VBox> </VBox>
</HBox> </HBox>
<!-- Additional, mostly optional properties -->
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
</columnConstraints>
<VBox>
<Label text="Vendor" labelFor="${vendorComboBox}" styleClass="bold-text"/>
<Hyperlink fx:id="vendorsHyperlink" text="Manage vendors" styleClass="small-font"/>
</VBox>
<ComboBox fx:id="vendorComboBox" editable="true" maxWidth="Infinity"/>
<VBox>
<Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
<Hyperlink fx:id="categoriesHyperlink" text="Manage categories" styleClass="small-font"/>
</VBox>
<CategorySelectionBox fx:id="categoryComboBox" maxWidth="Infinity"/>
<VBox>
<Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
<Hyperlink fx:id="tagsHyperlink" text="Manage tags" styleClass="small-font"/>
</VBox>
<VBox maxWidth="Infinity">
<HBox styleClass="std-spacing">
<ComboBox fx:id="tagsComboBox" editable="true" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
<Button fx:id="addTagButton" text="Add" HBox.hgrow="NEVER"/>
</HBox>
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
</VBox>
<Label text="Line Items" styleClass="bold-text"/>
<VBox maxWidth="Infinity">
<Button text="Add Line Item" fx:id="addLineItemButton" disable="true"/>
<StyledText styleClass="small-font">
Line items aren't yet supported. I'm working on it!
</StyledText>
<VBox styleClass="std-spacing" fx:id="addLineItemForm">
<HBox styleClass="std-spacing">
<VBox>
<Label text="Quantity" styleClass="bold-text,small-font"/>
<Spinner fx:id="lineItemQuantitySpinner" minWidth="60" maxWidth="60"/>
</VBox>
<VBox HBox.hgrow="ALWAYS">
<Label text="Value per Item" styleClass="bold-text,small-font"/>
<TextField fx:id="lineItemValueField"/>
</VBox>
</HBox>
<VBox>
<Label text="Description" styleClass="bold-text,small-font"/>
<TextField fx:id="lineItemDescriptionField"/>
</VBox>
<HBox styleClass="std-spacing" alignment="CENTER_RIGHT">
<Button text="Add" fx:id="addLineItemAddButton"/>
<Button text="Cancel" fx:id="addLineItemCancelButton"/>
</HBox>
</VBox>
<VBox fx:id="lineItemsVBox"/>
</VBox>
</PropertiesPane>
<!-- Container for attachments --> <!-- Container for attachments -->
<VBox styleClass="std-padding"> <VBox styleClass="std-padding">
<Label text="Attachments" styleClass="bold-text"/> <Label text="Attachments" styleClass="bold-text"/>

View File

@ -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>

View File

@ -28,7 +28,7 @@
<!-- App footer --> <!-- App footer -->
<bottom> <bottom>
<HBox styleClass="std-padding,std-spacing"> <HBox styleClass="std-padding,std-spacing">
<Label text="Perfin Version 1.4.0"/> <Label text="Perfin Version 1.5.0"/>
<AnchorPane> <AnchorPane>
<Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/> <Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
</AnchorPane> </AnchorPane>

View File

@ -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"
}
]

View File

@ -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;

View File

@ -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;

View File

@ -8,14 +8,6 @@ CREATE TABLE account (
currency VARCHAR(3) NOT NULL currency VARCHAR(3) NOT NULL
); );
CREATE TABLE transaction (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
timestamp TIMESTAMP NOT NULL,
amount NUMERIC(12, 4) NOT NULL,
currency VARCHAR(3) NOT NULL,
description VARCHAR(255) NULL
);
CREATE TABLE attachment ( CREATE TABLE attachment (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
uploaded_at TIMESTAMP NOT NULL, uploaded_at TIMESTAMP NOT NULL,
@ -24,6 +16,45 @@ CREATE TABLE attachment (
content_type VARCHAR(255) NOT NULL content_type VARCHAR(255) NOT NULL
); );
/* TRANSACTION ENTITIES */
CREATE TABLE transaction_vendor (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
description VARCHAR(255)
);
CREATE TABLE transaction_category (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT DEFAULT NULL,
name VARCHAR(63) NOT NULL UNIQUE,
color VARCHAR(6) NOT NULL DEFAULT 'FFFFFF',
CONSTRAINT fk_transaction_category_parent
FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_tag (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(63) NOT NULL UNIQUE
);
CREATE TABLE transaction (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
timestamp TIMESTAMP NOT NULL,
amount NUMERIC(12, 4) NOT NULL,
currency VARCHAR(3) NOT NULL,
description VARCHAR(255) NULL,
vendor_id BIGINT DEFAULT NULL,
category_id BIGINT DEFAULT NULL,
CONSTRAINT fk_transaction_vendor
FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id)
ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT fk_transaction_category
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE SET NULL
);
CREATE TABLE account_entry ( CREATE TABLE account_entry (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
timestamp TIMESTAMP NOT NULL, timestamp TIMESTAMP NOT NULL,
@ -52,6 +83,34 @@ CREATE TABLE transaction_attachment (
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE transaction_tag_join (
transaction_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
PRIMARY KEY (transaction_id, tag_id),
CONSTRAINT fk_transaction_tag_join_transaction
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_tag_join_tag
FOREIGN KEY (tag_id) REFERENCES transaction_tag(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE transaction_line_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
transaction_id BIGINT NOT NULL,
value_per_item NUMERIC(12, 4) NOT NULL,
quantity INT NOT NULL DEFAULT 1,
idx INT NOT NULL DEFAULT 0,
description VARCHAR(255) NOT NULL,
CONSTRAINT fk_transaction_line_item_transaction
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT ck_transaction_line_item_quantity_positive
CHECK quantity > 0
);
/* BALANCE RECORD ENTITIES */
CREATE TABLE balance_record ( CREATE TABLE balance_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
timestamp TIMESTAMP NOT NULL, timestamp TIMESTAMP NOT NULL,
@ -75,42 +134,49 @@ CREATE TABLE balance_record_attachment (
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE account_history_item ( /* HISTORY */
CREATE TABLE history (
id BIGINT PRIMARY KEY AUTO_INCREMENT
);
CREATE TABLE history_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
history_id BIGINT NOT NULL,
timestamp TIMESTAMP NOT NULL, timestamp TIMESTAMP NOT NULL,
account_id BIGINT NOT NULL,
type VARCHAR(63) NOT NULL, type VARCHAR(63) NOT NULL,
CONSTRAINT fk_account_history_item_account CONSTRAINT fk_history_item_history
FOREIGN KEY (account_id) REFERENCES account(id) FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE account_history_item_text ( CREATE TABLE history_item_text (
item_id BIGINT NOT NULL PRIMARY KEY, id BIGINT PRIMARY KEY,
description VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL,
CONSTRAINT fk_account_history_item_text_pk CONSTRAINT fk_history_item_text_pk
FOREIGN KEY (item_id) REFERENCES account_history_item(id) FOREIGN KEY (id) REFERENCES history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE account_history_item_account_entry ( CREATE TABLE history_account (
item_id BIGINT NOT NULL PRIMARY KEY, account_id BIGINT NOT NULL,
entry_id BIGINT NOT NULL, history_id BIGINT NOT NULL,
CONSTRAINT fk_account_history_item_account_entry_pk PRIMARY KEY (account_id, history_id),
FOREIGN KEY (item_id) REFERENCES account_history_item(id) CONSTRAINT fk_history_account_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_history_item_account_entry CONSTRAINT fk_history_account_history
FOREIGN KEY (entry_id) REFERENCES account_entry(id) FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE account_history_item_balance_record ( CREATE TABLE history_transaction (
item_id BIGINT NOT NULL PRIMARY KEY, transaction_id BIGINT NOT NULL,
record_id BIGINT NOT NULL, history_id BIGINT NOT NULL,
CONSTRAINT fk_account_history_item_balance_record_pk PRIMARY KEY (transaction_id, history_id),
FOREIGN KEY (item_id) REFERENCES account_history_item(id) CONSTRAINT fk_history_transaction_transaction
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_history_item_balance_record CONSTRAINT fk_history_transaction_history
FOREIGN KEY (record_id) REFERENCES balance_record(id) FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );

View File

@ -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>

View File

@ -6,6 +6,7 @@
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<?import javafx.scene.text.Text?> <?import javafx.scene.text.Text?>
<?import javafx.scene.text.TextFlow?> <?import javafx.scene.text.TextFlow?>
<?import javafx.scene.shape.Circle?>
<BorderPane xmlns="http://javafx.com/javafx" <BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.TransactionViewController" fx:controller="com.andrewlalis.perfin.control.TransactionViewController"
@ -32,6 +33,37 @@
<Label text="Description" styleClass="bold-text"/> <Label text="Description" styleClass="bold-text"/>
<Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/> <Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/>
</PropertiesPane> </PropertiesPane>
<PropertiesPane vgap="5" hgap="5">
<columnConstraints>
<ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
</columnConstraints>
<Label text="Vendor" styleClass="bold-text"/>
<Label fx:id="vendorLabel"/>
</PropertiesPane>
<PropertiesPane vgap="5" hgap="5">
<columnConstraints>
<ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
</columnConstraints>
<Label text="Category" styleClass="bold-text"/>
<HBox styleClass="std-spacing">
<Circle radius="8" fx:id="categoryColorIndicator"/>
<Label fx:id="categoryLabel"/>
</HBox>
</PropertiesPane>
<PropertiesPane vgap="5" hgap="5">
<columnConstraints>
<ColumnConstraints minWidth="100" halignment="LEFT" hgrow="NEVER"/>
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
</columnConstraints>
<Label text="Tags" styleClass="bold-text"/>
<Label fx:id="tagsLabel"/>
</PropertiesPane>
<VBox> <VBox>
<TextFlow> <TextFlow>
<Text text="Debited to"/> <Text text="Debited to"/>
@ -45,7 +77,7 @@
<AttachmentsViewPane fx:id="attachmentsViewPane"/> <AttachmentsViewPane fx:id="attachmentsViewPane"/>
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT"> <HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
<Button text="Edit" onAction="#editTransaction"/> <Button text="Edit" onAction="#editTransaction"/>
<Button text="Delete this transaction" onAction="#deleteTransaction"/> <Button text="Delete" onAction="#deleteTransaction"/>
</HBox> </HBox>
</VBox> </VBox>
</ScrollPane> </ScrollPane>

View File

@ -6,6 +6,7 @@
<?import javafx.scene.control.Label?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.ScrollPane?> <?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<?import javafx.scene.control.TextField?>
<BorderPane xmlns="http://javafx.com/javafx" <BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController" fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
@ -20,7 +21,8 @@
<HBox> <HBox>
<BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS"> <BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
<top> <top>
<HBox styleClass="std-padding,std-spacing"> <HBox styleClass="padding-extra,std-spacing">
<TextField fx:id="searchField" promptText="Search"/>
<PropertiesPane hgap="5" vgap="5"> <PropertiesPane hgap="5" vgap="5">
<Label text="Filter by Account"/> <Label text="Filter by Account"/>
<AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/> <AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>

View File

@ -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>