Added ability to edit tags, vendor, and category of a transaction.

This commit is contained in:
Andrew Lalis 2024-01-29 14:01:49 -05:00
parent e17e2c55a5
commit b9678313bf
20 changed files with 813 additions and 84 deletions

View File

@ -58,7 +58,7 @@ public class PerfinApp extends Application {
PerfinApp::initAppDir,
c -> initMainScreen(stage, c),
PerfinApp::loadLastUsedProfile
));
), false);
splashScreen.showAndWait();
if (splashScreen.isStartupSuccessful()) {
stage.show();

View File

@ -1,12 +1,14 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
@ -15,10 +17,16 @@ import com.andrewlalis.perfin.view.component.validation.validators.PredicateVali
import javafx.application.Platform;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -27,10 +35,7 @@ import java.nio.file.Path;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.Comparator;
import java.util.Currency;
import java.util.List;
import java.util.*;
import static com.andrewlalis.perfin.PerfinApp.router;
@ -49,6 +54,13 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public AccountSelectionBox debitAccountSelector;
@FXML public AccountSelectionBox creditAccountSelector;
@FXML public ComboBox<String> vendorComboBox;
@FXML public ComboBox<String> categoryComboBox;
@FXML public ComboBox<String> tagsComboBox;
@FXML public Button addTagButton;
@FXML public VBox tagsVBox;
private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
@FXML public FileSelectionArea attachmentsSelectionArea;
@FXML public Button saveButton;
@ -75,27 +87,40 @@ public class EditTransactionController implements RouteSelectionListener {
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator<CreditAndDebitAccounts>()
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
.addPredicate(
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
"The credit and debit accounts cannot be the same."
)
.addPredicate(
accounts -> (
(!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
(!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
),
"Linked accounts must use the same currency."
)
.addPredicate(
accounts -> (
(!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
(!accounts.hasDebit() || !accounts.debitAccount().isArchived())
),
"Linked accounts must not be archived."
)
).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty);
var linkedAccountsValid = new ValidationApplier<>(getLinkedAccountsValidator())
.validatedInitially()
.attach(linkedAccountsContainer, linkedAccountsProperty);
// Set up the list of added tags.
addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
addTagButton.setOnAction(event -> {
if (tagsComboBox.getValue() == null) return;
String tag = tagsComboBox.getValue().strip();
if (!selectedTags.contains(tag)) {
selectedTags.add(tag);
selectedTags.sort(String::compareToIgnoreCase);
}
tagsComboBox.setValue(null);
});
tagsComboBox.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
addTagButton.fire();
}
});
BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, tag -> {
Label label = new Label(tag);
label.setMaxWidth(Double.POSITIVE_INFINITY);
label.getStyleClass().addAll("bold-text");
Button removeButton = new Button("Remove");
removeButton.setOnAction(event -> {
selectedTags.remove(tag);
});
BorderPane tile = new BorderPane(label);
tile.setRight(removeButton);
tile.getStyleClass().addAll("std-spacing");
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
return tile;
});
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not());
@ -107,6 +132,9 @@ public class EditTransactionController implements RouteSelectionListener {
Currency currency = currencyChoiceBox.getValue();
String description = getSanitizedDescription();
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
String vendor = vendorComboBox.getValue();
String category = categoryComboBox.getValue();
Set<String> tags = new HashSet<>(selectedTags);
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
final long idToNavigate;
@ -119,6 +147,9 @@ public class EditTransactionController implements RouteSelectionListener {
currency,
description,
linkedAccounts,
vendor,
category,
tags,
newAttachmentPaths
)
);
@ -132,6 +163,9 @@ public class EditTransactionController implements RouteSelectionListener {
currency,
description,
linkedAccounts,
vendor,
category,
tags,
existingAttachments,
newAttachmentPaths
)
@ -149,6 +183,11 @@ public class EditTransactionController implements RouteSelectionListener {
public void onRouteSelected(Object context) {
transaction = (Transaction) context;
// Clear some initial fields immediately:
tagsComboBox.setValue(null);
vendorComboBox.setValue(null);
categoryComboBox.setValue(null);
if (transaction == null) {
titleLabel.setText("Create New Transaction");
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
@ -163,10 +202,13 @@ public class EditTransactionController implements RouteSelectionListener {
// Fetch some account-specific data.
container.setDisable(true);
DataSource ds = Profile.getCurrent().dataSource();
Thread.ofVirtual().start(() -> {
try (
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
var transactionRepo = Profile.getCurrent().dataSource().getTransactionRepository()
var accountRepo = ds.getAccountRepository();
var transactionRepo = ds.getTransactionRepository();
var vendorRepo = ds.getTransactionVendorRepository();
var categoryRepo = ds.getTransactionCategoryRepository()
) {
// First fetch all the data.
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
@ -174,23 +216,50 @@ public class EditTransactionController implements RouteSelectionListener {
.toList();
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
final List<Attachment> attachments;
final List<String> availableTags = transactionRepo.findAllTags();
final List<String> tags;
final CreditAndDebitAccounts linkedAccounts;
final String vendorName;
final String categoryName;
if (transaction == null) {
attachments = Collections.emptyList();
tags = Collections.emptyList();
linkedAccounts = new CreditAndDebitAccounts(null, null);
vendorName = null;
categoryName = null;
} else {
attachments = transactionRepo.findAttachments(transaction.id);
tags = transactionRepo.findTags(transaction.id);
linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
if (transaction.getVendorId() != null) {
vendorName = vendorRepo.findById(transaction.getVendorId())
.map(TransactionVendor::getName).orElse(null);
} else {
vendorName = null;
}
if (transaction.getCategoryId() != null) {
categoryName = categoryRepo.findById(transaction.getCategoryId())
.map(TransactionCategory::getName).orElse(null);
} else {
categoryName = null;
}
}
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
final List<TransactionCategory> availableCategories = categoryRepo.findAll();
// Then make updates to the view.
Platform.runLater(() -> {
currencyChoiceBox.getItems().setAll(currencies);
creditAccountSelector.setAccounts(accounts);
debitAccountSelector.setAccounts(accounts);
currencyChoiceBox.getItems().setAll(currencies);
vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList());
vendorComboBox.setValue(vendorName);
categoryComboBox.getItems().setAll(availableCategories.stream().map(TransactionCategory::getName).toList());
categoryComboBox.setValue(categoryName);
tagsComboBox.getItems().setAll(availableTags);
attachmentsSelectionArea.clear();
attachmentsSelectionArea.addAttachments(attachments);
selectedTags.clear();
if (transaction == null) {
// TODO: Allow user to select a default currency.
currencyChoiceBox.getSelectionModel().selectFirst();
creditAccountSelector.select(null);
debitAccountSelector.select(null);
@ -198,12 +267,14 @@ public class EditTransactionController implements RouteSelectionListener {
currencyChoiceBox.getSelectionModel().select(transaction.getCurrency());
creditAccountSelector.select(linkedAccounts.creditAccount());
debitAccountSelector.select(linkedAccounts.debitAccount());
selectedTags.addAll(tags);
}
container.setDisable(false);
});
} catch (Exception e) {
log.error("Failed to get repositories.", e);
Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage());
Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()));
router.navigateBackAndClear();
}
});
}
@ -215,6 +286,29 @@ public class EditTransactionController implements RouteSelectionListener {
);
}
private PredicateValidator<CreditAndDebitAccounts> getLinkedAccountsValidator() {
return new PredicateValidator<CreditAndDebitAccounts>()
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
.addPredicate(
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
"The credit and debit accounts cannot be the same."
)
.addPredicate(
accounts -> (
(!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
(!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
),
"Linked accounts must use the same currency."
)
.addPredicate(
accounts -> (
(!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
(!accounts.hasDebit() || !accounts.debitAccount().isArchived())
),
"Linked accounts must not be archived."
);
}
private LocalDateTime parseTimestamp() {
List<DateTimeFormatter> formatters = List.of(
DateTimeFormatter.ISO_LOCAL_DATE_TIME,

View File

@ -106,6 +106,7 @@ public class ProfilesViewController {
log.info("Opening profile \"{}\".", name);
try {
Profile.setCurrent(PerfinApp.profileLoader.load(name));
ProfileLoader.saveLastProfile(name);
ProfilesStage.closeView();
router.replace("accounts");
if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");

View File

@ -30,6 +30,8 @@ public interface DataSource {
AccountRepository getAccountRepository();
BalanceRecordRepository getBalanceRecordRepository();
TransactionRepository getTransactionRepository();
TransactionVendorRepository getTransactionVendorRepository();
TransactionCategoryRepository getTransactionCategoryRepository();
AttachmentRepository getAttachmentRepository();
AccountHistoryItemRepository getAccountHistoryItemRepository();
@ -81,6 +83,8 @@ public interface DataSource {
AccountRepository.class, this::getAccountRepository,
BalanceRecordRepository.class, this::getBalanceRecordRepository,
TransactionRepository.class, this::getTransactionRepository,
TransactionVendorRepository.class, this::getTransactionVendorRepository,
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
AttachmentRepository.class, this::getAttachmentRepository,
AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository
);

View File

@ -0,0 +1,17 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.TransactionCategory;
import javafx.scene.paint.Color;
import java.util.List;
import java.util.Optional;
public interface TransactionCategoryRepository extends Repository, AutoCloseable {
Optional<TransactionCategory> findById(long id);
Optional<TransactionCategory> findByName(String name);
List<TransactionCategory> findAllBaseCategories();
List<TransactionCategory> findAll();
long insert(long parentId, String name, Color color);
long insert(String name, Color color);
void deleteById(long id);
}

View File

@ -21,6 +21,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Path> attachments
);
Optional<Transaction> findById(long id);
@ -31,6 +34,8 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
List<Attachment> findAttachments(long transactionId);
List<String> findTags(long transactionId);
List<String> findAllTags();
void delete(long transactionId);
void update(
long id,
@ -39,6 +44,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Attachment> existingAttachments,
List<Path> newAttachmentPaths
);

View File

@ -0,0 +1,15 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.TransactionVendor;
import java.util.List;
import java.util.Optional;
public interface TransactionVendorRepository extends Repository, AutoCloseable {
Optional<TransactionVendor> findById(long id);
Optional<TransactionVendor> findByName(String name);
List<TransactionVendor> findAll();
long insert(String name, String description);
long insert(String name);
void deleteById(long id);
}

View File

@ -49,6 +49,16 @@ public class JdbcDataSource implements DataSource {
return new JdbcTransactionRepository(getConnection(), contentDir);
}
@Override
public TransactionVendorRepository getTransactionVendorRepository() {
return new JdbcTransactionVendorRepository(getConnection());
}
@Override
public TransactionCategoryRepository getTransactionCategoryRepository() {
return new JdbcTransactionCategoryRepository(getConnection());
}
@Override
public AttachmentRepository getAttachmentRepository() {
return new JdbcAttachmentRepository(getConnection(), contentDir);

View File

@ -0,0 +1,90 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.data.util.ColorUtil;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.TransactionCategory;
import javafx.scene.paint.Color;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository {
@Override
public Optional<TransactionCategory> findById(long id) {
return DbUtil.findById(
conn,
"SELECT * FROM transaction_category WHERE id = ?",
id,
JdbcTransactionCategoryRepository::parseCategory
);
}
@Override
public Optional<TransactionCategory> findByName(String name) {
return DbUtil.findOne(
conn,
"SELECT * FROM transaction_category WHERE name = ?",
List.of(name),
JdbcTransactionCategoryRepository::parseCategory
);
}
@Override
public List<TransactionCategory> findAllBaseCategories() {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
JdbcTransactionCategoryRepository::parseCategory
);
}
@Override
public List<TransactionCategory> findAll() {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction_category ORDER BY parent_id ASC, name ASC",
JdbcTransactionCategoryRepository::parseCategory
);
}
@Override
public long insert(long parentId, String name, Color color) {
return DbUtil.insertOne(
conn,
"INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
List.of(parentId, name, ColorUtil.toHex(color))
);
}
@Override
public long insert(String name, Color color) {
return DbUtil.insertOne(
conn,
"INSERT INTO transaction_category (name, color) VALUES (?, ?)",
List.of(name, ColorUtil.toHex(color))
);
}
@Override
public void deleteById(long id) {
DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
}
@Override
public void close() throws Exception {
conn.close();
}
public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
return new TransactionCategory(
rs.getLong("id"),
rs.getObject("parent_id", Long.class),
rs.getString("name"),
Color.valueOf("#" + rs.getString("color"))
);
}
}

View File

@ -8,14 +8,14 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
import com.andrewlalis.perfin.model.*;
import javafx.scene.paint.Color;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.*;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@ -28,29 +28,104 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Path> attachments
) {
return DbUtil.doTransaction(conn, () -> {
// 1. Insert the transaction.
long txId = DbUtil.insertOne(
conn,
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description)
);
// 2. Insert linked account entries.
Long vendorId = null;
if (vendor != null && !vendor.isBlank()) {
vendorId = getOrCreateVendorId(vendor.strip());
}
Long categoryId = null;
if (category != null && !category.isBlank()) {
categoryId = getOrCreateCategoryId(category.strip());
}
// Insert the transaction, using a custom JDBC statement to deal with nullables.
long txId;
try (var stmt = conn.prepareStatement(
"INSERT INTO transaction (timestamp, amount, currency, description, vendor_id, category_id) VALUES (?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
)) {
stmt.setTimestamp(1, DbUtil.timestampFromUtcLDT(utcTimestamp));
stmt.setBigDecimal(2, amount);
stmt.setString(3, currency.getCurrencyCode());
if (description != null && !description.isBlank()) {
stmt.setString(4, description.strip());
} else {
stmt.setNull(4, Types.VARCHAR);
}
if (vendorId != null) {
stmt.setLong(5, vendorId);
} else {
stmt.setNull(5, Types.BIGINT);
}
if (categoryId != null) {
stmt.setLong(6, categoryId);
} else {
stmt.setNull(6, Types.BIGINT);
}
int result = stmt.executeUpdate();
if (result != 1) throw new UncheckedSqlException("Transaction insert returned " + result);
var rs = stmt.getGeneratedKeys();
if (!rs.next()) throw new UncheckedSqlException("Transaction insert didn't generate any keys.");
txId = rs.getLong(1);
}
// Insert linked account entries.
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
// 3. Add attachments.
// Add attachments.
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
for (Path attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath);
insertAttachmentLink(txId, attachment.id);
}
// Add tags.
for (String tag : tags) {
try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)")) {
long tagId = getOrCreateTagId(tag.toLowerCase().strip());
stmt.setLong(1, txId);
stmt.setLong(2, tagId);
stmt.executeUpdate();
}
}
return txId;
});
}
private long getOrCreateVendorId(String name) {
var repo = new JdbcTransactionVendorRepository(conn);
TransactionVendor vendor = repo.findByName(name).orElse(null);
if (vendor != null) {
return vendor.id;
}
return repo.insert(name);
}
private long getOrCreateCategoryId(String name) {
var repo = new JdbcTransactionCategoryRepository(conn);
TransactionCategory category = repo.findByName(name).orElse(null);
if (category != null) {
return category.id;
}
return repo.insert(name, Color.WHITE);
}
private long getOrCreateTagId(String name) {
Optional<Long> optionalId = DbUtil.findOne(
conn,
"SELECT id FROM transaction_tag WHERE name = ?",
List.of(name),
rs -> rs.getLong(1)
);
return optionalId.orElseGet(() ->
DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name))
);
}
@Override
public Optional<Transaction> findById(long id) {
return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
@ -147,6 +222,30 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
);
}
@Override
public List<String> findTags(long transactionId) {
return DbUtil.findAll(
conn,
"""
SELECT tt.name
FROM transaction_tag tt
LEFT JOIN transaction_tag_join ttj ON ttj.tag_id = tt.id
WHERE ttj.transaction_id = ?
ORDER BY tt.name ASC""",
List.of(transactionId),
rs -> rs.getString(1)
);
}
@Override
public List<String> findAllTags() {
return DbUtil.findAll(
conn,
"SELECT name FROM transaction_tag ORDER BY name ASC",
rs -> rs.getString(1)
);
}
@Override
public void delete(long transactionId) {
DbUtil.doTransaction(conn, () -> {
@ -164,44 +263,93 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Attachment> existingAttachments,
List<Path> newAttachmentPaths
) {
DbUtil.doTransaction(conn, () -> {
Transaction tx = findById(id).orElseThrow();
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
List<Attachment> currentAttachments = findAttachments(id);
var entryRepo = new JdbcAccountEntryRepository(conn);
var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
var vendorRepo = new JdbcTransactionVendorRepository(conn);
var categoryRepo = new JdbcTransactionCategoryRepository(conn);
Transaction tx = findById(id).orElseThrow();
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
TransactionVendor currentVendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElseThrow();
String currentVendorName = currentVendor == null ? null : currentVendor.getName();
TransactionCategory currentCategory = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElseThrow();
String currentCategoryName = currentCategory == null ? null : currentCategory.getName();
Set<String> currentTags = new HashSet<>(findTags(id));
List<Attachment> currentAttachments = findAttachments(id);
List<String> updateMessages = new ArrayList<>();
if (!tx.getTimestamp().equals(utcTimestamp)) {
DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), id));
DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", DbUtil.timestampFromUtcLDT(utcTimestamp), id);
updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
}
BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
if (!tx.getAmount().equals(scaledAmount)) {
DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", List.of(scaledAmount, id));
DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", scaledAmount, id);
updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
}
if (!tx.getCurrency().equals(currency)) {
DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", List.of(currency.getCurrencyCode(), id));
DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", currency.getCurrencyCode(), id);
updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
}
if (!Objects.equals(tx.getDescription(), description)) {
DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", List.of(description, id));
DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", description, id);
updateMessages.add("Updated description.");
}
boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
!tx.getCurrency().equals(currency) ||
!tx.getTimestamp().equals(utcTimestamp) ||
!currentLinkedAccounts.equals(linkedAccounts);
if (updateAccountEntries) {
// Delete all entries and re-write them correctly?
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id));
if (shouldUpdateAccountEntries) {
// Delete all entries and re-write them correctly.
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", id);
linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
updateMessages.add("Updated linked accounts.");
}
// Manage vendor change.
if (!Objects.equals(vendor, currentVendorName)) {
if (vendor == null || vendor.isBlank()) {
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = NULL WHERE id = ?", id);
} else {
long newVendorId = getOrCreateVendorId(vendor);
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = ? WHERE id = ?", newVendorId, id);
}
updateMessages.add("Updated vendor name to \"" + vendor + "\".");
}
// Manage category change.
if (!Objects.equals(category, currentCategoryName)) {
if (category == null || category.isBlank()) {
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = NULL WHERE id = ?", id);
} else {
long newCategoryId = getOrCreateCategoryId(category);
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = ? WHERE id = ?", newCategoryId, id);
}
updateMessages.add("Updated category name to \"" + category + "\".");
}
// Manage tags changes.
if (!currentTags.equals(tags)) {
Set<String> tagsAdded = new HashSet<>(tags);
tagsAdded.removeAll(currentTags);
Set<String> tagsRemoved = new HashSet<>(currentTags);
tagsRemoved.removeAll(tags);
for (var t : tagsRemoved) removeTag(id, t);
for (var t : tagsAdded) addTag(id, t);
if (!tagsAdded.isEmpty()) {
updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded));
}
if (!tagsRemoved.isEmpty()) {
updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved));
}
}
// Manage attachments changes.
List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
removedAttachments.removeAll(existingAttachments);
@ -214,6 +362,8 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
insertAttachmentLink(tx.id, attachment.id);
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
}
// Add a text history item to any linked accounts detailing the changes.
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
var historyRepo = new JdbcAccountHistoryItemRepository(conn);
linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
@ -226,16 +376,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
conn.close();
}
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
return new Transaction(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getBigDecimal("amount"),
Currency.getInstance(rs.getString("currency")),
rs.getString("description")
);
}
private void insertAttachmentLink(long transactionId, long attachmentId) {
DbUtil.insertOne(
conn,
@ -243,4 +383,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
List.of(transactionId, attachmentId)
);
}
private long getTagId(String name) {
return DbUtil.findOne(
conn,
"SELECT id FROM transaction_tag WHERE name = ?",
List.of(name),
rs -> rs.getLong(1)
).orElse(-1L);
}
private void removeTag(long transactionId, String tag) {
long id = getTagId(tag);
if (id != -1) {
DbUtil.update(conn, "DELETE FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", transactionId, id);
}
}
private void addTag(long transactionId, String tag) {
long id = getOrCreateTagId(tag);
boolean exists = DbUtil.count(
conn,
"SELECT COUNT(tag_id) FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?",
transactionId,
id
) > 0;
if (!exists) {
DbUtil.insertOne(
conn,
"INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)",
transactionId,
id
);
}
}
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
return new Transaction(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getBigDecimal("amount"),
Currency.getInstance(rs.getString("currency")),
rs.getString("description"),
rs.getObject("vendor_id", Long.class),
rs.getObject("category_id", Long.class)
);
}
}

