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, PerfinApp::initAppDir,
c -> initMainScreen(stage, c), c -> initMainScreen(stage, c),
PerfinApp::loadLastUsedProfile PerfinApp::loadLastUsedProfile
)); ), false);
splashScreen.showAndWait(); splashScreen.showAndWait();
if (splashScreen.isStartupSuccessful()) { if (splashScreen.isStartupSuccessful()) {
stage.show(); stage.show();

View File

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

View File

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

View File

@ -30,6 +30,8 @@ public interface DataSource {
AccountRepository getAccountRepository(); AccountRepository getAccountRepository();
BalanceRecordRepository getBalanceRecordRepository(); BalanceRecordRepository getBalanceRecordRepository();
TransactionRepository getTransactionRepository(); TransactionRepository getTransactionRepository();
TransactionVendorRepository getTransactionVendorRepository();
TransactionCategoryRepository getTransactionCategoryRepository();
AttachmentRepository getAttachmentRepository(); AttachmentRepository getAttachmentRepository();
AccountHistoryItemRepository getAccountHistoryItemRepository(); AccountHistoryItemRepository getAccountHistoryItemRepository();
@ -81,6 +83,8 @@ public interface DataSource {
AccountRepository.class, this::getAccountRepository, AccountRepository.class, this::getAccountRepository,
BalanceRecordRepository.class, this::getBalanceRecordRepository, BalanceRecordRepository.class, this::getBalanceRecordRepository,
TransactionRepository.class, this::getTransactionRepository, TransactionRepository.class, this::getTransactionRepository,
TransactionVendorRepository.class, this::getTransactionVendorRepository,
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
AttachmentRepository.class, this::getAttachmentRepository, AttachmentRepository.class, this::getAttachmentRepository,
AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository 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, Currency currency,
String description, String description,
CreditAndDebitAccounts linkedAccounts, CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Path> attachments List<Path> attachments
); );
Optional<Transaction> findById(long id); Optional<Transaction> findById(long id);
@ -31,6 +34,8 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination); Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
CreditAndDebitAccounts findLinkedAccounts(long transactionId); CreditAndDebitAccounts findLinkedAccounts(long transactionId);
List<Attachment> findAttachments(long transactionId); List<Attachment> findAttachments(long transactionId);
List<String> findTags(long transactionId);
List<String> findAllTags();
void delete(long transactionId); void delete(long transactionId);
void update( void update(
long id, long id,
@ -39,6 +44,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Currency currency, Currency currency,
String description, String description,
CreditAndDebitAccounts linkedAccounts, CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Attachment> existingAttachments, List<Attachment> existingAttachments,
List<Path> newAttachmentPaths 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); return new JdbcTransactionRepository(getConnection(), contentDir);
} }
@Override
public TransactionVendorRepository getTransactionVendorRepository() {
return new JdbcTransactionVendorRepository(getConnection());
}
@Override
public TransactionCategoryRepository getTransactionCategoryRepository() {
return new JdbcTransactionCategoryRepository(getConnection());
}
@Override @Override
public AttachmentRepository getAttachmentRepository() { public AttachmentRepository getAttachmentRepository() {
return new JdbcAttachmentRepository(getConnection(), contentDir); 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.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.model.*;
import javafx.scene.paint.Color;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.nio.file.Path; import java.nio.file.Path;
import java.sql.Connection; import java.sql.*;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -28,29 +28,104 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
Currency currency, Currency currency,
String description, String description,
CreditAndDebitAccounts linkedAccounts, CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Path> attachments List<Path> attachments
) { ) {
return DbUtil.doTransaction(conn, () -> { return DbUtil.doTransaction(conn, () -> {
// 1. Insert the transaction. Long vendorId = null;
long txId = DbUtil.insertOne( if (vendor != null && !vendor.isBlank()) {
conn, vendorId = getOrCreateVendorId(vendor.strip());
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)", }
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description) Long categoryId = null;
); if (category != null && !category.isBlank()) {
// 2. Insert linked account entries. 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); AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency)); 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)); 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); AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
for (Path attachmentPath : attachments) { for (Path attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath); Attachment attachment = attachmentRepo.insert(attachmentPath);
insertAttachmentLink(txId, attachment.id); 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; 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 @Override
public Optional<Transaction> findById(long id) { public Optional<Transaction> findById(long id) {
return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction); 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 @Override
public void delete(long transactionId) { public void delete(long transactionId) {
DbUtil.doTransaction(conn, () -> { DbUtil.doTransaction(conn, () -> {
@ -164,44 +263,93 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
Currency currency, Currency currency,
String description, String description,
CreditAndDebitAccounts linkedAccounts, CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<Attachment> existingAttachments, List<Attachment> existingAttachments,
List<Path> newAttachmentPaths List<Path> newAttachmentPaths
) { ) {
DbUtil.doTransaction(conn, () -> { DbUtil.doTransaction(conn, () -> {
Transaction tx = findById(id).orElseThrow();
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
List<Attachment> currentAttachments = findAttachments(id);
var entryRepo = new JdbcAccountEntryRepository(conn); var entryRepo = new JdbcAccountEntryRepository(conn);
var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); 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<>(); List<String> updateMessages = new ArrayList<>();
if (!tx.getTimestamp().equals(utcTimestamp)) { 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) + "."); updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
} }
BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP); BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
if (!tx.getAmount().equals(scaledAmount)) { 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)) + "."); updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
} }
if (!tx.getCurrency().equals(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() + "."); updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
} }
if (!Objects.equals(tx.getDescription(), description)) { 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."); updateMessages.add("Updated description.");
} }
boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) || boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
!tx.getCurrency().equals(currency) || !tx.getCurrency().equals(currency) ||
!tx.getTimestamp().equals(utcTimestamp) || !tx.getTimestamp().equals(utcTimestamp) ||
!currentLinkedAccounts.equals(linkedAccounts); !currentLinkedAccounts.equals(linkedAccounts);
if (updateAccountEntries) { if (shouldUpdateAccountEntries) {
// Delete all entries and re-write them correctly? // Delete all entries and re-write them correctly.
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id)); 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.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)); linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
updateMessages.add("Updated linked accounts."); 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. // Manage attachments changes.
List<Attachment> removedAttachments = new ArrayList<>(currentAttachments); List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
removedAttachments.removeAll(existingAttachments); removedAttachments.removeAll(existingAttachments);
@ -214,6 +362,8 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
insertAttachmentLink(tx.id, attachment.id); insertAttachmentLink(tx.id, attachment.id);
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\"."); 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); String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
var historyRepo = new JdbcAccountHistoryItemRepository(conn); var historyRepo = new JdbcAccountHistoryItemRepository(conn);
linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
@ -226,16 +376,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
conn.close(); 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) { private void insertAttachmentLink(long transactionId, long attachmentId) {
DbUtil.insertOne( DbUtil.insertOne(
conn, conn,
@ -243,4 +383,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
List.of(transactionId, attachmentId) 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); 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) { public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
try (var stmt = conn.prepareStatement(query)) { try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args); 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) { public static void updateOne(Connection conn, String query, List<Object> args) {
try (var stmt = conn.prepareStatement(query)) { try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args); 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) { public static long insertOne(Connection conn, String query, List<Object> args) {
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
setArgs(stmt, args); setArgs(stmt, args);
int result = stmt.executeUpdate(); int result = stmt.executeUpdate();
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row."); if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
var rs = stmt.getGeneratedKeys(); var rs = stmt.getGeneratedKeys();
rs.next(); if (!rs.next()) throw new UncheckedSqlException("Insert query did not generate any keys.");
return rs.getLong(1); return rs.getLong(1);
} catch (SQLException e) { } catch (SQLException e) {
throw new UncheckedSqlException(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) { public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
return Timestamp.from(utc.toInstant(ZoneOffset.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 BigDecimal amount;
private final Currency currency; private final Currency currency;
private final String description; 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); super(id);
this.timestamp = timestamp; this.timestamp = timestamp;
this.amount = amount; this.amount = amount;
this.currency = currency; this.currency = currency;
this.description = description; this.description = description;
this.vendorId = vendorId;
this.categoryId = categoryId;
} }
public LocalDateTime getTimestamp() { public LocalDateTime getTimestamp() {
@ -42,6 +46,14 @@ public class Transaction extends IdEntity {
return description; return description;
} }
public Long getVendorId() {
return vendorId;
}
public Long getCategoryId() {
return categoryId;
}
public MoneyValue getMoneyAmount() { public MoneyValue getMoneyAmount() {
return new MoneyValue(amount, currency); 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> { public class StartupSplashScreen extends Stage implements Consumer<String> {
private final List<ThrowableConsumer<Consumer<String>>> tasks; private final List<ThrowableConsumer<Consumer<String>>> tasks;
private final boolean delayTasks;
private boolean startupSuccessful = false; private boolean startupSuccessful = false;
private final TextArea textArea = new TextArea(); 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.tasks = tasks;
this.delayTasks = delayTasks;
setTitle("Starting Perfin..."); setTitle("Starting Perfin...");
setResizable(false); setResizable(false);
initStyle(StageStyle.UNDECORATED); initStyle(StageStyle.UNDECORATED);
@ -67,11 +69,7 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
*/ */
private void runTasks() { private void runTasks() {
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
try { if (delayTasks) sleepOrThrowRE(1000);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
for (var task : tasks) { for (var task : tasks) {
try { try {
CompletableFuture<Void> future = new CompletableFuture<>(); CompletableFuture<Void> future = new CompletableFuture<>();
@ -84,27 +82,31 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
} }
}); });
future.join(); future.join();
Thread.sleep(500); if (delayTasks) sleepOrThrowRE(500);
} catch (Exception e) { } catch (Exception e) {
accept("Startup failed: " + e.getMessage()); accept("Startup failed: " + e.getMessage());
e.printStackTrace(System.err); e.printStackTrace(System.err);
try { sleepOrThrowRE(5000);
Thread.sleep(5000);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
Platform.runLater(this::close); Platform.runLater(this::close);
return; return;
} }
} }
accept("Startup successful!"); accept("Startup successful!");
try { if (delayTasks) sleepOrThrowRE(1000);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
startupSuccessful = true; startupSuccessful = true;
Platform.runLater(this::close); 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 --> <!-- Container for linked accounts -->
<HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer"> <HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
<VBox> <VBox HBox.hgrow="ALWAYS">
<Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/> <Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
<AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/> <AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
</VBox> </VBox>
<VBox> <VBox HBox.hgrow="ALWAYS">
<Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/> <Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
<AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/> <AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
</VBox> </VBox>
</HBox> </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 --> <!-- Container for attachments -->
<VBox styleClass="std-padding"> <VBox styleClass="std-padding">
<Label text="Attachments" styleClass="bold-text"/> <Label text="Attachments" styleClass="bold-text"/>