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:
parent
a13c9c22df
commit
e4783e5a47
2
pom.xml
2
pom.xml
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue