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