View File

@ -0,0 +1,78 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.TransactionVendorRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.TransactionVendor;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository {
@Override
public Optional<TransactionVendor> findById(long id) {
return DbUtil.findById(
conn,
"SELECT * FROM transaction_vendor WHERE id = ?",
id,
JdbcTransactionVendorRepository::parseVendor
);
}
@Override
public Optional<TransactionVendor> findByName(String name) {
return DbUtil.findOne(
conn,
"SELECT * FROM transaction_vendor WHERE name = ?",
List.of(name),
JdbcTransactionVendorRepository::parseVendor
);
}
@Override
public List<TransactionVendor> findAll() {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction_vendor ORDER BY name ASC",
JdbcTransactionVendorRepository::parseVendor
);
}
@Override
public long insert(String name, String description) {
return DbUtil.insertOne(
conn,
"INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
List.of(name, description)
);
}
@Override
public long insert(String name) {
return DbUtil.insertOne(
conn,
"INSERT INTO transaction_vendor (name) VALUES (?)",
List.of(name)
);
}
@Override
public void deleteById(long id) {
DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id));
}
@Override
public void close() throws Exception {
conn.close();
}
public static TransactionVendor parseVendor(ResultSet rs) throws SQLException {
return new TransactionVendor(
rs.getLong("id"),
rs.getString("name"),
rs.getString("description")
);
}
}

View File

@ -0,0 +1,14 @@
package com.andrewlalis.perfin.data.util;
import javafx.scene.paint.Color;
public class ColorUtil {
public static String toHex(Color color) {
return formatColorDouble(color.getRed()) + formatColorDouble(color.getGreen()) + formatColorDouble(color.getBlue());
}
private static String formatColorDouble(double val) {
String in = Integer.toHexString((int) Math.round(val * 255));
return in.length() == 1 ? "0" + in : in;
}
}

View File

@ -58,6 +58,17 @@ public final class DbUtil {
return findAll(conn, query, pagination, Collections.emptyList(), mapper);
}
public static long count(Connection conn, String query, Object... args) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);
var rs = stmt.executeQuery();
if (!rs.next()) throw new UncheckedSqlException("No count result available.");
return rs.getLong(1);
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);
@ -82,6 +93,10 @@ public final class DbUtil {
}
}
public static int update(Connection conn, String query, Object... args) {
return update(conn, query, List.of(args));
}
public static void updateOne(Connection conn, String query, List<Object> args) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);
@ -92,19 +107,27 @@ public final class DbUtil {
}
}
public static void updateOne(Connection conn, String query, Object... args) {
updateOne(conn, query, List.of(args));
}
public static long insertOne(Connection conn, String query, List<Object> args) {
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
setArgs(stmt, args);
int result = stmt.executeUpdate();
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
var rs = stmt.getGeneratedKeys();
rs.next();
if (!rs.next()) throw new UncheckedSqlException("Insert query did not generate any keys.");
return rs.getLong(1);
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
public static long insertOne(Connection conn, String query, Object... args) {
return insertOne(conn, query, List.of(args));
}
public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
}

