Added check for duplicate transactions, id-exact-match search for transactions, and some cleanup. Updated to version 1.12.0.

This commit is contained in:
Andrew Lalis 2024-05-30 15:51:00 -04:00
parent a13c9c22df
commit e4783e5a47
7 changed files with 84 additions and 13 deletions

View File

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

View File

@ -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<Currency> 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<BasicTransactionInfo> 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<BasicTransactionInfo> 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.

View File

@ -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<SearchFilter> getCurrentSearchFilters() {
List<SearchFilter> 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();

View File

@ -31,6 +31,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Optional<Transaction> findById(long id);
Page<Transaction> findAll(PageRequest pagination);
List<Transaction> findRecentN(int n);
List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency);
long countAll();
long countAllAfter(long transactionId);
long countAllByAccounts(Set<Long> accountIds);

View File

@ -153,6 +153,16 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
);
}
@Override
public List<Transaction> 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);

View File

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

View File

@ -1,12 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import com.andrewlalis.perfin.view.component.AccountSelectionBox?>
<?import com.andrewlalis.perfin.view.component.FileSelectionArea?>
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import com.andrewlalis.perfin.view.component.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import com.andrewlalis.perfin.view.component.CategorySelectionBox?>
<?import com.andrewlalis.perfin.view.component.StyledText?>
<BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.EditTransactionController"