From 41530d5276946dfac8ef0c23bc6c078b048b31fa Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Fri, 9 Feb 2024 12:21:06 -0500 Subject: [PATCH] Added the ability to add, edit, and remove transaction line items. --- .../perfin/control/DashboardController.java | 11 +- .../control/EditTransactionController.java | 140 ++++++++++++++---- .../control/TransactionViewController.java | 38 ++++- .../andrewlalis/perfin/data/DataSource.java | 2 + .../data/TransactionLineItemRepository.java | 10 ++ .../perfin/data/TransactionRepository.java | 3 + .../data/impl/JdbcAccountRepository.java | 3 +- .../perfin/data/impl/JdbcDataSource.java | 5 + .../data/impl/JdbcDataSourceFactory.java | 2 +- .../JdbcTransactionLineItemRepository.java | 79 ++++++++++ .../data/impl/JdbcTransactionRepository.java | 18 ++- .../data/impl/migration/Migrations.java | 1 + .../com/andrewlalis/perfin/model/Account.java | 10 +- .../perfin/model/TransactionLineItem.java | 8 +- .../perfin/view/component/CategoryLabel.java | 4 + .../component/TransactionLineItemTile.java | 86 +++++++++++ src/main/resources/edit-transaction.fxml | 61 ++++---- ...dLineItemCategoryAndAccountDescription.sql | 18 +++ src/main/resources/sql/schema.sql | 7 +- src/main/resources/transaction-view.fxml | 6 + 20 files changed, 439 insertions(+), 73 deletions(-) create mode 100644 src/main/java/com/andrewlalis/perfin/data/TransactionLineItemRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionLineItemRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/view/component/TransactionLineItemTile.java create mode 100644 src/main/resources/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql diff --git a/src/main/java/com/andrewlalis/perfin/control/DashboardController.java b/src/main/java/com/andrewlalis/perfin/control/DashboardController.java index a33667c..3fdfdac 100644 --- a/src/main/java/com/andrewlalis/perfin/control/DashboardController.java +++ b/src/main/java/com/andrewlalis/perfin/control/DashboardController.java @@ -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) { - for (var child : modulesFlowPane.getChildren()) { - DashboardModule module = (DashboardModule) child; - module.refreshContents(); - } + Profile.whenLoaded(profile -> { + for (var child : modulesFlowPane.getChildren()) { + DashboardModule module = (DashboardModule) child; + module.refreshContents(); + } + }); } } diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index 0a7c119..14c977f 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -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 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 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() .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() - .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 currencies = accountRepo.findAllUsedCurrencies().stream() @@ -229,12 +238,14 @@ public class EditTransactionController implements RouteSelectionListener { final CreditAndDebitAccounts linkedAccounts; final String vendorName; final TransactionCategory category; + final List 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 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() + .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(), diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index 02cf37d..940220b 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -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 transactionProperty = new SimpleObjectProperty<>(null); + private final ObservableValue observableCurrency = transactionProperty.map(Transaction::getCurrency); private final ObjectProperty linkedAccountsProperty = new SimpleObjectProperty<>(null); private final ObjectProperty vendorProperty = new SimpleObjectProperty<>(null); private final ObjectProperty categoryProperty = new SimpleObjectProperty<>(null); private final ObservableList tagsList = FXCollections.observableArrayList(); private final ListProperty tagsListProperty = new SimpleListProperty<>(tagsList); + private final ObservableList lineItemsList = FXCollections.observableArrayList(); + private final ListProperty lineItemsProperty = new SimpleListProperty<>(lineItemsList); private final ObservableList 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> tileFutures = lineItemsList.stream() + .map(item -> TransactionLineItemTile.build(item, observableCurrency, null)) + .toList(); + Thread.ofVirtual().start(() -> { + List 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); diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java index 1f21073..a0a4720 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java @@ -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 diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionLineItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionLineItemRepository.java new file mode 100644 index 0000000..e7ba0d7 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionLineItemRepository.java @@ -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 findItems(long transactionId); + List saveItems(long transactionId, List items); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index 1275202..5686a59 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -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 tags, + List lineItems, List attachments ); Optional findById(long id); @@ -50,6 +52,7 @@ public interface TransactionRepository extends Repository, AutoCloseable { String vendor, String category, Set tags, + List lineItems, List existingAttachments, List newAttachmentPaths ); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java index 6dec67e..e044517 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -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 diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java index 1469d31..438cfab 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java @@ -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); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index 4fb2141..912d0c9 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -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)); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionLineItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionLineItemRepository.java new file mode 100644 index 0000000..a223eaf --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionLineItemRepository.java @@ -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 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 saveItems(long transactionId, List 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); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java index 097970a..cbc3bdf 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -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 tags, + List lineItems, List 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 tags, + List lineItems, List existingAttachments, List 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 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); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java index 3893fae..2e28bbe 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java @@ -18,6 +18,7 @@ public class Migrations { final Map 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; } diff --git a/src/main/java/com/andrewlalis/perfin/model/Account.java b/src/main/java/com/andrewlalis/perfin/model/Account.java index 3cd4231..5acdb7b 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Account.java +++ b/src/main/java/com/andrewlalis/perfin/model/Account.java @@ -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; } diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java index fa58745..c057bc0 100644 --- a/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java @@ -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)); } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/CategoryLabel.java b/src/main/java/com/andrewlalis/perfin/view/component/CategoryLabel.java index cdaa08f..f7913b2 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/CategoryLabel.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/CategoryLabel.java @@ -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); diff --git a/src/main/java/com/andrewlalis/perfin/view/component/TransactionLineItemTile.java b/src/main/java/com/andrewlalis/perfin/view/component/TransactionLineItemTile.java new file mode 100644 index 0000000..9278448 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/TransactionLineItemTile.java @@ -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 build(TransactionLineItem item, ObservableValue currencyValue, List categoriesCache) { + TransactionLineItemTile tile = new TransactionLineItemTile(); + tile.getStyleClass().addAll("std-spacing", "std-padding", "small-font"); + tile.setStyle("-fx-background-color: -fx-theme-background-2;"); + Function 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 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); + } + } +} diff --git a/src/main/resources/edit-transaction.fxml b/src/main/resources/edit-transaction.fxml index 9687dfa..13c7ef2 100644 --- a/src/main/resources/edit-transaction.fxml +++ b/src/main/resources/edit-transaction.fxml @@ -85,37 +85,38 @@ - -