diff --git a/pom.xml b/pom.xml index e3dbf69..08d6461 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.andrewlalis perfin - 1.11.0 + 1.12.0 21 diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index 9ab3fc3..2f44e97 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -13,12 +13,12 @@ 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.TransactionLineItemTile; +import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction; import com.andrewlalis.perfin.view.component.validation.ValidationApplier; 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.PredicateValidator; import javafx.application.Platform; -import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanExpression; import javafx.beans.property.*; import javafx.beans.value.ObservableValue; @@ -41,6 +41,7 @@ import java.time.DateTimeException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.concurrent.CompletableFuture; import static com.andrewlalis.perfin.PerfinApp.router; @@ -57,6 +58,7 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public TextField timestampField; @FXML public TextField amountField; @FXML public ChoiceBox currencyChoiceBox; + private final BooleanProperty basicTransactionInfoValid = new SimpleBooleanProperty(false); @FXML public TextArea descriptionField; @FXML public HBox linkedAccountsContainer; @@ -132,11 +134,13 @@ public class EditTransactionController implements RouteSelectionListener { var linkedAccountsValid = initializeLinkedAccountsValidationUi(); initializeTagSelectionUi(); initializeLineItemsUi(); + initializeDuplicateTransactionUi(); vendorsHyperlink.setOnAction(event -> router.navigate("vendors")); categoriesHyperlink.setOnAction(event -> router.navigate("categories")); tagsHyperlink.setOnAction(event -> router.navigate("tags")); + basicTransactionInfoValid.bind(timestampValid.and(amountValid).and(currencyChoiceBox.valueProperty().isNotNull())); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); saveButton.disableProperty().bind(formValid.not()); } @@ -313,6 +317,49 @@ public class EditTransactionController implements RouteSelectionListener { .attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty()); } + record BasicTransactionInfo(LocalDateTime timestamp, BigDecimal amount, Currency currency) {} + + private BasicTransactionInfo getBasicTransactionInfo() { + if (!basicTransactionInfoValid.get()) return null; + return new BasicTransactionInfo( + DateUtil.localToUTC(parseTimestamp()), + new BigDecimal(amountField.getText()), + currencyChoiceBox.getValue() + ); + } + + /** + * Initializes the duplicate transaction validation, which operates on the + * basic transaction properties: timestamp, amount, and currency. We listen + * for changes to these, and if they're all at least valid, we search for + * existing transactions with the same values. + */ + private void initializeDuplicateTransactionUi() { + Property txInfoProperty = new SimpleObjectProperty<>(getBasicTransactionInfo()); + basicTransactionInfoValid.addListener((observable, oldValue, newValue) -> { + if (newValue) { + txInfoProperty.setValue(new BasicTransactionInfo( + DateUtil.localToUTC(parseTimestamp()), + new BigDecimal(amountField.getText()), + currencyChoiceBox.getValue() + )); + } else { + txInfoProperty.setValue(null); + } + }); + AsyncValidationFunction validationFunction = info -> { + if (info == null) return CompletableFuture.completedFuture(ValidationResult.valid()); + return Profile.getCurrent().dataSource().mapRepoAsync( + TransactionRepository.class, + repo -> repo.findDuplicates(info.timestamp(), info.amount(), info.currency()) + ) + .thenApply(matches -> matches.stream().map(m -> "Found possible duplicate transaction: #" + m.id).toList()) + .thenApply(ValidationResult::new); + }; + new ValidationApplier<>(validationFunction) + .attach(descriptionField.getParent(), txInfoProperty); + } + private void initializeTagSelectionUi() { addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank())); addTagButton.setOnAction(event -> { @@ -403,12 +450,10 @@ public class EditTransactionController implements RouteSelectionListener { } }); BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false); - lineItemsTotalValue.addListener((observable, oldValue, newValue) -> { - lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0); - }); - amountFieldValue.addListener((observable, oldValue, newValue) -> { - lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0); - }); + lineItemsTotalValue.addListener((observable, oldValue, newValue) -> + lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0)); + amountFieldValue.addListener((observable, oldValue, newValue) -> + lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0)); BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not())); // Logic for button that syncs line items total to the amount field. diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index 068c200..d2018b6 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -66,6 +66,9 @@ public class TransactionsViewController implements RouteSelectionListener { paginationControls.setPage(1); selectedTransaction.set(null); }); + // Add a listener to the search field that sets the page to 1 (thus + // doing a new search with the contents of the field), and deselects any + // selected transaction. searchField.textProperty().addListener((observable, oldValue, newValue) -> { paginationControls.setPage(1); selectedTransaction.set(null); @@ -185,6 +188,14 @@ public class TransactionsViewController implements RouteSelectionListener { private List getCurrentSearchFilters() { List filters = new ArrayList<>(); if (searchField.getText() != null && !searchField.getText().isBlank()) { + // Special case: for input like "#123", search directly for the transaction id. + if (searchField.getText().strip().matches("#\\d+")) { + int idQuery = Integer.parseInt(searchField.getText().strip().substring(1)); + var filter = new SearchFilter.Builder().where("id = ?").withArg(idQuery).build(); + return List.of(filter); + } + + // General case: split the input into a list of terms, then apply each term in a LIKE %term% query. var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+")) .map(t -> '%'+t+'%') .toList(); diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index 5686a59..812eb32 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -31,6 +31,7 @@ public interface TransactionRepository extends Repository, AutoCloseable { Optional findById(long id); Page findAll(PageRequest pagination); List findRecentN(int n); + List findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency); long countAll(); long countAllAfter(long transactionId); long countAllByAccounts(Set accountIds); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java index cbc3bdf..6650702 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -153,6 +153,16 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem ); } + @Override + public List findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency) { + return DbUtil.findAll( + conn, + "SELECT * FROM transaction WHERE timestamp = ? AND amount = ? AND currency = ? ORDER BY timestamp DESC", + List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode()), + JdbcTransactionRepository::parseTransaction + ); + } + @Override public long countAll() { return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L); diff --git a/src/main/java/com/andrewlalis/perfin/model/CreditAndDebitAccounts.java b/src/main/java/com/andrewlalis/perfin/model/CreditAndDebitAccounts.java index a98621b..9fd8530 100644 --- a/src/main/java/com/andrewlalis/perfin/model/CreditAndDebitAccounts.java +++ b/src/main/java/com/andrewlalis/perfin/model/CreditAndDebitAccounts.java @@ -2,6 +2,14 @@ package com.andrewlalis.perfin.model; import java.util.function.Consumer; +/** + * A simple pair of accounts representing the two possible linked accounts for a + * {@link Transaction}. + * @param creditAccount The account linked as the account to which the + * transaction amount is credited. + * @param debitAccount The account linked as the account from which the + * transaction amount is debited. + */ public record CreditAndDebitAccounts(Account creditAccount, Account debitAccount) { public boolean hasCredit() { return creditAccount != null; diff --git a/src/main/resources/edit-transaction.fxml b/src/main/resources/edit-transaction.fxml index 39bc5e9..9f2bfeb 100644 --- a/src/main/resources/edit-transaction.fxml +++ b/src/main/resources/edit-transaction.fxml @@ -1,12 +1,8 @@ - - - + - -