Compare commits
2 Commits
8f5ff09891
...
e4783e5a47
Author | SHA1 | Date |
---|---|---|
Andrew Lalis | e4783e5a47 | |
Andrew Lalis | a13c9c22df |
|
@ -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.
|
||||||
|
|
||||||
|
|
2
pom.xml
2
pom.xml
|
@ -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>
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue