Compare commits

...

2 Commits

8 changed files with 84 additions and 16 deletions

View File

@ -1,8 +1,5 @@
# Perfin # Perfin
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/andrewlalis/perfin/run-tests.yaml?style=flat-square&logo=github)
![GitHub release (with filter)](https://img.shields.io/github/v/release/andrewlalis/perfin?style=flat-square)
A personal accounting desktop app to track your finances using an approachable A personal accounting desktop app to track your finances using an approachable
interface and interoperable file formats for maximum compatibility. interface and interoperable file formats for maximum compatibility.

View File

@ -6,7 +6,7 @@
<groupId>com.andrewlalis</groupId> <groupId>com.andrewlalis</groupId>
<artifactId>perfin</artifactId> <artifactId>perfin</artifactId>
<version>1.11.0</version> <version>1.12.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <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.CategorySelectionBox;
import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.TransactionLineItemTile; 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.ValidationApplier;
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 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.Bindings;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.*; import javafx.beans.property.*;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
@ -41,6 +41,7 @@ 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.*; import java.util.*;
import java.util.concurrent.CompletableFuture;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
@ -57,6 +58,7 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public TextField timestampField; @FXML public TextField timestampField;
@FXML public TextField amountField; @FXML public TextField amountField;
@FXML public ChoiceBox<Currency> currencyChoiceBox; @FXML public ChoiceBox<Currency> currencyChoiceBox;
private final BooleanProperty basicTransactionInfoValid = new SimpleBooleanProperty(false);
@FXML public TextArea descriptionField; @FXML public TextArea descriptionField;
@FXML public HBox linkedAccountsContainer; @FXML public HBox linkedAccountsContainer;
@ -132,11 +134,13 @@ public class EditTransactionController implements RouteSelectionListener {
var linkedAccountsValid = initializeLinkedAccountsValidationUi(); var linkedAccountsValid = initializeLinkedAccountsValidationUi();
initializeTagSelectionUi(); initializeTagSelectionUi();
initializeLineItemsUi(); initializeLineItemsUi();
initializeDuplicateTransactionUi();
vendorsHyperlink.setOnAction(event -> router.navigate("vendors")); vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
categoriesHyperlink.setOnAction(event -> router.navigate("categories")); categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
tagsHyperlink.setOnAction(event -> router.navigate("tags")); tagsHyperlink.setOnAction(event -> router.navigate("tags"));
basicTransactionInfoValid.bind(timestampValid.and(amountValid).and(currencyChoiceBox.valueProperty().isNotNull()));
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());
} }
@ -313,6 +317,49 @@ public class EditTransactionController implements RouteSelectionListener {
.attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty()); .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() { private void initializeTagSelectionUi() {
addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank())); addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
addTagButton.setOnAction(event -> { addTagButton.setOnAction(event -> {
@ -403,12 +450,10 @@ public class EditTransactionController implements RouteSelectionListener {
} }
}); });
BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false); BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false);
lineItemsTotalValue.addListener((observable, oldValue, newValue) -> { lineItemsTotalValue.addListener((observable, oldValue, newValue) ->
lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0); lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0));
}); amountFieldValue.addListener((observable, oldValue, newValue) ->
amountFieldValue.addListener((observable, oldValue, newValue) -> { lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0));
lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0);
});
BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not())); BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not()));
// Logic for button that syncs line items total to the amount field. // 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); paginationControls.setPage(1);
selectedTransaction.set(null); 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) -> { searchField.textProperty().addListener((observable, oldValue, newValue) -> {
paginationControls.setPage(1); paginationControls.setPage(1);
selectedTransaction.set(null); selectedTransaction.set(null);
@ -185,6 +188,14 @@ public class TransactionsViewController implements RouteSelectionListener {
private List<SearchFilter> getCurrentSearchFilters() { private List<SearchFilter> getCurrentSearchFilters() {
List<SearchFilter> filters = new ArrayList<>(); List<SearchFilter> filters = new ArrayList<>();
if (searchField.getText() != null && !searchField.getText().isBlank()) { 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+")) var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+"))
.map(t -> '%'+t+'%') .map(t -> '%'+t+'%')
.toList(); .toList();

View File

@ -31,6 +31,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Optional<Transaction> findById(long id); Optional<Transaction> findById(long id);
Page<Transaction> findAll(PageRequest pagination); Page<Transaction> findAll(PageRequest pagination);
List<Transaction> findRecentN(int n); List<Transaction> findRecentN(int n);
List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency);
long countAll(); long countAll();
long countAllAfter(long transactionId); long countAllAfter(long transactionId);
long countAllByAccounts(Set<Long> accountIds); 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 @Override
public long countAll() { public long countAll() {
return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L); 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; 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 record CreditAndDebitAccounts(Account creditAccount, Account debitAccount) {
public boolean hasCredit() { public boolean hasCredit() {
return creditAccount != null; return creditAccount != null;

View File

@ -1,12 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<?import com.andrewlalis.perfin.view.component.AccountSelectionBox?> <?import com.andrewlalis.perfin.view.component.*?>
<?import com.andrewlalis.perfin.view.component.FileSelectionArea?>
<?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"