View File

@ -17,13 +17,17 @@ public class Transaction extends IdEntity {
private final BigDecimal amount;
private final Currency currency;
private final String description;
private final Long vendorId;
private final Long categoryId;
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) {
super(id);
this.timestamp = timestamp;
this.amount = amount;
this.currency = currency;
this.description = description;
this.vendorId = vendorId;
this.categoryId = categoryId;
}
public LocalDateTime getTimestamp() {
@ -42,6 +46,14 @@ public class Transaction extends IdEntity {
return description;
}
public Long getVendorId() {
return vendorId;
}
public Long getCategoryId() {
return categoryId;
}
public MoneyValue getMoneyAmount() {
return new MoneyValue(amount, currency);
}

View File

@ -0,0 +1,35 @@
package com.andrewlalis.perfin.model;
import javafx.scene.paint.Color;
public class TransactionCategory extends IdEntity {
public static final int NAME_MAX_LENGTH = 63;
private final Long parentId;
private final String name;
private final Color color;
public TransactionCategory(long id, Long parentId, String name, Color color) {
super(id);
this.parentId = parentId;
this.name = name;
this.color = color;
}
public Long getParentId() {
return parentId;
}
public String getName() {
return name;
}
public Color getColor() {
return color;
}
@Override
public String toString() {
return name;
}
}

View File

@ -0,0 +1,65 @@
package com.andrewlalis.perfin.model;
import java.math.BigDecimal;
/**
* A line item that comprises part of a transaction. Its total value (value per
* item * quantity) is part of the transaction's total value. It can be used to
* record some transactions, like purchases and invoices, in more granular
* detail.
*/
public class TransactionLineItem extends IdEntity {
public static final int DESCRIPTION_MAX_LENGTH = 255;
private final long transactionId;
private final BigDecimal valuePerItem;
private final int quantity;
private final int idx;
private final String description;
public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description) {
super(id);
this.transactionId = transactionId;
this.valuePerItem = valuePerItem;
this.quantity = quantity;
this.idx = idx;
this.description = description;
}
public long getTransactionId() {
return transactionId;
}
public BigDecimal getValuePerItem() {
return valuePerItem;
}
public int getQuantity() {
return quantity;
}
public int getIdx() {
return idx;
}
public String getDescription() {
return description;
}
public BigDecimal getTotalValue() {
return valuePerItem.multiply(new BigDecimal(quantity));
}
@Override
public String toString() {
return String.format(
"TransactionLineItem(id=%d, transactionId=%d, valuePerItem=%s, quantity=%d, idx=%d, description=\"%s\")",
id,
transactionId,
valuePerItem.toPlainString(),
quantity,
idx,
description
);
}
}

View File

@ -0,0 +1,19 @@
package com.andrewlalis.perfin.model;
/**
* A tag that can be applied to a transaction to add some user-defined semantic
* meaning to it.
*/
public class TransactionTag extends IdEntity {
public static final int NAME_MAX_LENGTH = 63;
private final String name;
public TransactionTag(long id, String name) {
super(id);
this.name = name;
}
public String getName() {
return name;
}
}

View File

@ -0,0 +1,32 @@
package com.andrewlalis.perfin.model;
/**
* A vendor is a business establishment that can be linked to a transaction, to
* denote the business that the transaction took place with.
*/
public class TransactionVendor extends IdEntity {
public static final int NAME_MAX_LENGTH = 255;
public static final int DESCRIPTION_MAX_LENGTH = 255;
private final String name;
private final String description;
public TransactionVendor(long id, String name, String description) {
super(id);
this.name = name;
this.description = description;
}
public String getName() {
return name;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return name;
}
}

View File

@ -18,12 +18,14 @@ import java.util.function.Consumer;
*/
public class StartupSplashScreen extends Stage implements Consumer<String> {
private final List<ThrowableConsumer<Consumer<String>>> tasks;
private final boolean delayTasks;
private boolean startupSuccessful = false;
private final TextArea textArea = new TextArea();
public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks) {
public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks, boolean delayTasks) {
this.tasks = tasks;
this.delayTasks = delayTasks;
setTitle("Starting Perfin...");
setResizable(false);
initStyle(StageStyle.UNDECORATED);
@ -67,11 +69,7 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
*/
private void runTasks() {
Thread.ofVirtual().start(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (delayTasks) sleepOrThrowRE(1000);
for (var task : tasks) {
try {
CompletableFuture<Void> future = new CompletableFuture<>();
@ -84,27 +82,31 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
}
});
future.join();
Thread.sleep(500);
if (delayTasks) sleepOrThrowRE(500);
} catch (Exception e) {
accept("Startup failed: " + e.getMessage());
e.printStackTrace(System.err);
try {
Thread.sleep(5000);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
sleepOrThrowRE(5000);
Platform.runLater(this::close);
return;
}
}
accept("Startup successful!");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (delayTasks) sleepOrThrowRE(1000);
startupSuccessful = true;
Platform.runLater(this::close);
});
}
/**
* Helper method to sleep the current thread or throw a runtime exception.
* @param ms The number of milliseconds to sleep for.
*/
private static void sleepOrThrowRE(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -43,15 +43,39 @@
<!-- Container for linked accounts -->
<HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
<VBox>
<VBox HBox.hgrow="ALWAYS">
<Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
<AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
</VBox>
<VBox>
<VBox HBox.hgrow="ALWAYS">
<Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
<AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
</VBox>
</HBox>
<!-- Additional, mostly optional properties -->
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
</columnConstraints>
<Label text="Vendor" labelFor="${vendorComboBox}" styleClass="bold-text"/>
<ComboBox fx:id="vendorComboBox" editable="true" maxWidth="Infinity"/>
<Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
<ComboBox fx:id="categoryComboBox" editable="true" maxWidth="Infinity"/>
<Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
<VBox maxWidth="Infinity">
<HBox styleClass="std-spacing">
<ComboBox fx:id="tagsComboBox" editable="true" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
<Button fx:id="addTagButton" text="Add" HBox.hgrow="NEVER"/>
</HBox>
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
</VBox>
</PropertiesPane>
<!-- Container for attachments -->
<VBox styleClass="std-padding">
<Label text="Attachments" styleClass="bold-text"/>