diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index 13d1701..298f128 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -58,7 +58,7 @@ public class PerfinApp extends Application { PerfinApp::initAppDir, c -> initMainScreen(stage, c), PerfinApp::loadLastUsedProfile - )); + ), false); splashScreen.showAndWait(); if (splashScreen.isStartupSuccessful()) { stage.show(); diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index 5db7345..6faed03 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -1,12 +1,14 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.data.DataSource; import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.Sort; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.*; +import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.validation.ValidationApplier; @@ -15,10 +17,16 @@ import com.andrewlalis.perfin.view.component.validation.validators.PredicateVali import javafx.application.Platform; import javafx.beans.property.Property; import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.event.ActionEvent; import javafx.fxml.FXML; +import javafx.geometry.Pos; import javafx.scene.control.*; +import javafx.scene.input.KeyCode; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,10 +35,7 @@ import java.nio.file.Path; import java.time.DateTimeException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Collections; -import java.util.Comparator; -import java.util.Currency; -import java.util.List; +import java.util.*; import static com.andrewlalis.perfin.PerfinApp.router; @@ -49,6 +54,13 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public AccountSelectionBox debitAccountSelector; @FXML public AccountSelectionBox creditAccountSelector; + @FXML public ComboBox vendorComboBox; + @FXML public ComboBox categoryComboBox; + @FXML public ComboBox tagsComboBox; + @FXML public Button addTagButton; + @FXML public VBox tagsVBox; + private final ObservableList selectedTags = FXCollections.observableArrayList(); + @FXML public FileSelectionArea attachmentsSelectionArea; @FXML public Button saveButton; @@ -75,27 +87,40 @@ public class EditTransactionController implements RouteSelectionListener { Property linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts()); debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); - var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator() - .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.") - .addPredicate( - accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()), - "The credit and debit accounts cannot be the same." - ) - .addPredicate( - accounts -> ( - (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) && - (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue())) - ), - "Linked accounts must use the same currency." - ) - .addPredicate( - accounts -> ( - (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) && - (!accounts.hasDebit() || !accounts.debitAccount().isArchived()) - ), - "Linked accounts must not be archived." - ) - ).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty); + var linkedAccountsValid = new ValidationApplier<>(getLinkedAccountsValidator()) + .validatedInitially() + .attach(linkedAccountsContainer, linkedAccountsProperty); + + // Set up the list of added tags. + addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank())); + addTagButton.setOnAction(event -> { + if (tagsComboBox.getValue() == null) return; + String tag = tagsComboBox.getValue().strip(); + if (!selectedTags.contains(tag)) { + selectedTags.add(tag); + selectedTags.sort(String::compareToIgnoreCase); + } + tagsComboBox.setValue(null); + }); + tagsComboBox.setOnKeyPressed(event -> { + if (event.getCode() == KeyCode.ENTER) { + addTagButton.fire(); + } + }); + BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, tag -> { + Label label = new Label(tag); + label.setMaxWidth(Double.POSITIVE_INFINITY); + label.getStyleClass().addAll("bold-text"); + Button removeButton = new Button("Remove"); + removeButton.setOnAction(event -> { + selectedTags.remove(tag); + }); + BorderPane tile = new BorderPane(label); + tile.setRight(removeButton); + tile.getStyleClass().addAll("std-spacing"); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + return tile; + }); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); saveButton.disableProperty().bind(formValid.not()); @@ -107,6 +132,9 @@ public class EditTransactionController implements RouteSelectionListener { Currency currency = currencyChoiceBox.getValue(); String description = getSanitizedDescription(); CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); + String vendor = vendorComboBox.getValue(); + String category = categoryComboBox.getValue(); + Set tags = new HashSet<>(selectedTags); List newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths(); List existingAttachments = attachmentsSelectionArea.getSelectedAttachments(); final long idToNavigate; @@ -119,6 +147,9 @@ public class EditTransactionController implements RouteSelectionListener { currency, description, linkedAccounts, + vendor, + category, + tags, newAttachmentPaths ) ); @@ -132,6 +163,9 @@ public class EditTransactionController implements RouteSelectionListener { currency, description, linkedAccounts, + vendor, + category, + tags, existingAttachments, newAttachmentPaths ) @@ -149,6 +183,11 @@ public class EditTransactionController implements RouteSelectionListener { public void onRouteSelected(Object context) { transaction = (Transaction) context; + // Clear some initial fields immediately: + tagsComboBox.setValue(null); + vendorComboBox.setValue(null); + categoryComboBox.setValue(null); + if (transaction == null) { titleLabel.setText("Create New Transaction"); timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); @@ -163,10 +202,13 @@ public class EditTransactionController implements RouteSelectionListener { // Fetch some account-specific data. container.setDisable(true); + DataSource ds = Profile.getCurrent().dataSource(); Thread.ofVirtual().start(() -> { try ( - var accountRepo = Profile.getCurrent().dataSource().getAccountRepository(); - var transactionRepo = Profile.getCurrent().dataSource().getTransactionRepository() + var accountRepo = ds.getAccountRepository(); + var transactionRepo = ds.getTransactionRepository(); + var vendorRepo = ds.getTransactionVendorRepository(); + var categoryRepo = ds.getTransactionCategoryRepository() ) { // First fetch all the data. List currencies = accountRepo.findAllUsedCurrencies().stream() @@ -174,23 +216,50 @@ public class EditTransactionController implements RouteSelectionListener { .toList(); List accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); final List attachments; + final List availableTags = transactionRepo.findAllTags(); + final List tags; final CreditAndDebitAccounts linkedAccounts; + final String vendorName; + final String categoryName; if (transaction == null) { attachments = Collections.emptyList(); + tags = Collections.emptyList(); linkedAccounts = new CreditAndDebitAccounts(null, null); + vendorName = null; + categoryName = null; } else { attachments = transactionRepo.findAttachments(transaction.id); + tags = transactionRepo.findTags(transaction.id); linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id); + if (transaction.getVendorId() != null) { + vendorName = vendorRepo.findById(transaction.getVendorId()) + .map(TransactionVendor::getName).orElse(null); + } else { + vendorName = null; + } + if (transaction.getCategoryId() != null) { + categoryName = categoryRepo.findById(transaction.getCategoryId()) + .map(TransactionCategory::getName).orElse(null); + } else { + categoryName = null; + } } + final List availableVendors = vendorRepo.findAll(); + final List availableCategories = categoryRepo.findAll(); // Then make updates to the view. Platform.runLater(() -> { + currencyChoiceBox.getItems().setAll(currencies); creditAccountSelector.setAccounts(accounts); debitAccountSelector.setAccounts(accounts); - currencyChoiceBox.getItems().setAll(currencies); + vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList()); + vendorComboBox.setValue(vendorName); + categoryComboBox.getItems().setAll(availableCategories.stream().map(TransactionCategory::getName).toList()); + categoryComboBox.setValue(categoryName); + tagsComboBox.getItems().setAll(availableTags); attachmentsSelectionArea.clear(); attachmentsSelectionArea.addAttachments(attachments); + selectedTags.clear(); if (transaction == null) { - // TODO: Allow user to select a default currency. currencyChoiceBox.getSelectionModel().selectFirst(); creditAccountSelector.select(null); debitAccountSelector.select(null); @@ -198,12 +267,14 @@ public class EditTransactionController implements RouteSelectionListener { currencyChoiceBox.getSelectionModel().select(transaction.getCurrency()); creditAccountSelector.select(linkedAccounts.creditAccount()); debitAccountSelector.select(linkedAccounts.debitAccount()); + selectedTags.addAll(tags); } container.setDisable(false); }); } catch (Exception e) { log.error("Failed to get repositories.", e); - Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()); + Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage())); + router.navigateBackAndClear(); } }); } @@ -215,6 +286,29 @@ public class EditTransactionController implements RouteSelectionListener { ); } + private PredicateValidator getLinkedAccountsValidator() { + return new PredicateValidator() + .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.") + .addPredicate( + accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()), + "The credit and debit accounts cannot be the same." + ) + .addPredicate( + accounts -> ( + (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) && + (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue())) + ), + "Linked accounts must use the same currency." + ) + .addPredicate( + accounts -> ( + (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) && + (!accounts.hasDebit() || !accounts.debitAccount().isArchived()) + ), + "Linked accounts must not be archived." + ); + } + private LocalDateTime parseTimestamp() { List formatters = List.of( DateTimeFormatter.ISO_LOCAL_DATE_TIME, diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java index 8f3d08c..0304a94 100644 --- a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java @@ -106,6 +106,7 @@ public class ProfilesViewController { log.info("Opening profile \"{}\".", name); try { Profile.setCurrent(PerfinApp.profileLoader.load(name)); + ProfileLoader.saveLastProfile(name); ProfilesStage.closeView(); router.replace("accounts"); if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded."); diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java index ca008de..ba57e7a 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java @@ -30,6 +30,8 @@ public interface DataSource { AccountRepository getAccountRepository(); BalanceRecordRepository getBalanceRecordRepository(); TransactionRepository getTransactionRepository(); + TransactionVendorRepository getTransactionVendorRepository(); + TransactionCategoryRepository getTransactionCategoryRepository(); AttachmentRepository getAttachmentRepository(); AccountHistoryItemRepository getAccountHistoryItemRepository(); @@ -81,6 +83,8 @@ public interface DataSource { AccountRepository.class, this::getAccountRepository, BalanceRecordRepository.class, this::getBalanceRecordRepository, TransactionRepository.class, this::getTransactionRepository, + TransactionVendorRepository.class, this::getTransactionVendorRepository, + TransactionCategoryRepository.class, this::getTransactionCategoryRepository, AttachmentRepository.class, this::getAttachmentRepository, AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository ); diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java new file mode 100644 index 0000000..71a3f3b --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java @@ -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 findById(long id); + Optional findByName(String name); + List findAllBaseCategories(); + List findAll(); + long insert(long parentId, String name, Color color); + long insert(String name, Color color); + void deleteById(long id); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index 08003cd..7865a70 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -21,6 +21,9 @@ public interface TransactionRepository extends Repository, AutoCloseable { Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List attachments ); Optional findById(long id); @@ -31,6 +34,8 @@ public interface TransactionRepository extends Repository, AutoCloseable { Page findAllByAccounts(Set accountIds, PageRequest pagination); CreditAndDebitAccounts findLinkedAccounts(long transactionId); List findAttachments(long transactionId); + List findTags(long transactionId); + List findAllTags(); void delete(long transactionId); void update( long id, @@ -39,6 +44,9 @@ public interface TransactionRepository extends Repository, AutoCloseable { Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List existingAttachments, List newAttachmentPaths ); diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java new file mode 100644 index 0000000..36eab89 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java @@ -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 findById(long id); + Optional findByName(String name); + List findAll(); + long insert(String name, String description); + long insert(String name); + void deleteById(long id); +} 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 5296a2a..9ad342e 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java @@ -49,6 +49,16 @@ public class JdbcDataSource implements DataSource { return new JdbcTransactionRepository(getConnection(), contentDir); } + @Override + public TransactionVendorRepository getTransactionVendorRepository() { + return new JdbcTransactionVendorRepository(getConnection()); + } + + @Override + public TransactionCategoryRepository getTransactionCategoryRepository() { + return new JdbcTransactionCategoryRepository(getConnection()); + } + @Override public AttachmentRepository getAttachmentRepository() { return new JdbcAttachmentRepository(getConnection(), contentDir); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java new file mode 100644 index 0000000..3eb3901 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java @@ -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 findById(long id) { + return DbUtil.findById( + conn, + "SELECT * FROM transaction_category WHERE id = ?", + id, + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public Optional findByName(String name) { + return DbUtil.findOne( + conn, + "SELECT * FROM transaction_category WHERE name = ?", + List.of(name), + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public List findAllBaseCategories() { + return DbUtil.findAll( + conn, + "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC", + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public List 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")) + ); + } +} 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 5eb9a8d..4c1f720 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -8,14 +8,14 @@ import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.data.util.UncheckedSqlException; import com.andrewlalis.perfin.model.*; +import javafx.scene.paint.Color; import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.file.Path; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; +import java.sql.*; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -28,29 +28,104 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List attachments ) { return DbUtil.doTransaction(conn, () -> { - // 1. Insert the transaction. - long txId = DbUtil.insertOne( - conn, - "INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)", - List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description) - ); - // 2. Insert linked account entries. + Long vendorId = null; + if (vendor != null && !vendor.isBlank()) { + vendorId = getOrCreateVendorId(vendor.strip()); + } + Long categoryId = null; + if (category != null && !category.isBlank()) { + categoryId = getOrCreateCategoryId(category.strip()); + } + // Insert the transaction, using a custom JDBC statement to deal with nullables. + long txId; + try (var stmt = conn.prepareStatement( + "INSERT INTO transaction (timestamp, amount, currency, description, vendor_id, category_id) VALUES (?, ?, ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + )) { + stmt.setTimestamp(1, DbUtil.timestampFromUtcLDT(utcTimestamp)); + stmt.setBigDecimal(2, amount); + stmt.setString(3, currency.getCurrencyCode()); + if (description != null && !description.isBlank()) { + stmt.setString(4, description.strip()); + } else { + stmt.setNull(4, Types.VARCHAR); + } + if (vendorId != null) { + stmt.setLong(5, vendorId); + } else { + stmt.setNull(5, Types.BIGINT); + } + if (categoryId != null) { + stmt.setLong(6, categoryId); + } else { + stmt.setNull(6, Types.BIGINT); + } + int result = stmt.executeUpdate(); + if (result != 1) throw new UncheckedSqlException("Transaction insert returned " + result); + var rs = stmt.getGeneratedKeys(); + if (!rs.next()) throw new UncheckedSqlException("Transaction insert didn't generate any keys."); + txId = rs.getLong(1); + } + // Insert linked account entries. AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn); linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency)); linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency)); - // 3. Add attachments. + // Add attachments. AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); for (Path attachmentPath : attachments) { Attachment attachment = attachmentRepo.insert(attachmentPath); insertAttachmentLink(txId, attachment.id); } + // Add tags. + for (String tag : tags) { + try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)")) { + long tagId = getOrCreateTagId(tag.toLowerCase().strip()); + stmt.setLong(1, txId); + stmt.setLong(2, tagId); + stmt.executeUpdate(); + } + + } return txId; }); } + private long getOrCreateVendorId(String name) { + var repo = new JdbcTransactionVendorRepository(conn); + TransactionVendor vendor = repo.findByName(name).orElse(null); + if (vendor != null) { + return vendor.id; + } + return repo.insert(name); + } + + private long getOrCreateCategoryId(String name) { + var repo = new JdbcTransactionCategoryRepository(conn); + TransactionCategory category = repo.findByName(name).orElse(null); + if (category != null) { + return category.id; + } + return repo.insert(name, Color.WHITE); + } + + private long getOrCreateTagId(String name) { + Optional optionalId = DbUtil.findOne( + conn, + "SELECT id FROM transaction_tag WHERE name = ?", + List.of(name), + rs -> rs.getLong(1) + ); + return optionalId.orElseGet(() -> + DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name)) + ); + } + @Override public Optional findById(long id) { return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction); @@ -147,6 +222,30 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem ); } + @Override + public List 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 findAllTags() { + return DbUtil.findAll( + conn, + "SELECT name FROM transaction_tag ORDER BY name ASC", + rs -> rs.getString(1) + ); + } + @Override public void delete(long transactionId) { DbUtil.doTransaction(conn, () -> { @@ -164,44 +263,93 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List existingAttachments, List newAttachmentPaths ) { DbUtil.doTransaction(conn, () -> { - Transaction tx = findById(id).orElseThrow(); - CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id); - List currentAttachments = findAttachments(id); var entryRepo = new JdbcAccountEntryRepository(conn); var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); + var vendorRepo = new JdbcTransactionVendorRepository(conn); + var categoryRepo = new JdbcTransactionCategoryRepository(conn); + + Transaction tx = findById(id).orElseThrow(); + CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id); + TransactionVendor currentVendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElseThrow(); + String currentVendorName = currentVendor == null ? null : currentVendor.getName(); + TransactionCategory currentCategory = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElseThrow(); + String currentCategoryName = currentCategory == null ? null : currentCategory.getName(); + Set currentTags = new HashSet<>(findTags(id)); + List currentAttachments = findAttachments(id); + List updateMessages = new ArrayList<>(); if (!tx.getTimestamp().equals(utcTimestamp)) { - DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), id)); + DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", DbUtil.timestampFromUtcLDT(utcTimestamp), id); updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + "."); } BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP); if (!tx.getAmount().equals(scaledAmount)) { - DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", List.of(scaledAmount, id)); + DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", scaledAmount, id); updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + "."); } if (!tx.getCurrency().equals(currency)) { - DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", List.of(currency.getCurrencyCode(), id)); + DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", currency.getCurrencyCode(), id); updateMessages.add("Updated currency to " + currency.getCurrencyCode() + "."); } if (!Objects.equals(tx.getDescription(), description)) { - DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", List.of(description, id)); + DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", description, id); updateMessages.add("Updated description."); } - boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) || + boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) || !tx.getCurrency().equals(currency) || !tx.getTimestamp().equals(utcTimestamp) || !currentLinkedAccounts.equals(linkedAccounts); - if (updateAccountEntries) { - // Delete all entries and re-write them correctly? - DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id)); + if (shouldUpdateAccountEntries) { + // Delete all entries and re-write them correctly. + DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", id); linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency)); linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency)); updateMessages.add("Updated linked accounts."); } + // Manage vendor change. + if (!Objects.equals(vendor, currentVendorName)) { + if (vendor == null || vendor.isBlank()) { + DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = NULL WHERE id = ?", id); + } else { + long newVendorId = getOrCreateVendorId(vendor); + DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = ? WHERE id = ?", newVendorId, id); + } + updateMessages.add("Updated vendor name to \"" + vendor + "\"."); + } + // Manage category change. + if (!Objects.equals(category, currentCategoryName)) { + if (category == null || category.isBlank()) { + DbUtil.updateOne(conn, "UPDATE transaction SET category_id = NULL WHERE id = ?", id); + } else { + long newCategoryId = getOrCreateCategoryId(category); + DbUtil.updateOne(conn, "UPDATE transaction SET category_id = ? WHERE id = ?", newCategoryId, id); + } + updateMessages.add("Updated category name to \"" + category + "\"."); + } + // Manage tags changes. + if (!currentTags.equals(tags)) { + Set tagsAdded = new HashSet<>(tags); + tagsAdded.removeAll(currentTags); + Set tagsRemoved = new HashSet<>(currentTags); + tagsRemoved.removeAll(tags); + + for (var t : tagsRemoved) removeTag(id, t); + for (var t : tagsAdded) addTag(id, t); + + if (!tagsAdded.isEmpty()) { + updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded)); + } + if (!tagsRemoved.isEmpty()) { + updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved)); + } + } // Manage attachments changes. List removedAttachments = new ArrayList<>(currentAttachments); removedAttachments.removeAll(existingAttachments); @@ -214,6 +362,8 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem insertAttachmentLink(tx.id, attachment.id); updateMessages.add("Added attachment \"" + attachment.getFilename() + "\"."); } + + // Add a text history item to any linked accounts detailing the changes. String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages); var historyRepo = new JdbcAccountHistoryItemRepository(conn); linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); @@ -226,16 +376,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem conn.close(); } - public static Transaction parseTransaction(ResultSet rs) throws SQLException { - return new Transaction( - rs.getLong("id"), - DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")), - rs.getBigDecimal("amount"), - Currency.getInstance(rs.getString("currency")), - rs.getString("description") - ); - } - private void insertAttachmentLink(long transactionId, long attachmentId) { DbUtil.insertOne( conn, @@ -243,4 +383,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem List.of(transactionId, attachmentId) ); } + + private long getTagId(String name) { + return DbUtil.findOne( + conn, + "SELECT id FROM transaction_tag WHERE name = ?", + List.of(name), + rs -> rs.getLong(1) + ).orElse(-1L); + } + + private void removeTag(long transactionId, String tag) { + long id = getTagId(tag); + if (id != -1) { + DbUtil.update(conn, "DELETE FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", transactionId, id); + } + } + + private void addTag(long transactionId, String tag) { + long id = getOrCreateTagId(tag); + boolean exists = DbUtil.count( + conn, + "SELECT COUNT(tag_id) FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", + transactionId, + id + ) > 0; + if (!exists) { + DbUtil.insertOne( + conn, + "INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)", + transactionId, + id + ); + } + } + + public static Transaction parseTransaction(ResultSet rs) throws SQLException { + return new Transaction( + rs.getLong("id"), + DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")), + rs.getBigDecimal("amount"), + Currency.getInstance(rs.getString("currency")), + rs.getString("description"), + rs.getObject("vendor_id", Long.class), + rs.getObject("category_id", Long.class) + ); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java new file mode 100644 index 0000000..25ef9ca --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java @@ -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 findById(long id) { + return DbUtil.findById( + conn, + "SELECT * FROM transaction_vendor WHERE id = ?", + id, + JdbcTransactionVendorRepository::parseVendor + ); + } + + @Override + public Optional findByName(String name) { + return DbUtil.findOne( + conn, + "SELECT * FROM transaction_vendor WHERE name = ?", + List.of(name), + JdbcTransactionVendorRepository::parseVendor + ); + } + + @Override + public List 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") + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java new file mode 100644 index 0000000..98b9325 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java @@ -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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java index 11d2cdb..94b2cd6 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java @@ -58,6 +58,17 @@ public final class DbUtil { return findAll(conn, query, pagination, Collections.emptyList(), mapper); } + public static long count(Connection conn, String query, Object... args) { + try (var stmt = conn.prepareStatement(query)) { + setArgs(stmt, args); + var rs = stmt.executeQuery(); + if (!rs.next()) throw new UncheckedSqlException("No count result available."); + return rs.getLong(1); + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + } + public static Optional findOne(Connection conn, String query, List args, ResultSetMapper mapper) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); @@ -82,6 +93,10 @@ public final class DbUtil { } } + public static int update(Connection conn, String query, Object... args) { + return update(conn, query, List.of(args)); + } + public static void updateOne(Connection conn, String query, List args) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); @@ -92,19 +107,27 @@ public final class DbUtil { } } + public static void updateOne(Connection conn, String query, Object... args) { + updateOne(conn, query, List.of(args)); + } + public static long insertOne(Connection conn, String query, List args) { try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { setArgs(stmt, args); int result = stmt.executeUpdate(); if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row."); var rs = stmt.getGeneratedKeys(); - rs.next(); + if (!rs.next()) throw new UncheckedSqlException("Insert query did not generate any keys."); return rs.getLong(1); } catch (SQLException e) { throw new UncheckedSqlException(e); } } + public static long insertOne(Connection conn, String query, Object... args) { + return insertOne(conn, query, List.of(args)); + } + public static Timestamp timestampFromUtcLDT(LocalDateTime utc) { return Timestamp.from(utc.toInstant(ZoneOffset.UTC)); } diff --git a/src/main/java/com/andrewlalis/perfin/model/Transaction.java b/src/main/java/com/andrewlalis/perfin/model/Transaction.java index 8f179ca..92588d8 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Transaction.java +++ b/src/main/java/com/andrewlalis/perfin/model/Transaction.java @@ -17,13 +17,17 @@ public class Transaction extends IdEntity { private final BigDecimal amount; private final Currency currency; private final String description; + private final Long vendorId; + private final Long categoryId; - public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) { + public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) { super(id); this.timestamp = timestamp; this.amount = amount; this.currency = currency; this.description = description; + this.vendorId = vendorId; + this.categoryId = categoryId; } public LocalDateTime getTimestamp() { @@ -42,6 +46,14 @@ public class Transaction extends IdEntity { return description; } + public Long getVendorId() { + return vendorId; + } + + public Long getCategoryId() { + return categoryId; + } + public MoneyValue getMoneyAmount() { return new MoneyValue(amount, currency); } diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java b/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java new file mode 100644 index 0000000..e18d86f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java @@ -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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java new file mode 100644 index 0000000..fa58745 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java @@ -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 + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java new file mode 100644 index 0000000..2dfd29f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java @@ -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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java b/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java new file mode 100644 index 0000000..af4130b --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java @@ -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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java index c0d9994..e78fb82 100644 --- a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java +++ b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java @@ -18,12 +18,14 @@ import java.util.function.Consumer; */ public class StartupSplashScreen extends Stage implements Consumer { private final List>> tasks; + private final boolean delayTasks; private boolean startupSuccessful = false; private final TextArea textArea = new TextArea(); - public StartupSplashScreen(List>> tasks) { + public StartupSplashScreen(List>> tasks, boolean delayTasks) { this.tasks = tasks; + this.delayTasks = delayTasks; setTitle("Starting Perfin..."); setResizable(false); initStyle(StageStyle.UNDECORATED); @@ -67,11 +69,7 @@ public class StartupSplashScreen extends Stage implements Consumer { */ private void runTasks() { Thread.ofVirtual().start(() -> { - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } + if (delayTasks) sleepOrThrowRE(1000); for (var task : tasks) { try { CompletableFuture future = new CompletableFuture<>(); @@ -84,27 +82,31 @@ public class StartupSplashScreen extends Stage implements Consumer { } }); future.join(); - Thread.sleep(500); + if (delayTasks) sleepOrThrowRE(500); } catch (Exception e) { accept("Startup failed: " + e.getMessage()); e.printStackTrace(System.err); - try { - Thread.sleep(5000); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); - } + sleepOrThrowRE(5000); Platform.runLater(this::close); return; } } accept("Startup successful!"); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } + if (delayTasks) sleepOrThrowRE(1000); startupSuccessful = true; Platform.runLater(this::close); }); } + + /** + * Helper method to sleep the current thread or throw a runtime exception. + * @param ms The number of milliseconds to sleep for. + */ + private static void sleepOrThrowRE(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } } diff --git a/src/main/resources/edit-transaction.fxml b/src/main/resources/edit-transaction.fxml index a0dc9b8..3b0333c 100644 --- a/src/main/resources/edit-transaction.fxml +++ b/src/main/resources/edit-transaction.fxml @@ -43,15 +43,39 @@ - + - + + + + + + + + + +