Added ability to edit tags, vendor, and category of a transaction.
This commit is contained in:
parent
e17e2c55a5
commit
b9678313bf
|
@ -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();
|
||||
|
|
|
@ -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<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 Button saveButton;
|
||||
|
@ -75,7 +87,207 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
Property<CreditAndDebitAccounts> 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<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()) || !accounts.creditAccount().equals(accounts.debitAccount()),
|
||||
|
@ -94,124 +306,6 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
(!accounts.hasDebit() || !accounts.debitAccount().isArchived())
|
||||
),
|
||||
"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);
|
||||
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.");
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
String description,
|
||||
CreditAndDebitAccounts linkedAccounts,
|
||||
String vendor,
|
||||
String category,
|
||||
Set<String> tags,
|
||||
List<Path> attachments
|
||||
);
|
||||
Optional<Transaction> findById(long id);
|
||||
|
@ -31,6 +34,8 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
|||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
||||
List<Attachment> findAttachments(long transactionId);
|
||||
List<String> findTags(long transactionId);
|
||||
List<String> 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<String> tags,
|
||||
List<Attachment> existingAttachments,
|
||||
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);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
|
|
@ -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.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<String> tags,
|
||||
List<Path> 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<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
|
||||
public Optional<Transaction> 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<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
|
||||
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<String> tags,
|
||||
List<Attachment> existingAttachments,
|
||||
List<Path> newAttachmentPaths
|
||||
) {
|
||||
DbUtil.doTransaction(conn, () -> {
|
||||
Transaction tx = findById(id).orElseThrow();
|
||||
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
|
||||
List<Attachment> 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<String> currentTags = new HashSet<>(findTags(id));
|
||||
List<Attachment> currentAttachments = findAttachments(id);
|
||||
|
||||
List<String> 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<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.
|
||||
List<Attachment> 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
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) {
|
||||
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<Object> 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<Object> 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));
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
private final List<ThrowableConsumer<Consumer<String>>> tasks;
|
||||
private final boolean delayTasks;
|
||||
private boolean startupSuccessful = false;
|
||||
|
||||
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.delayTasks = delayTasks;
|
||||
setTitle("Starting Perfin...");
|
||||
setResizable(false);
|
||||
initStyle(StageStyle.UNDECORATED);
|
||||
|
@ -67,11 +69,7 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
|
|||
*/
|
||||
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<Void> future = new CompletableFuture<>();
|
||||
|
@ -84,27 +82,31 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
|
|||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,15 +43,39 @@
|
|||
|
||||
<!-- Container for linked accounts -->
|
||||
<HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
|
||||
<VBox>
|
||||
<VBox HBox.hgrow="ALWAYS">
|
||||
<Label text="Debited Account" labelFor="${debitAccountSelector}" styleClass="bold-text"/>
|
||||
<AccountSelectionBox fx:id="debitAccountSelector" allowNone="true" showBalance="true"/>
|
||||
</VBox>
|
||||
<VBox>
|
||||
<VBox HBox.hgrow="ALWAYS">
|
||||
<Label text="Credited Account" labelFor="${creditAccountSelector}" styleClass="bold-text"/>
|
||||
<AccountSelectionBox fx:id="creditAccountSelector" allowNone="true" showBalance="true"/>
|
||||
</VBox>
|
||||
</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 -->
|
||||
<VBox styleClass="std-padding">
|
||||
<Label text="Attachments" styleClass="bold-text"/>
|
||||
|
|
Loading…
Reference in New Issue