Add Transaction Properties #15
|
@ -58,7 +58,7 @@ public class PerfinApp extends Application {
|
||||||
PerfinApp::initAppDir,
|
PerfinApp::initAppDir,
|
||||||
c -> initMainScreen(stage, c),
|
c -> initMainScreen(stage, c),
|
||||||
PerfinApp::loadLastUsedProfile
|
PerfinApp::loadLastUsedProfile
|
||||||
));
|
), false);
|
||||||
splashScreen.showAndWait();
|
splashScreen.showAndWait();
|
||||||
if (splashScreen.isStartupSuccessful()) {
|
if (splashScreen.isStartupSuccessful()) {
|
||||||
stage.show();
|
stage.show();
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
package com.andrewlalis.perfin.control;
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.data.pagination.Sort;
|
import com.andrewlalis.perfin.data.pagination.Sort;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.*;
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
||||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
|
@ -15,10 +17,16 @@ import com.andrewlalis.perfin.view.component.validation.validators.PredicateVali
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.property.Property;
|
import javafx.beans.property.Property;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.event.ActionEvent;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.control.*;
|
import javafx.scene.control.*;
|
||||||
|
import javafx.scene.input.KeyCode;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@ -27,10 +35,7 @@ import java.nio.file.Path;
|
||||||
import java.time.DateTimeException;
|
import java.time.DateTimeException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Collections;
|
import java.util.*;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.Currency;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
@ -49,6 +54,13 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
@FXML public AccountSelectionBox debitAccountSelector;
|
@FXML public AccountSelectionBox debitAccountSelector;
|
||||||
@FXML public AccountSelectionBox creditAccountSelector;
|
@FXML public AccountSelectionBox creditAccountSelector;
|
||||||
|
|
||||||
|
@FXML public ComboBox<String> vendorComboBox;
|
||||||
|
@FXML public ComboBox<String> categoryComboBox;
|
||||||
|
@FXML public ComboBox<String> tagsComboBox;
|
||||||
|
@FXML public Button addTagButton;
|
||||||
|
@FXML public VBox tagsVBox;
|
||||||
|
private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
|
||||||
|
|
||||||
@FXML public FileSelectionArea attachmentsSelectionArea;
|
@FXML public FileSelectionArea attachmentsSelectionArea;
|
||||||
|
|
||||||
@FXML public Button saveButton;
|
@FXML public Button saveButton;
|
||||||
|
@ -75,7 +87,207 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
|
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
|
||||||
debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
||||||
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
||||||
var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator<CreditAndDebitAccounts>()
|
var linkedAccountsValid = new ValidationApplier<>(getLinkedAccountsValidator())
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void save() {
|
||||||
|
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
|
||||||
|
BigDecimal amount = new BigDecimal(amountField.getText());
|
||||||
|
Currency currency = currencyChoiceBox.getValue();
|
||||||
|
String description = getSanitizedDescription();
|
||||||
|
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
||||||
|
String vendor = vendorComboBox.getValue();
|
||||||
|
String category = categoryComboBox.getValue();
|
||||||
|
Set<String> tags = new HashSet<>(selectedTags);
|
||||||
|
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
|
||||||
|
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
|
||||||
|
final long idToNavigate;
|
||||||
|
if (transaction == null) {
|
||||||
|
idToNavigate = Profile.getCurrent().dataSource().mapRepo(
|
||||||
|
TransactionRepository.class,
|
||||||
|
repo -> repo.insert(
|
||||||
|
utcTimestamp,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
description,
|
||||||
|
linkedAccounts,
|
||||||
|
vendor,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
newAttachmentPaths
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Profile.getCurrent().dataSource().useRepo(
|
||||||
|
TransactionRepository.class,
|
||||||
|
repo -> repo.update(
|
||||||
|
transaction.id,
|
||||||
|
utcTimestamp,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
description,
|
||||||
|
linkedAccounts,
|
||||||
|
vendor,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
existingAttachments,
|
||||||
|
newAttachmentPaths
|
||||||
|
)
|
||||||
|
);
|
||||||
|
idToNavigate = transaction.id;
|
||||||
|
}
|
||||||
|
router.replace("transactions", new TransactionsViewController.RouteContext(idToNavigate));
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML public void cancel() {
|
||||||
|
router.navigateBackAndClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
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));
|
||||||
|
amountField.setText(null);
|
||||||
|
descriptionField.setText(null);
|
||||||
|
} else {
|
||||||
|
titleLabel.setText("Edit Transaction #" + transaction.id);
|
||||||
|
timestampField.setText(DateUtil.formatUTCAsLocal(transaction.getTimestamp()));
|
||||||
|
amountField.setText(CurrencyUtil.formatMoneyAsBasicNumber(transaction.getMoneyAmount()));
|
||||||
|
descriptionField.setText(transaction.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch some account-specific data.
|
||||||
|
container.setDisable(true);
|
||||||
|
DataSource ds = Profile.getCurrent().dataSource();
|
||||||
|
Thread.ofVirtual().start(() -> {
|
||||||
|
try (
|
||||||
|
var accountRepo = ds.getAccountRepository();
|
||||||
|
var transactionRepo = ds.getTransactionRepository();
|
||||||
|
var vendorRepo = ds.getTransactionVendorRepository();
|
||||||
|
var categoryRepo = ds.getTransactionCategoryRepository()
|
||||||
|
) {
|
||||||
|
// First fetch all the data.
|
||||||
|
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
|
||||||
|
.sorted(Comparator.comparing(Currency::getCurrencyCode))
|
||||||
|
.toList();
|
||||||
|
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
||||||
|
final List<Attachment> attachments;
|
||||||
|
final List<String> availableTags = transactionRepo.findAllTags();
|
||||||
|
final List<String> tags;
|
||||||
|
final CreditAndDebitAccounts linkedAccounts;
|
||||||
|
final String vendorName;
|
||||||
|
final String categoryName;
|
||||||
|
if (transaction == null) {
|
||||||
|
attachments = Collections.emptyList();
|
||||||
|
tags = Collections.emptyList();
|
||||||
|
linkedAccounts = new CreditAndDebitAccounts(null, null);
|
||||||
|
vendorName = null;
|
||||||
|
categoryName = null;
|
||||||
|
} else {
|
||||||
|
attachments = transactionRepo.findAttachments(transaction.id);
|
||||||
|
tags = transactionRepo.findTags(transaction.id);
|
||||||
|
linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
|
||||||
|
if (transaction.getVendorId() != null) {
|
||||||
|
vendorName = vendorRepo.findById(transaction.getVendorId())
|
||||||
|
.map(TransactionVendor::getName).orElse(null);
|
||||||
|
} else {
|
||||||
|
vendorName = null;
|
||||||
|
}
|
||||||
|
if (transaction.getCategoryId() != null) {
|
||||||
|
categoryName = categoryRepo.findById(transaction.getCategoryId())
|
||||||
|
.map(TransactionCategory::getName).orElse(null);
|
||||||
|
} else {
|
||||||
|
categoryName = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
|
||||||
|
final List<TransactionCategory> availableCategories = categoryRepo.findAll();
|
||||||
|
// Then make updates to the view.
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
currencyChoiceBox.getItems().setAll(currencies);
|
||||||
|
creditAccountSelector.setAccounts(accounts);
|
||||||
|
debitAccountSelector.setAccounts(accounts);
|
||||||
|
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) {
|
||||||
|
currencyChoiceBox.getSelectionModel().selectFirst();
|
||||||
|
creditAccountSelector.select(null);
|
||||||
|
debitAccountSelector.select(null);
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()));
|
||||||
|
router.navigateBackAndClear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreditAndDebitAccounts getSelectedAccounts() {
|
||||||
|
return new CreditAndDebitAccounts(
|
||||||
|
creditAccountSelector.getValue(),
|
||||||
|
debitAccountSelector.getValue()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PredicateValidator<CreditAndDebitAccounts> getLinkedAccountsValidator() {
|
||||||
|
return new PredicateValidator<CreditAndDebitAccounts>()
|
||||||
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
|
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
|
||||||
.addPredicate(
|
.addPredicate(
|
||||||
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
|
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
|
||||||
|
@ -94,124 +306,6 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
(!accounts.hasDebit() || !accounts.debitAccount().isArchived())
|
(!accounts.hasDebit() || !accounts.debitAccount().isArchived())
|
||||||
),
|
),
|
||||||
"Linked accounts must not be archived."
|
"Linked accounts must not be archived."
|
||||||
)
|
|
||||||
).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty);
|
|
||||||
|
|
||||||
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
|
|
||||||
saveButton.disableProperty().bind(formValid.not());
|
|
||||||
}
|
|
||||||
|
|
||||||
@FXML public void save() {
|
|
||||||
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
|
|
||||||
BigDecimal amount = new BigDecimal(amountField.getText());
|
|
||||||
Currency currency = currencyChoiceBox.getValue();
|
|
||||||
String description = getSanitizedDescription();
|
|
||||||
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
|
|
||||||
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
|
|
||||||
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
|
|
||||||
final long idToNavigate;
|
|
||||||
if (transaction == null) {
|
|
||||||
idToNavigate = Profile.getCurrent().dataSource().mapRepo(
|
|
||||||
TransactionRepository.class,
|
|
||||||
repo -> repo.insert(
|
|
||||||
utcTimestamp,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
description,
|
|
||||||
linkedAccounts,
|
|
||||||
newAttachmentPaths
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Profile.getCurrent().dataSource().useRepo(
|
|
||||||
TransactionRepository.class,
|
|
||||||
repo -> repo.update(
|
|
||||||
transaction.id,
|
|
||||||
utcTimestamp,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
description,
|
|
||||||
linkedAccounts,
|
|
||||||
existingAttachments,
|
|
||||||
newAttachmentPaths
|
|
||||||
)
|
|
||||||
);
|
|
||||||
idToNavigate = transaction.id;
|
|
||||||
}
|
|
||||||
router.replace("transactions", new TransactionsViewController.RouteContext(idToNavigate));
|
|
||||||
}
|
|
||||||
|
|
||||||
@FXML public void cancel() {
|
|
||||||
router.navigateBackAndClear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onRouteSelected(Object context) {
|
|
||||||
transaction = (Transaction) context;
|
|
||||||
|
|
||||||
if (transaction == null) {
|
|
||||||
titleLabel.setText("Create New Transaction");
|
|
||||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
|
||||||
amountField.setText(null);
|
|
||||||
descriptionField.setText(null);
|
|
||||||
} else {
|
|
||||||
titleLabel.setText("Edit Transaction #" + transaction.id);
|
|
||||||
timestampField.setText(DateUtil.formatUTCAsLocal(transaction.getTimestamp()));
|
|
||||||
amountField.setText(CurrencyUtil.formatMoneyAsBasicNumber(transaction.getMoneyAmount()));
|
|
||||||
descriptionField.setText(transaction.getDescription());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch some account-specific data.
|
|
||||||
container.setDisable(true);
|
|
||||||
Thread.ofVirtual().start(() -> {
|
|
||||||
try (
|
|
||||||
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
|
|
||||||
var transactionRepo = Profile.getCurrent().dataSource().getTransactionRepository()
|
|
||||||
) {
|
|
||||||
// First fetch all the data.
|
|
||||||
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
|
|
||||||
.sorted(Comparator.comparing(Currency::getCurrencyCode))
|
|
||||||
.toList();
|
|
||||||
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
|
|
||||||
final List<Attachment> attachments;
|
|
||||||
final CreditAndDebitAccounts linkedAccounts;
|
|
||||||
if (transaction == null) {
|
|
||||||
attachments = Collections.emptyList();
|
|
||||||
linkedAccounts = new CreditAndDebitAccounts(null, null);
|
|
||||||
} else {
|
|
||||||
attachments = transactionRepo.findAttachments(transaction.id);
|
|
||||||
linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
|
|
||||||
}
|
|
||||||
// Then make updates to the view.
|
|
||||||
Platform.runLater(() -> {
|
|
||||||
creditAccountSelector.setAccounts(accounts);
|
|
||||||
debitAccountSelector.setAccounts(accounts);
|
|
||||||
currencyChoiceBox.getItems().setAll(currencies);
|
|
||||||
attachmentsSelectionArea.clear();
|
|
||||||
attachmentsSelectionArea.addAttachments(attachments);
|
|
||||||
if (transaction == null) {
|
|
||||||
// TODO: Allow user to select a default currency.
|
|
||||||
currencyChoiceBox.getSelectionModel().selectFirst();
|
|
||||||
creditAccountSelector.select(null);
|
|
||||||
debitAccountSelector.select(null);
|
|
||||||
} else {
|
|
||||||
currencyChoiceBox.getSelectionModel().select(transaction.getCurrency());
|
|
||||||
creditAccountSelector.select(linkedAccounts.creditAccount());
|
|
||||||
debitAccountSelector.select(linkedAccounts.debitAccount());
|
|
||||||
}
|
|
||||||
container.setDisable(false);
|
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to get repositories.", e);
|
|
||||||
Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private CreditAndDebitAccounts getSelectedAccounts() {
|
|
||||||
return new CreditAndDebitAccounts(
|
|
||||||
creditAccountSelector.getValue(),
|
|
||||||
debitAccountSelector.getValue()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -106,6 +106,7 @@ public class ProfilesViewController {
|
||||||
log.info("Opening profile \"{}\".", name);
|
log.info("Opening profile \"{}\".", name);
|
||||||
try {
|
try {
|
||||||
Profile.setCurrent(PerfinApp.profileLoader.load(name));
|
Profile.setCurrent(PerfinApp.profileLoader.load(name));
|
||||||
|
ProfileLoader.saveLastProfile(name);
|
||||||
ProfilesStage.closeView();
|
ProfilesStage.closeView();
|
||||||
router.replace("accounts");
|
router.replace("accounts");
|
||||||
if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");
|
if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");
|
||||||
|
|
|
@ -30,6 +30,8 @@ public interface DataSource {
|
||||||
AccountRepository getAccountRepository();
|
AccountRepository getAccountRepository();
|
||||||
BalanceRecordRepository getBalanceRecordRepository();
|
BalanceRecordRepository getBalanceRecordRepository();
|
||||||
TransactionRepository getTransactionRepository();
|
TransactionRepository getTransactionRepository();
|
||||||
|
TransactionVendorRepository getTransactionVendorRepository();
|
||||||
|
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||||
AttachmentRepository getAttachmentRepository();
|
AttachmentRepository getAttachmentRepository();
|
||||||
AccountHistoryItemRepository getAccountHistoryItemRepository();
|
AccountHistoryItemRepository getAccountHistoryItemRepository();
|
||||||
|
|
||||||
|
@ -81,6 +83,8 @@ public interface DataSource {
|
||||||
AccountRepository.class, this::getAccountRepository,
|
AccountRepository.class, this::getAccountRepository,
|
||||||
BalanceRecordRepository.class, this::getBalanceRecordRepository,
|
BalanceRecordRepository.class, this::getBalanceRecordRepository,
|
||||||
TransactionRepository.class, this::getTransactionRepository,
|
TransactionRepository.class, this::getTransactionRepository,
|
||||||
|
TransactionVendorRepository.class, this::getTransactionVendorRepository,
|
||||||
|
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
|
||||||
AttachmentRepository.class, this::getAttachmentRepository,
|
AttachmentRepository.class, this::getAttachmentRepository,
|
||||||
AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository
|
AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface TransactionCategoryRepository extends Repository, AutoCloseable {
|
||||||
|
Optional<TransactionCategory> findById(long id);
|
||||||
|
Optional<TransactionCategory> findByName(String name);
|
||||||
|
List<TransactionCategory> findAllBaseCategories();
|
||||||
|
List<TransactionCategory> findAll();
|
||||||
|
long insert(long parentId, String name, Color color);
|
||||||
|
long insert(String name, Color color);
|
||||||
|
void deleteById(long id);
|
||||||
|
}
|
|
@ -21,6 +21,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
Currency currency,
|
Currency currency,
|
||||||
String description,
|
String description,
|
||||||
CreditAndDebitAccounts linkedAccounts,
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
String vendor,
|
||||||
|
String category,
|
||||||
|
Set<String> tags,
|
||||||
List<Path> attachments
|
List<Path> attachments
|
||||||
);
|
);
|
||||||
Optional<Transaction> findById(long id);
|
Optional<Transaction> findById(long id);
|
||||||
|
@ -31,6 +34,8 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||||
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
||||||
List<Attachment> findAttachments(long transactionId);
|
List<Attachment> findAttachments(long transactionId);
|
||||||
|
List<String> findTags(long transactionId);
|
||||||
|
List<String> findAllTags();
|
||||||
void delete(long transactionId);
|
void delete(long transactionId);
|
||||||
void update(
|
void update(
|
||||||
long id,
|
long id,
|
||||||
|
@ -39,6 +44,9 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
Currency currency,
|
Currency currency,
|
||||||
String description,
|
String description,
|
||||||
CreditAndDebitAccounts linkedAccounts,
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
String vendor,
|
||||||
|
String category,
|
||||||
|
Set<String> tags,
|
||||||
List<Attachment> existingAttachments,
|
List<Attachment> existingAttachments,
|
||||||
List<Path> newAttachmentPaths
|
List<Path> newAttachmentPaths
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface TransactionVendorRepository extends Repository, AutoCloseable {
|
||||||
|
Optional<TransactionVendor> findById(long id);
|
||||||
|
Optional<TransactionVendor> findByName(String name);
|
||||||
|
List<TransactionVendor> findAll();
|
||||||
|
long insert(String name, String description);
|
||||||
|
long insert(String name);
|
||||||
|
void deleteById(long id);
|
||||||
|
}
|
|
@ -49,6 +49,16 @@ public class JdbcDataSource implements DataSource {
|
||||||
return new JdbcTransactionRepository(getConnection(), contentDir);
|
return new JdbcTransactionRepository(getConnection(), contentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransactionVendorRepository getTransactionVendorRepository() {
|
||||||
|
return new JdbcTransactionVendorRepository(getConnection());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransactionCategoryRepository getTransactionCategoryRepository() {
|
||||||
|
return new JdbcTransactionCategoryRepository(getConnection());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AttachmentRepository getAttachmentRepository() {
|
public AttachmentRepository getAttachmentRepository() {
|
||||||
return new JdbcAttachmentRepository(getConnection(), contentDir);
|
return new JdbcAttachmentRepository(getConnection(), contentDir);
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.ColorUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository {
|
||||||
|
@Override
|
||||||
|
public Optional<TransactionCategory> findById(long id) {
|
||||||
|
return DbUtil.findById(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category WHERE id = ?",
|
||||||
|
id,
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<TransactionCategory> findByName(String name) {
|
||||||
|
return DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category WHERE name = ?",
|
||||||
|
List.of(name),
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionCategory> findAllBaseCategories() {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionCategory> findAll() {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_category ORDER BY parent_id ASC, name ASC",
|
||||||
|
JdbcTransactionCategoryRepository::parseCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long insert(long parentId, String name, Color color) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
|
||||||
|
List.of(parentId, name, ColorUtil.toHex(color))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long insert(String name, Color color) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_category (name, color) VALUES (?, ?)",
|
||||||
|
List.of(name, ColorUtil.toHex(color))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(long id) {
|
||||||
|
DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
|
||||||
|
return new TransactionCategory(
|
||||||
|
rs.getLong("id"),
|
||||||
|
rs.getObject("parent_id", Long.class),
|
||||||
|
rs.getString("name"),
|
||||||
|
Color.valueOf("#" + rs.getString("color"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,14 +8,14 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
|
||||||
import com.andrewlalis.perfin.model.*;
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.*;
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
@ -28,29 +28,104 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
Currency currency,
|
Currency currency,
|
||||||
String description,
|
String description,
|
||||||
CreditAndDebitAccounts linkedAccounts,
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
String vendor,
|
||||||
|
String category,
|
||||||
|
Set<String> tags,
|
||||||
List<Path> attachments
|
List<Path> attachments
|
||||||
) {
|
) {
|
||||||
return DbUtil.doTransaction(conn, () -> {
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
// 1. Insert the transaction.
|
Long vendorId = null;
|
||||||
long txId = DbUtil.insertOne(
|
if (vendor != null && !vendor.isBlank()) {
|
||||||
conn,
|
vendorId = getOrCreateVendorId(vendor.strip());
|
||||||
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
|
}
|
||||||
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description)
|
Long categoryId = null;
|
||||||
);
|
if (category != null && !category.isBlank()) {
|
||||||
// 2. Insert linked account entries.
|
categoryId = getOrCreateCategoryId(category.strip());
|
||||||
|
}
|
||||||
|
// Insert the transaction, using a custom JDBC statement to deal with nullables.
|
||||||
|
long txId;
|
||||||
|
try (var stmt = conn.prepareStatement(
|
||||||
|
"INSERT INTO transaction (timestamp, amount, currency, description, vendor_id, category_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
Statement.RETURN_GENERATED_KEYS
|
||||||
|
)) {
|
||||||
|
stmt.setTimestamp(1, DbUtil.timestampFromUtcLDT(utcTimestamp));
|
||||||
|
stmt.setBigDecimal(2, amount);
|
||||||
|
stmt.setString(3, currency.getCurrencyCode());
|
||||||
|
if (description != null && !description.isBlank()) {
|
||||||
|
stmt.setString(4, description.strip());
|
||||||
|
} else {
|
||||||
|
stmt.setNull(4, Types.VARCHAR);
|
||||||
|
}
|
||||||
|
if (vendorId != null) {
|
||||||
|
stmt.setLong(5, vendorId);
|
||||||
|
} else {
|
||||||
|
stmt.setNull(5, Types.BIGINT);
|
||||||
|
}
|
||||||
|
if (categoryId != null) {
|
||||||
|
stmt.setLong(6, categoryId);
|
||||||
|
} else {
|
||||||
|
stmt.setNull(6, Types.BIGINT);
|
||||||
|
}
|
||||||
|
int result = stmt.executeUpdate();
|
||||||
|
if (result != 1) throw new UncheckedSqlException("Transaction insert returned " + result);
|
||||||
|
var rs = stmt.getGeneratedKeys();
|
||||||
|
if (!rs.next()) throw new UncheckedSqlException("Transaction insert didn't generate any keys.");
|
||||||
|
txId = rs.getLong(1);
|
||||||
|
}
|
||||||
|
// Insert linked account entries.
|
||||||
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
|
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
|
||||||
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
|
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
|
||||||
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
|
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
|
||||||
// 3. Add attachments.
|
// Add attachments.
|
||||||
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||||
for (Path attachmentPath : attachments) {
|
for (Path attachmentPath : attachments) {
|
||||||
Attachment attachment = attachmentRepo.insert(attachmentPath);
|
Attachment attachment = attachmentRepo.insert(attachmentPath);
|
||||||
insertAttachmentLink(txId, attachment.id);
|
insertAttachmentLink(txId, attachment.id);
|
||||||
}
|
}
|
||||||
|
// Add tags.
|
||||||
|
for (String tag : tags) {
|
||||||
|
try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)")) {
|
||||||
|
long tagId = getOrCreateTagId(tag.toLowerCase().strip());
|
||||||
|
stmt.setLong(1, txId);
|
||||||
|
stmt.setLong(2, tagId);
|
||||||
|
stmt.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
return txId;
|
return txId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long getOrCreateVendorId(String name) {
|
||||||
|
var repo = new JdbcTransactionVendorRepository(conn);
|
||||||
|
TransactionVendor vendor = repo.findByName(name).orElse(null);
|
||||||
|
if (vendor != null) {
|
||||||
|
return vendor.id;
|
||||||
|
}
|
||||||
|
return repo.insert(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getOrCreateCategoryId(String name) {
|
||||||
|
var repo = new JdbcTransactionCategoryRepository(conn);
|
||||||
|
TransactionCategory category = repo.findByName(name).orElse(null);
|
||||||
|
if (category != null) {
|
||||||
|
return category.id;
|
||||||
|
}
|
||||||
|
return repo.insert(name, Color.WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long getOrCreateTagId(String name) {
|
||||||
|
Optional<Long> optionalId = DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM transaction_tag WHERE name = ?",
|
||||||
|
List.of(name),
|
||||||
|
rs -> rs.getLong(1)
|
||||||
|
);
|
||||||
|
return optionalId.orElseGet(() ->
|
||||||
|
DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Transaction> findById(long id) {
|
public Optional<Transaction> findById(long id) {
|
||||||
return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
|
return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
|
||||||
|
@ -147,6 +222,30 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> findTags(long transactionId) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT tt.name
|
||||||
|
FROM transaction_tag tt
|
||||||
|
LEFT JOIN transaction_tag_join ttj ON ttj.tag_id = tt.id
|
||||||
|
WHERE ttj.transaction_id = ?
|
||||||
|
ORDER BY tt.name ASC""",
|
||||||
|
List.of(transactionId),
|
||||||
|
rs -> rs.getString(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> findAllTags() {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT name FROM transaction_tag ORDER BY name ASC",
|
||||||
|
rs -> rs.getString(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(long transactionId) {
|
public void delete(long transactionId) {
|
||||||
DbUtil.doTransaction(conn, () -> {
|
DbUtil.doTransaction(conn, () -> {
|
||||||
|
@ -164,44 +263,93 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
Currency currency,
|
Currency currency,
|
||||||
String description,
|
String description,
|
||||||
CreditAndDebitAccounts linkedAccounts,
|
CreditAndDebitAccounts linkedAccounts,
|
||||||
|
String vendor,
|
||||||
|
String category,
|
||||||
|
Set<String> tags,
|
||||||
List<Attachment> existingAttachments,
|
List<Attachment> existingAttachments,
|
||||||
List<Path> newAttachmentPaths
|
List<Path> newAttachmentPaths
|
||||||
) {
|
) {
|
||||||
DbUtil.doTransaction(conn, () -> {
|
DbUtil.doTransaction(conn, () -> {
|
||||||
Transaction tx = findById(id).orElseThrow();
|
|
||||||
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
|
|
||||||
List<Attachment> currentAttachments = findAttachments(id);
|
|
||||||
var entryRepo = new JdbcAccountEntryRepository(conn);
|
var entryRepo = new JdbcAccountEntryRepository(conn);
|
||||||
var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||||
|
var vendorRepo = new JdbcTransactionVendorRepository(conn);
|
||||||
|
var categoryRepo = new JdbcTransactionCategoryRepository(conn);
|
||||||
|
|
||||||
|
Transaction tx = findById(id).orElseThrow();
|
||||||
|
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
|
||||||
|
TransactionVendor currentVendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElseThrow();
|
||||||
|
String currentVendorName = currentVendor == null ? null : currentVendor.getName();
|
||||||
|
TransactionCategory currentCategory = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElseThrow();
|
||||||
|
String currentCategoryName = currentCategory == null ? null : currentCategory.getName();
|
||||||
|
Set<String> currentTags = new HashSet<>(findTags(id));
|
||||||
|
List<Attachment> currentAttachments = findAttachments(id);
|
||||||
|
|
||||||
List<String> updateMessages = new ArrayList<>();
|
List<String> updateMessages = new ArrayList<>();
|
||||||
if (!tx.getTimestamp().equals(utcTimestamp)) {
|
if (!tx.getTimestamp().equals(utcTimestamp)) {
|
||||||
DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), id));
|
DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", DbUtil.timestampFromUtcLDT(utcTimestamp), id);
|
||||||
updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
|
updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
|
||||||
}
|
}
|
||||||
BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
|
BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
|
||||||
if (!tx.getAmount().equals(scaledAmount)) {
|
if (!tx.getAmount().equals(scaledAmount)) {
|
||||||
DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", List.of(scaledAmount, id));
|
DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", scaledAmount, id);
|
||||||
updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
|
updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
|
||||||
}
|
}
|
||||||
if (!tx.getCurrency().equals(currency)) {
|
if (!tx.getCurrency().equals(currency)) {
|
||||||
DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", List.of(currency.getCurrencyCode(), id));
|
DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", currency.getCurrencyCode(), id);
|
||||||
updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
|
updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
|
||||||
}
|
}
|
||||||
if (!Objects.equals(tx.getDescription(), description)) {
|
if (!Objects.equals(tx.getDescription(), description)) {
|
||||||
DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", List.of(description, id));
|
DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", description, id);
|
||||||
updateMessages.add("Updated description.");
|
updateMessages.add("Updated description.");
|
||||||
}
|
}
|
||||||
boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
|
boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
|
||||||
!tx.getCurrency().equals(currency) ||
|
!tx.getCurrency().equals(currency) ||
|
||||||
!tx.getTimestamp().equals(utcTimestamp) ||
|
!tx.getTimestamp().equals(utcTimestamp) ||
|
||||||
!currentLinkedAccounts.equals(linkedAccounts);
|
!currentLinkedAccounts.equals(linkedAccounts);
|
||||||
if (updateAccountEntries) {
|
if (shouldUpdateAccountEntries) {
|
||||||
// Delete all entries and re-write them correctly?
|
// Delete all entries and re-write them correctly.
|
||||||
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id));
|
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", id);
|
||||||
linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
|
linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
|
||||||
linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
|
linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
|
||||||
updateMessages.add("Updated linked accounts.");
|
updateMessages.add("Updated linked accounts.");
|
||||||
}
|
}
|
||||||
|
// Manage vendor change.
|
||||||
|
if (!Objects.equals(vendor, currentVendorName)) {
|
||||||
|
if (vendor == null || vendor.isBlank()) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = NULL WHERE id = ?", id);
|
||||||
|
} else {
|
||||||
|
long newVendorId = getOrCreateVendorId(vendor);
|
||||||
|
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = ? WHERE id = ?", newVendorId, id);
|
||||||
|
}
|
||||||
|
updateMessages.add("Updated vendor name to \"" + vendor + "\".");
|
||||||
|
}
|
||||||
|
// Manage category change.
|
||||||
|
if (!Objects.equals(category, currentCategoryName)) {
|
||||||
|
if (category == null || category.isBlank()) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = NULL WHERE id = ?", id);
|
||||||
|
} else {
|
||||||
|
long newCategoryId = getOrCreateCategoryId(category);
|
||||||
|
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = ? WHERE id = ?", newCategoryId, id);
|
||||||
|
}
|
||||||
|
updateMessages.add("Updated category name to \"" + category + "\".");
|
||||||
|
}
|
||||||
|
// Manage tags changes.
|
||||||
|
if (!currentTags.equals(tags)) {
|
||||||
|
Set<String> tagsAdded = new HashSet<>(tags);
|
||||||
|
tagsAdded.removeAll(currentTags);
|
||||||
|
Set<String> tagsRemoved = new HashSet<>(currentTags);
|
||||||
|
tagsRemoved.removeAll(tags);
|
||||||
|
|
||||||
|
for (var t : tagsRemoved) removeTag(id, t);
|
||||||
|
for (var t : tagsAdded) addTag(id, t);
|
||||||
|
|
||||||
|
if (!tagsAdded.isEmpty()) {
|
||||||
|
updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded));
|
||||||
|
}
|
||||||
|
if (!tagsRemoved.isEmpty()) {
|
||||||
|
updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved));
|
||||||
|
}
|
||||||
|
}
|
||||||
// Manage attachments changes.
|
// Manage attachments changes.
|
||||||
List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
|
List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
|
||||||
removedAttachments.removeAll(existingAttachments);
|
removedAttachments.removeAll(existingAttachments);
|
||||||
|
@ -214,6 +362,8 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
insertAttachmentLink(tx.id, attachment.id);
|
insertAttachmentLink(tx.id, attachment.id);
|
||||||
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
|
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a text history item to any linked accounts detailing the changes.
|
||||||
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
|
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
|
||||||
var historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
var historyRepo = new JdbcAccountHistoryItemRepository(conn);
|
||||||
linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
|
linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr));
|
||||||
|
@ -226,16 +376,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
conn.close();
|
conn.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
|
|
||||||
return new Transaction(
|
|
||||||
rs.getLong("id"),
|
|
||||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
|
||||||
rs.getBigDecimal("amount"),
|
|
||||||
Currency.getInstance(rs.getString("currency")),
|
|
||||||
rs.getString("description")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void insertAttachmentLink(long transactionId, long attachmentId) {
|
private void insertAttachmentLink(long transactionId, long attachmentId) {
|
||||||
DbUtil.insertOne(
|
DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
|
@ -243,4 +383,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
List.of(transactionId, attachmentId)
|
List.of(transactionId, attachmentId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long getTagId(String name) {
|
||||||
|
return DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM transaction_tag WHERE name = ?",
|
||||||
|
List.of(name),
|
||||||
|
rs -> rs.getLong(1)
|
||||||
|
).orElse(-1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeTag(long transactionId, String tag) {
|
||||||
|
long id = getTagId(tag);
|
||||||
|
if (id != -1) {
|
||||||
|
DbUtil.update(conn, "DELETE FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", transactionId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addTag(long transactionId, String tag) {
|
||||||
|
long id = getOrCreateTagId(tag);
|
||||||
|
boolean exists = DbUtil.count(
|
||||||
|
conn,
|
||||||
|
"SELECT COUNT(tag_id) FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?",
|
||||||
|
transactionId,
|
||||||
|
id
|
||||||
|
) > 0;
|
||||||
|
if (!exists) {
|
||||||
|
DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)",
|
||||||
|
transactionId,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
|
||||||
|
return new Transaction(
|
||||||
|
rs.getLong("id"),
|
||||||
|
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||||
|
rs.getBigDecimal("amount"),
|
||||||
|
Currency.getInstance(rs.getString("currency")),
|
||||||
|
rs.getString("description"),
|
||||||
|
rs.getObject("vendor_id", Long.class),
|
||||||
|
rs.getObject("category_id", Long.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository {
|
||||||
|
@Override
|
||||||
|
public Optional<TransactionVendor> findById(long id) {
|
||||||
|
return DbUtil.findById(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_vendor WHERE id = ?",
|
||||||
|
id,
|
||||||
|
JdbcTransactionVendorRepository::parseVendor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<TransactionVendor> findByName(String name) {
|
||||||
|
return DbUtil.findOne(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_vendor WHERE name = ?",
|
||||||
|
List.of(name),
|
||||||
|
JdbcTransactionVendorRepository::parseVendor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionVendor> findAll() {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_vendor ORDER BY name ASC",
|
||||||
|
JdbcTransactionVendorRepository::parseVendor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long insert(String name, String description) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
|
||||||
|
List.of(name, description)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long insert(String name) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO transaction_vendor (name) VALUES (?)",
|
||||||
|
List.of(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(long id) {
|
||||||
|
DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransactionVendor parseVendor(ResultSet rs) throws SQLException {
|
||||||
|
return new TransactionVendor(
|
||||||
|
rs.getLong("id"),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("description")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -58,6 +58,17 @@ public final class DbUtil {
|
||||||
return findAll(conn, query, pagination, Collections.emptyList(), mapper);
|
return findAll(conn, query, pagination, Collections.emptyList(), mapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static long count(Connection conn, String query, Object... args) {
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
setArgs(stmt, args);
|
||||||
|
var rs = stmt.executeQuery();
|
||||||
|
if (!rs.next()) throw new UncheckedSqlException("No count result available.");
|
||||||
|
return rs.getLong(1);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||||
try (var stmt = conn.prepareStatement(query)) {
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
setArgs(stmt, args);
|
setArgs(stmt, args);
|
||||||
|
@ -82,6 +93,10 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int update(Connection conn, String query, Object... args) {
|
||||||
|
return update(conn, query, List.of(args));
|
||||||
|
}
|
||||||
|
|
||||||
public static void updateOne(Connection conn, String query, List<Object> args) {
|
public static void updateOne(Connection conn, String query, List<Object> args) {
|
||||||
try (var stmt = conn.prepareStatement(query)) {
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
setArgs(stmt, args);
|
setArgs(stmt, args);
|
||||||
|
@ -92,19 +107,27 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void updateOne(Connection conn, String query, Object... args) {
|
||||||
|
updateOne(conn, query, List.of(args));
|
||||||
|
}
|
||||||
|
|
||||||
public static long insertOne(Connection conn, String query, List<Object> args) {
|
public static long insertOne(Connection conn, String query, List<Object> args) {
|
||||||
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
|
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
|
||||||
setArgs(stmt, args);
|
setArgs(stmt, args);
|
||||||
int result = stmt.executeUpdate();
|
int result = stmt.executeUpdate();
|
||||||
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
|
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
|
||||||
var rs = stmt.getGeneratedKeys();
|
var rs = stmt.getGeneratedKeys();
|
||||||
rs.next();
|
if (!rs.next()) throw new UncheckedSqlException("Insert query did not generate any keys.");
|
||||||
return rs.getLong(1);
|
return rs.getLong(1);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new UncheckedSqlException(e);
|
throw new UncheckedSqlException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static long insertOne(Connection conn, String query, Object... args) {
|
||||||
|
return insertOne(conn, query, List.of(args));
|
||||||
|
}
|
||||||
|
|
||||||
public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
|
public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
|
||||||
return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
|
return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,13 +17,17 @@ public class Transaction extends IdEntity {
|
||||||
private final BigDecimal amount;
|
private final BigDecimal amount;
|
||||||
private final Currency currency;
|
private final Currency currency;
|
||||||
private final String description;
|
private final String description;
|
||||||
|
private final Long vendorId;
|
||||||
|
private final Long categoryId;
|
||||||
|
|
||||||
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
|
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) {
|
||||||
super(id);
|
super(id);
|
||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
this.amount = amount;
|
this.amount = amount;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
|
this.vendorId = vendorId;
|
||||||
|
this.categoryId = categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getTimestamp() {
|
public LocalDateTime getTimestamp() {
|
||||||
|
@ -42,6 +46,14 @@ public class Transaction extends IdEntity {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getVendorId() {
|
||||||
|
return vendorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
public MoneyValue getMoneyAmount() {
|
public MoneyValue getMoneyAmount() {
|
||||||
return new MoneyValue(amount, currency);
|
return new MoneyValue(amount, currency);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,12 +18,14 @@ import java.util.function.Consumer;
|
||||||
*/
|
*/
|
||||||
public class StartupSplashScreen extends Stage implements Consumer<String> {
|
public class StartupSplashScreen extends Stage implements Consumer<String> {
|
||||||
private final List<ThrowableConsumer<Consumer<String>>> tasks;
|
private final List<ThrowableConsumer<Consumer<String>>> tasks;
|
||||||
|
private final boolean delayTasks;
|
||||||
private boolean startupSuccessful = false;
|
private boolean startupSuccessful = false;
|
||||||
|
|
||||||
private final TextArea textArea = new TextArea();
|
private final TextArea textArea = new TextArea();
|
||||||
|
|
||||||
public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks) {
|
public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks, boolean delayTasks) {
|
||||||
this.tasks = tasks;
|
this.tasks = tasks;
|
||||||
|
this.delayTasks = delayTasks;
|
||||||
setTitle("Starting Perfin...");
|
setTitle("Starting Perfin...");
|
||||||
setResizable(false);
|
setResizable(false);
|
||||||
initStyle(StageStyle.UNDECORATED);
|
initStyle(StageStyle.UNDECORATED);
|
||||||
|
@ -67,11 +69,7 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
|
||||||
*/
|
*/
|
||||||
private void runTasks() {
|
private void runTasks() {
|
||||||
Thread.ofVirtual().start(() -> {
|
Thread.ofVirtual().start(() -> {
|
||||||
try {
|
if (delayTasks) sleepOrThrowRE(1000);
|
||||||
Thread.sleep(1000);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
for (var task : tasks) {
|
for (var task : tasks) {
|
||||||
try {
|
try {
|
||||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||||
|
@ -84,27 +82,31 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
future.join();
|
future.join();
|
||||||
Thread.sleep(500);
|
if (delayTasks) sleepOrThrowRE(500);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
accept("Startup failed: " + e.getMessage());
|
accept("Startup failed: " + e.getMessage());
|
||||||
e.printStackTrace(System.err);
|
e.printStackTrace(System.err);
|
||||||
try {
|
sleepOrThrowRE(5000);
|
||||||
Thread.sleep(5000);
|
|
||||||
} catch (InterruptedException ex) {
|
|
||||||
throw new RuntimeException(ex);
|
|
||||||
}
|
|
||||||
Platform.runLater(this::close);
|
Platform.runLater(this::close);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
accept("Startup successful!");
|
accept("Startup successful!");
|
||||||
try {
|
if (delayTasks) sleepOrThrowRE(1000);
|
||||||
Thread.sleep(1000);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
startupSuccessful = true;
|
startupSuccessful = true;
|
||||||
Platform.runLater(this::close);
|
Platform.runLater(this::close);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to sleep the current thread or throw a runtime exception.
|
||||||
|
* @param ms The number of milliseconds to sleep for.
|
||||||
|
*/
|
||||||
|
private static void sleepOrThrowRE(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,15 +43,39 @@
|
||||||
|
|
||||||
<!-- Container for linked accounts -->
|
<!-- Container for linked accounts -->
|
||||||
<HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
|
<HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
|
||||||
<VBox>
|
<VBox HBox.hgrow="ALWAYS">
|
||||||
<Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
|
<Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
|
||||||
<AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
|
<AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
|
||||||
</VBox>
|
</VBox>
|
||||||
<VBox>
|
<VBox HBox.hgrow="ALWAYS">
|
||||||
<Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
|
<Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
|
||||||
<AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
|
<AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
|
||||||
</VBox>
|
</VBox>
|
||||||
</HBox>
|
</HBox>
|
||||||
|
|
||||||
|
<!-- Additional, mostly optional properties -->
|
||||||
|
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||||
|
<columnConstraints>
|
||||||
|
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||||
|
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||||
|
</columnConstraints>
|
||||||
|
|
||||||
|
<Label text="Vendor" labelFor="${vendorComboBox}" styleClass="bold-text"/>
|
||||||
|
<ComboBox fx:id="vendorComboBox" editable="true" maxWidth="Infinity"/>
|
||||||
|
|
||||||
|
<Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
|
||||||
|
<ComboBox fx:id="categoryComboBox" editable="true" maxWidth="Infinity"/>
|
||||||
|
|
||||||
|
<Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
|
||||||
|
<VBox maxWidth="Infinity">
|
||||||
|
<HBox styleClass="std-spacing">
|
||||||
|
<ComboBox fx:id="tagsComboBox" editable="true" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
|
||||||
|
<Button fx:id="addTagButton" text="Add" HBox.hgrow="NEVER"/>
|
||||||
|
</HBox>
|
||||||
|
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
|
||||||
|
</VBox>
|
||||||
|
</PropertiesPane>
|
||||||
|
|
||||||
<!-- Container for attachments -->
|
<!-- Container for attachments -->
|
||||||
<VBox styleClass="std-padding">
|
<VBox styleClass="std-padding">
|
||||||
<Label text="Attachments" styleClass="bold-text"/>
|
<Label text="Attachments" styleClass="bold-text"/>
|
||||||
|
|
Loading…
Reference in New Issue