Added the ability to add, edit, and remove transaction line items.

This commit is contained in:
Andrew Lalis 2024-02-09 12:21:06 -05:00
parent 5cc789419c
commit 41530d5276
20 changed files with 439 additions and 73 deletions

View File

@ -1,6 +1,7 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.module.*;
import javafx.fxml.FXML;
import javafx.geometry.Bounds;
@ -34,9 +35,11 @@ public class DashboardController implements RouteSelectionListener {
@Override
public void onRouteSelected(Object context) {
Profile.whenLoaded(profile -> {
for (var child : modulesFlowPane.getChildren()) {
DashboardModule module = (DashboardModule) child;
module.refreshContents();
}
});
}
}

View File

@ -12,15 +12,14 @@ import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.CategorySelectionBox;
import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
@ -75,11 +74,15 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public Spinner<Integer> lineItemQuantitySpinner;
@FXML public TextField lineItemValueField;
@FXML public TextField lineItemDescriptionField;
@FXML public CategorySelectionBox lineItemCategoryComboBox;
@FXML public Button addLineItemButton;
@FXML public VBox addLineItemForm;
@FXML public Button addLineItemAddButton;
@FXML public Button addLineItemCancelButton;
@FXML public VBox lineItemsVBox;
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
private final ObservableList<TransactionLineItem> lineItems = FXCollections.observableArrayList();
private static long tmpLineItemId = -1L;
@FXML public FileSelectionArea attachmentsSelectionArea;
@ -96,39 +99,40 @@ public class EditTransactionController implements RouteSelectionListener {
return ts != null && ts.isBefore(LocalDateTime.now());
}, "Timestamp cannot be in the future.")
).validatedInitially().attachToTextField(timestampField);
var amountValid = new ValidationApplier<>(
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
).validatedInitially().attachToTextField(amountField, currencyChoiceBox.valueProperty());
var amountValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false) {
@Override
public ValidationResult validate(String input) {
var r = super.validate(input);
if (!r.isValid()) return r;
// Check that this amount is enough to cover the total of any line items.
BigDecimal lineItemsTotal = lineItems.stream().map(TransactionLineItem::getTotalValue)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal transactionAmount = new BigDecimal(input);
if (transactionAmount.compareTo(lineItemsTotal) < 0) {
String msg = String.format(
"Amount must be at least %s to account for line items.",
CurrencyUtil.formatMoney(new MoneyValue(lineItemsTotal, currencyChoiceBox.getValue()))
);
return ValidationResult.of(msg);
}
return ValidationResult.valid();
}
}).validatedInitially().attachToTextField(
amountField,
currencyChoiceBox.valueProperty(),
new SimpleListProperty<>(lineItems)
);
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
initializeTagSelectionUi();
initializeLineItemsUi();
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
tagsHyperlink.setOnAction(event -> router.navigate("tags"));
// Initialize line item stuff.
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
addLineItemCancelButton.setOnAction(event -> {
lineItemQuantitySpinner.getValueFactory().setValue(1);
lineItemValueField.setText(null);
lineItemDescriptionField.setText(null);
addingLineItemProperty.set(false);
});
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
var lineItemValueValid = new ValidationApplier<>(
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
).attachToTextField(lineItemDescriptionField);
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not());
}
@ -157,6 +161,7 @@ public class EditTransactionController implements RouteSelectionListener {
vendor,
category,
tags,
lineItems,
newAttachmentPaths
)
);
@ -173,6 +178,7 @@ public class EditTransactionController implements RouteSelectionListener {
vendor,
category,
tags,
lineItems,
existingAttachments,
newAttachmentPaths
)
@ -195,6 +201,8 @@ public class EditTransactionController implements RouteSelectionListener {
vendorComboBox.setValue(null);
categoryComboBox.select(null);
addingLineItemProperty.set(false);
if (transaction == null) {
titleLabel.setText("Create New Transaction");
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
@ -215,7 +223,8 @@ public class EditTransactionController implements RouteSelectionListener {
var accountRepo = ds.getAccountRepository();
var transactionRepo = ds.getTransactionRepository();
var vendorRepo = ds.getTransactionVendorRepository();
var categoryRepo = ds.getTransactionCategoryRepository()
var categoryRepo = ds.getTransactionCategoryRepository();
var lineItemRepo = ds.getTransactionLineItemRepository()
) {
// First fetch all the data.
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
@ -229,12 +238,14 @@ public class EditTransactionController implements RouteSelectionListener {
final CreditAndDebitAccounts linkedAccounts;
final String vendorName;
final TransactionCategory category;
final List<TransactionLineItem> existingLineItems;
if (transaction == null) {
attachments = Collections.emptyList();
tags = Collections.emptyList();
linkedAccounts = new CreditAndDebitAccounts(null, null);
vendorName = null;
category = null;
existingLineItems = Collections.emptyList();
} else {
attachments = transactionRepo.findAttachments(transaction.id);
tags = transactionRepo.findTags(transaction.id);
@ -250,6 +261,7 @@ public class EditTransactionController implements RouteSelectionListener {
} else {
category = null;
}
existingLineItems = lineItemRepo.findItems(transaction.id);
}
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
// Then make updates to the view.
@ -275,6 +287,9 @@ public class EditTransactionController implements RouteSelectionListener {
creditAccountSelector.select(linkedAccounts.creditAccount());
debitAccountSelector.select(linkedAccounts.debitAccount());
}
lineItemCategoryComboBox.loadCategories(categoryTreeNodes);
lineItemCategoryComboBox.select(null);
lineItems.setAll(existingLineItems);
container.setDisable(false);
});
} catch (Exception e) {
@ -291,7 +306,7 @@ public class EditTransactionController implements RouteSelectionListener {
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
return new ValidationApplier<>(getLinkedAccountsValidator())
.validatedInitially()
.attach(linkedAccountsContainer, linkedAccountsProperty);
.attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty());
}
private void initializeTagSelectionUi() {
@ -326,6 +341,73 @@ public class EditTransactionController implements RouteSelectionListener {
return tile;
}
private void initializeLineItemsUi() {
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
addLineItemCancelButton.setOnAction(event -> addingLineItemProperty.set(false));
addingLineItemProperty.addListener((observable, oldValue, newValue) -> {
if (!newValue) { // The form has been closed.
lineItemQuantitySpinner.getValueFactory().setValue(1);
lineItemValueField.setText(null);
lineItemDescriptionField.setText(null);
lineItemCategoryComboBox.setValue(categoryComboBox.getValue());
}
});
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
BindingUtil.mapContent(lineItemsVBox.getChildren(), lineItems, this::createLineItemTile);
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
var lineItemValueValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false))
.validatedInitially().attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
.addPredicate(s -> s.strip().length() <= TransactionLineItem.DESCRIPTION_MAX_LENGTH, "Description is too long.")
.addPredicate(
s -> lineItems.stream().map(TransactionLineItem::getDescription).noneMatch(d -> d.equalsIgnoreCase(s)),
"Description must be unique."
)
).validatedInitially().attachToTextField(lineItemDescriptionField);
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
addLineItemAddButton.setOnAction(event -> {
int quantity = lineItemQuantitySpinner.getValue();
BigDecimal valuePerItem = new BigDecimal(lineItemValueField.getText());
String description = lineItemDescriptionField.getText().strip();
TransactionCategory category = lineItemCategoryComboBox.getValue();
Long categoryId = category == null ? null : category.id;
long tmpId = tmpLineItemId--;
TransactionLineItem tmpItem = new TransactionLineItem(tmpId, -1L, valuePerItem, quantity, -1, description, categoryId);
lineItems.add(tmpItem);
addingLineItemProperty.set(false);
});
}
private Node createLineItemTile(TransactionLineItem item) {
TransactionLineItemTile tile = TransactionLineItemTile.build(item, currencyChoiceBox.valueProperty(), categoryComboBox.getItems()).join();
Button removeButton = new Button("Remove");
removeButton.setMaxWidth(Double.POSITIVE_INFINITY);
removeButton.setOnAction(event -> lineItems.remove(item));
Button moveUpButton = new Button("Move Up");
moveUpButton.setMaxWidth(Double.POSITIVE_INFINITY);
moveUpButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getFirst().equals(item)));
moveUpButton.setOnAction(event -> {
int currentIdx = lineItems.indexOf(item);
lineItems.remove(currentIdx);
lineItems.add(currentIdx - 1, item);
});
Button moveDownButton = new Button("Move Down");
moveDownButton.setMaxWidth(Double.POSITIVE_INFINITY);
moveDownButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getLast().equals(item)));
moveDownButton.setOnAction(event -> {
int currentIdx = lineItems.indexOf(item);
lineItems.remove(currentIdx);
lineItems.add(currentIdx + 1, item);
});
VBox buttonsBox = new VBox(removeButton, moveUpButton, moveDownButton);
buttonsBox.getStyleClass().addAll("std-spacing");
tile.setRight(buttonsBox);
return tile;
}
private CreditAndDebitAccounts getSelectedAccounts() {
return new CreditAndDebitAccounts(
creditAccountSelector.getValue(),

View File

@ -7,32 +7,42 @@ import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
import javafx.application.Platform;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Circle;
import javafx.scene.text.TextFlow;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Currency;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import static com.andrewlalis.perfin.PerfinApp.router;
public class TransactionViewController {
private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
private final ObservableValue<Currency> observableCurrency = transactionProperty.map(Transaction::getCurrency);
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
private final ObservableList<TransactionLineItem> lineItemsList = FXCollections.observableArrayList();
private final ListProperty<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItemsList);
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
@FXML public Label titleLabel;
@ -49,6 +59,8 @@ public class TransactionViewController {
@FXML public Hyperlink debitAccountLink;
@FXML public Hyperlink creditAccountLink;
@FXML public VBox lineItemsVBox;
@FXML public AttachmentsViewPane attachmentsViewPane;
@FXML public void initialize() {
@ -89,6 +101,26 @@ public class TransactionViewController {
return event -> {};
}));
VBox lineItemsContainer = (VBox) lineItemsVBox.getParent();
BindingUtil.bindManagedAndVisible(lineItemsContainer, lineItemsProperty.emptyProperty().not());
lineItemsProperty.addListener((observable, oldValue, newValue) -> {
lineItemsVBox.getChildren().clear();
Label loadingLabel = new Label("Loading line items...");
loadingLabel.getStyleClass().addAll("secondary-color-text-fill");
lineItemsVBox.getChildren().add(loadingLabel);
List<CompletableFuture<TransactionLineItemTile>> tileFutures = lineItemsList.stream()
.map(item -> TransactionLineItemTile.build(item, observableCurrency, null))
.toList();
Thread.ofVirtual().start(() -> {
List<TransactionLineItemTile> tiles = tileFutures.stream()
.map(CompletableFuture::join).toList();
Platform.runLater(() -> {
lineItemsVBox.getChildren().remove(loadingLabel);
lineItemsVBox.getChildren().addAll(tiles);
});
});
});
attachmentsViewPane.hideIfEmpty();
attachmentsViewPane.listProperty().bindContent(attachmentsList);
@ -98,6 +130,7 @@ public class TransactionViewController {
vendorProperty.set(null);
categoryProperty.set(null);
tagsList.clear();
lineItemsList.clear();
attachmentsList.clear();
} else {
updateLinkedData(newValue);
@ -115,19 +148,22 @@ public class TransactionViewController {
try (
var transactionRepo = ds.getTransactionRepository();
var vendorRepo = ds.getTransactionVendorRepository();
var categoryRepo = ds.getTransactionCategoryRepository()
var categoryRepo = ds.getTransactionCategoryRepository();
var lineItemsRepo = ds.getTransactionLineItemRepository()
) {
final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
final var attachments = transactionRepo.findAttachments(tx.id);
final var tags = transactionRepo.findTags(tx.id);
final var lineItems = lineItemsRepo.findItems(tx.id);
Platform.runLater(() -> {
linkedAccountsProperty.set(linkedAccounts);
vendorProperty.set(vendor);
categoryProperty.set(category);
attachmentsList.setAll(attachments);
tagsList.setAll(tags);
lineItemsList.setAll(lineItems);
});
} catch (Exception e) {
log.error("Failed to fetch additional transaction data.", e);

View File

@ -32,6 +32,7 @@ public interface DataSource {
TransactionRepository getTransactionRepository();
TransactionVendorRepository getTransactionVendorRepository();
TransactionCategoryRepository getTransactionCategoryRepository();
TransactionLineItemRepository getTransactionLineItemRepository();
AttachmentRepository getAttachmentRepository();
HistoryRepository getHistoryRepository();
@ -87,6 +88,7 @@ public interface DataSource {
TransactionRepository.class, this::getTransactionRepository,
TransactionVendorRepository.class, this::getTransactionVendorRepository,
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
TransactionLineItemRepository.class, this::getTransactionLineItemRepository,
AttachmentRepository.class, this::getAttachmentRepository,
HistoryRepository.class, this::getHistoryRepository,
AnalyticsRepository.class, this::getAnalyticsRepository

View File

@ -0,0 +1,10 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.TransactionLineItem;
import java.util.List;
public interface TransactionLineItemRepository extends Repository, AutoCloseable {
List<TransactionLineItem> findItems(long transactionId);
List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items);
}

View File

@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.model.TransactionLineItem;
import java.math.BigDecimal;
import java.nio.file.Path;
@ -24,6 +25,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
String vendor,
String category,
Set<String> tags,
List<TransactionLineItem> lineItems,
List<Path> attachments
);
Optional<Transaction> findById(long id);
@ -50,6 +52,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
String vendor,
String category,
Set<String> tags,
List<TransactionLineItem> lineItems,
List<Attachment> existingAttachments,
List<Path> newAttachmentPaths
);

View File

@ -272,7 +272,8 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
String accountNumber = rs.getString("account_number");
String name = rs.getString("name");
Currency currency = Currency.getInstance(rs.getString("currency"));
return new Account(id, createdAt, archived, type, accountNumber, name, currency);
String description = rs.getString("description");
return new Account(id, createdAt, archived, type, accountNumber, name, currency, description);
}
@Override

View File

@ -59,6 +59,11 @@ public class JdbcDataSource implements DataSource {
return new JdbcTransactionCategoryRepository(getConnection());
}
@Override
public TransactionLineItemRepository getTransactionLineItemRepository() {
return new JdbcTransactionLineItemRepository(getConnection());
}
@Override
public AttachmentRepository getAttachmentRepository() {
return new JdbcAttachmentRepository(getConnection(), contentDir);

View File

@ -35,7 +35,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
* the profile has a newer schema version, we'll exit and prompt the user
* to update their app.
*/
public static final int SCHEMA_VERSION = 3;
public static final int SCHEMA_VERSION = 4;
public DataSource getDataSource(String profileName) throws ProfileLoadException {
final boolean dbExists = Files.exists(getDatabaseFile(profileName));

View File

@ -0,0 +1,79 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.TransactionLineItemRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
import com.andrewlalis.perfin.model.TransactionLineItem;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.Collections;
import java.util.List;
public record JdbcTransactionLineItemRepository(Connection conn) implements TransactionLineItemRepository {
@Override
public List<TransactionLineItem> findItems(long transactionId) {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction_line_item WHERE transaction_id = ? ORDER BY idx ASC",
List.of(transactionId),
JdbcTransactionLineItemRepository::parseItem
);
}
@Override
public List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items) {
// First delete all existing line items since it's just easier that way.
DbUtil.update(conn, "DELETE FROM transaction_line_item WHERE transaction_id = ?", transactionId);
if (items.isEmpty()) return Collections.emptyList(); // Skip insertion logic if no items are present.
String query = """
INSERT INTO transaction_line_item (
transaction_id,
value_per_item,
quantity,
idx,
description,
category_id
) VALUES (?, ?, ?, ?, ?, ?)""";
try (var stmt = conn.prepareStatement(query)) {
for (int i = 0; i < items.size(); i++) {
TransactionLineItem item = items.get(i);
stmt.setLong(1, transactionId);
stmt.setBigDecimal(2, item.getValuePerItem());
stmt.setInt(3, item.getQuantity());
stmt.setInt(4, i);
stmt.setString(5, item.getDescription());
if (item.getCategoryId() == null) {
stmt.setNull(6, Types.BIGINT);
} else {
stmt.setLong(6, item.getCategoryId());
}
int rowCount = stmt.executeUpdate();
if (rowCount != 1) throw new SQLException("Failed to insert line item.");
}
return findItems(transactionId); // Simply re-fetch items afterward. Their properties may have changed.
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
@Override
public void close() throws Exception {
conn.close();
}
public static TransactionLineItem parseItem(ResultSet rs) throws SQLException {
long id = rs.getLong("id");
long transactionId = rs.getLong("transaction_id");
BigDecimal valuePerItem = rs.getBigDecimal("value_per_item");
int quantity = rs.getInt("quantity");
int idx = rs.getInt("idx");
String description = rs.getString("description");
Long categoryId = rs.getLong("category_id");
if (rs.wasNull()) categoryId = null;
return new TransactionLineItem(id, transactionId, valuePerItem, quantity, idx, description, categoryId);
}
}

View File

@ -1,9 +1,6 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.*;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
@ -32,6 +29,7 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
String vendor,
String category,
Set<String> tags,
List<TransactionLineItem> lineItems,
List<Path> attachments
) {
return DbUtil.doTransaction(conn, () -> {
@ -93,6 +91,10 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
}
}
// Add Line Items.
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
lineItemRepo.saveItems(txId, lineItems);
return txId;
});
}
@ -297,6 +299,7 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
String vendor,
String category,
Set<String> tags,
List<TransactionLineItem> lineItems,
List<Attachment> existingAttachments,
List<Path> newAttachmentPaths
) {
@ -393,6 +396,13 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
insertAttachmentLink(tx.id, attachment.id);
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
}
// Manage line item changes.
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
List<TransactionLineItem> existingLineItems = lineItemRepo.findItems(tx.id);
if (!existingLineItems.equals(lineItems)) {
lineItemRepo.saveItems(tx.id, lineItems);
updateMessages.add("Updated line items.");
}
// Add a text history item to any linked accounts detailing the changes.
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);

View File

@ -18,6 +18,7 @@ public class Migrations {
final Map<Integer, Migration> migrations = new HashMap<>();
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
return migrations;
}

View File

@ -8,6 +8,8 @@ import java.util.Currency;
* credit-card, etc.).
*/
public class Account extends IdEntity {
public static final int DESCRIPTION_MAX_LENGTH = 255;
private final LocalDateTime createdAt;
private final boolean archived;
@ -15,8 +17,9 @@ public class Account extends IdEntity {
private final String accountNumber;
private final String name;
private final Currency currency;
private final String description;
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency) {
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency, String description) {
super(id);
this.createdAt = createdAt;
this.archived = archived;
@ -24,6 +27,7 @@ public class Account extends IdEntity {
this.accountNumber = accountNumber;
this.name = name;
this.currency = currency;
this.description = description;
}
public AccountType getType() {
@ -62,6 +66,10 @@ public class Account extends IdEntity {
return currency;
}
public String getDescription() {
return description;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@ -16,14 +16,16 @@ public class TransactionLineItem extends IdEntity {
private final int quantity;
private final int idx;
private final String description;
private final Long categoryId;
public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description) {
public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description, Long categoryId) {
super(id);
this.transactionId = transactionId;
this.valuePerItem = valuePerItem;
this.quantity = quantity;
this.idx = idx;
this.description = description;
this.categoryId = categoryId;
}
public long getTransactionId() {
@ -46,6 +48,10 @@ public class TransactionLineItem extends IdEntity {
return description;
}
public Long getCategoryId() {
return categoryId;
}
public BigDecimal getTotalValue() {
return valuePerItem.multiply(new BigDecimal(quantity));
}

View File

@ -7,6 +7,10 @@ import javafx.scene.shape.Circle;
public class CategoryLabel extends HBox {
public CategoryLabel(TransactionCategory category) {
this(category, 8);
}
public CategoryLabel(TransactionCategory category, double indicatorSize) {
Circle colorIndicator = new Circle(8, category.getColor());
Label label = new Label(category.getName());
this.getChildren().addAll(colorIndicator, label);

View File

@ -0,0 +1,86 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.TransactionCategory;
import com.andrewlalis.perfin.model.TransactionLineItem;
import javafx.application.Platform;
import javafx.beans.value.ObservableValue;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Currency;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
public class TransactionLineItemTile extends BorderPane {
private static final Logger log = LoggerFactory.getLogger(TransactionLineItemTile.class);
private TransactionLineItemTile() {}
public static CompletableFuture<TransactionLineItemTile> build(TransactionLineItem item, ObservableValue<Currency> currencyValue, List<TransactionCategory> categoriesCache) {
TransactionLineItemTile tile = new TransactionLineItemTile();
tile.getStyleClass().addAll("std-spacing", "std-padding", "small-font");
tile.setStyle("-fx-background-color: -fx-theme-background-2;");
Function<String, Label> boldLabelMaker = s -> {
Label lbl = new Label(s);
lbl.getStyleClass().addAll("bold-text");
return lbl;
};
Label descriptionLabel = new Label(item.getDescription());
Label valuePerItemLabel = new Label();
valuePerItemLabel.getStyleClass().add("mono-font");
valuePerItemLabel.textProperty().bind(currencyValue
.map(currency -> CurrencyUtil.formatMoney(new MoneyValue(item.getValuePerItem(), currency)))
);
Label totalValueLabel = new Label();
totalValueLabel.getStyleClass().add("mono-font");
totalValueLabel.textProperty().bind(currencyValue
.map(currency -> CurrencyUtil.formatMoney(new MoneyValue(item.getTotalValue(), currency)))
);
Label quantityLabel = new Label(Integer.toString(item.getQuantity()));
quantityLabel.getStyleClass().add("mono-font");
PropertiesPane propertiesPane = new PropertiesPane(80);
propertiesPane.getChildren().addAll(
boldLabelMaker.apply("Description"), descriptionLabel,
boldLabelMaker.apply("Quantity"), quantityLabel,
boldLabelMaker.apply("Item Value"), valuePerItemLabel,
boldLabelMaker.apply("Total"), totalValueLabel
);
tile.setCenter(propertiesPane);
if (item.getCategoryId() != null) {
if (categoriesCache != null) {
TransactionCategory category = categoriesCache.stream()
.filter(c -> c.id == item.getCategoryId())
.findFirst().orElse(null);
if (category == null) {
log.warn("Failed to find cached category for line item.");
} else {
propertiesPane.getChildren().addAll(
boldLabelMaker.apply("Category"), new CategoryLabel(category, 5)
);
}
return CompletableFuture.completedFuture(tile);
} else {
CompletableFuture<TransactionLineItemTile> cf = new CompletableFuture<>();
Profile.getCurrent().dataSource().mapRepoAsync(
TransactionCategoryRepository.class,
repo -> repo.findById(item.getCategoryId()).orElse(null)
).thenAccept(category -> Platform.runLater(() -> {
propertiesPane.getChildren().addAll(
boldLabelMaker.apply("Category"), new CategoryLabel(category, 5)
);
cf.complete(tile);
}));
return cf;
}
} else {
return CompletableFuture.completedFuture(tile);
}
}
}

View File

@ -85,13 +85,11 @@
</HBox>
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
</VBox>
</PropertiesPane>
<!-- Container for line items -->
<VBox styleClass="std-padding">
<Label text="Line Items" styleClass="bold-text"/>
<VBox maxWidth="Infinity">
<Button text="Add Line Item" fx:id="addLineItemButton" disable="true"/>
<StyledText styleClass="small-font">
Line items aren't yet supported. I'm working on it!
</StyledText>
<Button text="Add Line Item" fx:id="addLineItemButton"/>
<VBox styleClass="std-spacing" fx:id="addLineItemForm">
<HBox styleClass="std-spacing">
<VBox>
@ -107,15 +105,18 @@
<Label text="Description" styleClass="bold-text,small-font"/>
<TextField fx:id="lineItemDescriptionField"/>
</VBox>
<VBox>
<Label text="Category" styleClass="bold-text,small-font"/>
<CategorySelectionBox fx:id="lineItemCategoryComboBox" maxWidth="Infinity"/>
</VBox>
<HBox styleClass="std-spacing" alignment="CENTER_RIGHT">
<Button text="Add" fx:id="addLineItemAddButton"/>
<Button text="Cancel" fx:id="addLineItemCancelButton"/>
</HBox>
</VBox>
<VBox fx:id="lineItemsVBox"/>
<VBox fx:id="lineItemsVBox" styleClass="std-padding, std-spacing"/>
</VBox>
</PropertiesPane>
<!-- Container for attachments -->
<VBox styleClass="std-padding">

View File

@ -0,0 +1,18 @@
/*
This migration adds a few things:
- A `description` column to the account table, so people can add extra notes and
content to their accounts that isn't otherwise captured by the other fields.
- A `category_id` is added to transaction line items, so that each line item can
individually be marked with a category, so that you can further differentiate
large purchases consisting of smaller items.
*/
ALTER TABLE account
ADD COLUMN description VARCHAR(255) DEFAULT NULL AFTER currency;
ALTER TABLE transaction_line_item
ADD COLUMN category_id BIGINT DEFAULT NULL AFTER description;
ALTER TABLE transaction_line_item
ADD CONSTRAINT fk_transaction_line_item_category
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE CASCADE;

View File

@ -5,7 +5,8 @@ CREATE TABLE account (
account_type VARCHAR(31) NOT NULL,
account_number VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(63) NOT NULL,
currency VARCHAR(3) NOT NULL
currency VARCHAR(3) NOT NULL,
description VARCHAR(255) DEFAULT NULL
);
CREATE TABLE attachment (
@ -102,9 +103,13 @@ CREATE TABLE transaction_line_item (
quantity INT NOT NULL DEFAULT 1,
idx INT NOT NULL DEFAULT 0,
description VARCHAR(255) NOT NULL,
category_id BIGINT DEFAULT NULL,
CONSTRAINT fk_transaction_line_item_transaction
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_transaction_line_item_category
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT ck_transaction_line_item_quantity_positive
CHECK quantity > 0
);

View File

@ -74,6 +74,12 @@
<Hyperlink fx:id="creditAccountLink"/>
</TextFlow>
</VBox>
<VBox styleClass="std-spacing">
<Label text="Line Items" styleClass="bold-text"/>
<VBox fx:id="lineItemsVBox" styleClass="std-spacing"/>
</VBox>
<AttachmentsViewPane fx:id="attachmentsViewPane"/>
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
<Button text="Edit" onAction="#editTransaction"/>