Added transactions page, lots of utilities, and fixed account balance derivation formula.

This commit is contained in:
Andrew Lalis 2023-12-28 11:32:20 -05:00
parent b6e1481805
commit 14e1248b54
43 changed files with 1306 additions and 205 deletions

View File

@ -26,6 +26,7 @@ public class PerfinApp extends Application {
@Override
public void start(Stage stage) {
// TODO: Cleanup the splash screen logic!
SplashScreenStage splashStage = new SplashScreenStage("Loading", SceneUtil.load("/startup-splash-screen.fxml"));
splashStage.show();
defineRoutes();
@ -43,9 +44,15 @@ public class PerfinApp extends Application {
stage.setTitle("Perfin");
}
private static void mapResourceRoute(String route, String resource) {
router.map(route, PerfinApp.class.getResource(resource));
}
private static void defineRoutes() {
router.map("accounts", PerfinApp.class.getResource("/accounts-view.fxml"));
router.map("account", PerfinApp.class.getResource("/account-view.fxml"));
router.map("edit-account", PerfinApp.class.getResource("/edit-account.fxml"));
mapResourceRoute("accounts", "/accounts-view.fxml");
mapResourceRoute("account", "/account-view.fxml");
mapResourceRoute("edit-account", "/edit-account.fxml");
mapResourceRoute("transactions", "/transactions-view.fxml");
mapResourceRoute("create-transaction", "/create-transaction.fxml");
}
}

View File

@ -1,43 +0,0 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.perfin.model.Account;
import javafx.application.Platform;
import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
import static com.andrewlalis.perfin.PerfinApp.router;
public class AccountTileController {
private Account account;
@FXML
public VBox container;
@FXML
public Label accountNumberLabel;
@FXML
public Label accountBalanceLabel;
@FXML
public VBox accountNameBox;
@FXML
public Label accountNameLabel;
@FXML
public void initialize() {
ObservableValue<Boolean> accountNameTextPresent = accountNameLabel.textProperty().map(t -> t != null && !t.isBlank());
accountNameBox.visibleProperty().bind(accountNameTextPresent);
accountNameBox.managedProperty().bind(accountNameTextPresent);
}
public void setAccount(Account account) {
this.account = account;
Platform.runLater(() -> {
accountNumberLabel.setText(account.getAccountNumber());
accountBalanceLabel.setText(account.getCurrency().getSymbol());
accountNameLabel.setText(account.getName());
container.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> router.navigate("account", account));
});
}
}

View File

@ -1,6 +1,7 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile;
import javafx.fxml.FXML;
@ -24,6 +25,8 @@ public class AccountViewController implements RouteSelectionListener {
public TextField accountCreatedAtField;
@FXML
public TextField accountCurrencyField;
@FXML
public TextField accountBalanceField;
@Override
public void onRouteSelected(Object context) {
@ -33,7 +36,8 @@ public class AccountViewController implements RouteSelectionListener {
accountNameField.setText(account.getName());
accountNumberField.setText(account.getAccountNumber());
accountCurrencyField.setText(account.getCurrency().getDisplayName());
accountCreatedAtField.setText(account.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
accountCreatedAtField.setText(account.getCreatedAt().format(DateUtil.DEFAULT_DATETIME_FORMAT));
Profile.getCurrent().getDataSource().getAccountBalanceText(account, accountBalanceField::setText);
}
@FXML

View File

@ -1,52 +1,41 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.SceneUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.control.component.AccountTile;
import com.andrewlalis.perfin.data.CurrencyUtil;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.BindingUtil;
import javafx.beans.property.SimpleListProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.FlowPane;
import java.util.function.Consumer;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import static com.andrewlalis.perfin.PerfinApp.router;
public class AccountsViewController implements RouteSelectionListener {
@FXML
public BorderPane mainContainer;
@FXML
public FlowPane accountsPane;
@FXML
public Label noAccountsLabel;
@FXML
public Label totalLabel;
private final ObservableList<Account> accountsList = FXCollections.observableArrayList();
private final BooleanProperty noAccounts = new SimpleBooleanProperty(false);
@FXML
public void initialize() {
// Sync the size of the accounts pane to its container.
accountsPane.minWidthProperty().bind(mainContainer.widthProperty());
accountsPane.prefWidthProperty().bind(mainContainer.widthProperty());
accountsPane.prefWrapLengthProperty().bind(mainContainer.widthProperty());
accountsPane.maxWidthProperty().bind(mainContainer.widthProperty());
// Map each account in our list to an account tile element.
BindingUtil.mapContent(accountsPane.getChildren(), accountsList, account -> SceneUtil.loadNode(
"/account-tile.fxml",
(Consumer<AccountTileController>) c -> c.setAccount(account)
));
// Show the "no accounts" label when the accountsList is empty.
var listProp = new SimpleListProperty<>(accountsList);
noAccountsLabel.visibleProperty().bind(listProp.emptyProperty());
noAccountsLabel.managedProperty().bind(noAccountsLabel.visibleProperty());
accountsPane.visibleProperty().bind(listProp.emptyProperty().not());
accountsPane.managedProperty().bind(accountsPane.visibleProperty());
noAccountsLabel.visibleProperty().bind(noAccounts);
noAccountsLabel.managedProperty().bind(noAccounts);
accountsPane.visibleProperty().bind(noAccounts.not());
accountsPane.managedProperty().bind(noAccounts.not());
}
@FXML
@ -61,7 +50,26 @@ public class AccountsViewController implements RouteSelectionListener {
public void refreshAccounts() {
Profile.whenLoaded(profile -> {
accountsList.setAll(profile.getDataSource().getAccountRepository().findAll());
Thread.ofVirtual().start(() -> {
profile.getDataSource().useAccountRepository(repo -> {
var page = repo.findAll(PageRequest.unpaged(Sort.asc("created_at")));
Platform.runLater(() -> {
accountsPane.getChildren().setAll(page.items().stream().map(AccountTile::new).toList());
});
});
});
// Compute grand totals!
Thread.ofVirtual().start(() -> {
var totals = profile.getDataSource().getCombinedAccountBalances();
StringBuilder sb = new StringBuilder("Totals: ");
for (var entry : totals.entrySet()) {
Currency cur = entry.getKey();
BigDecimal value = entry.getValue().setScale(cur.getDefaultFractionDigits(), RoundingMode.HALF_UP);
sb.append(cur.getCurrencyCode()).append(' ').append(CurrencyUtil.formatMoney(value, cur)).append(' ');
}
Platform.runLater(() -> totalLabel.setText(sb.toString().strip()));
});
});
}
}

View File

@ -0,0 +1,162 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import java.math.BigDecimal;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import static com.andrewlalis.perfin.PerfinApp.router;
public class CreateTransactionController implements RouteSelectionListener {
@FXML public TextField timestampField;
@FXML public Label timestampInvalidLabel;
@FXML public Label timestampFutureLabel;
@FXML public TextField amountField;
@FXML public ChoiceBox<Currency> currencyChoiceBox;
@FXML public TextArea descriptionField;
@FXML public ComboBox<Account> linkDebitAccountComboBox;
@FXML public ComboBox<Account> linkCreditAccountComboBox;
@FXML public Label linkedAccountsErrorLabel;
@FXML public void initialize() {
// Setup error field validation.
timestampInvalidLabel.managedProperty().bind(timestampInvalidLabel.visibleProperty());
timestampFutureLabel.managedProperty().bind(timestampFutureLabel.visibleProperty());
timestampField.textProperty().addListener((observable, oldValue, newValue) -> {
LocalDateTime parsedTimestamp = parseTimestamp();
timestampInvalidLabel.setVisible(parsedTimestamp == null);
timestampFutureLabel.setVisible(parsedTimestamp != null && parsedTimestamp.isAfter(LocalDateTime.now()));
});
linkedAccountsErrorLabel.managedProperty().bind(linkedAccountsErrorLabel.visibleProperty());
linkedAccountsErrorLabel.visibleProperty().bind(linkedAccountsErrorLabel.textProperty().isNotEmpty());
linkDebitAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated());
linkCreditAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated());
// Update the lists of accounts available for linking based on the selected currency.
var cellFactory = new AccountComboBoxCellFactory();
linkDebitAccountComboBox.setCellFactory(cellFactory);
linkDebitAccountComboBox.setButtonCell(cellFactory.call(null));
linkCreditAccountComboBox.setCellFactory(cellFactory);
linkCreditAccountComboBox.setButtonCell(cellFactory.call(null));
currencyChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> {
updateLinkAccountComboBoxes(newValue);
});
}
@FXML public void save() {
// TODO: Validate data!
LocalDateTime timestamp = parseTimestamp();
BigDecimal amount = new BigDecimal(amountField.getText());
Currency currency = currencyChoiceBox.getValue();
String description = descriptionField.getText().strip();
Map<Long, AccountEntry.Type> affectedAccounts = getSelectedAccounts();
Transaction transaction = new Transaction(timestamp, amount, currency, description);
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
repo.insert(transaction, affectedAccounts);
});
router.navigateBackAndClear();
}
@FXML public void cancel() {
router.navigateBackAndClear();
}
@Override
public void onRouteSelected(Object context) {
resetForm();
}
private void resetForm() {
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
amountField.setText("0");
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
var currencies = repo.findAllUsedCurrencies().stream()
.sorted(Comparator.comparing(Currency::getCurrencyCode))
.toList();
Platform.runLater(() -> {
currencyChoiceBox.getItems().setAll(currencies);
// TODO: cache most-recent currency for the app (maybe for different contexts).
currencyChoiceBox.getSelectionModel().selectFirst();
});
});
});
}
private Map<Long, AccountEntry.Type> getSelectedAccounts() {
Account debitAccount = linkDebitAccountComboBox.getValue();
Account creditAccount = linkCreditAccountComboBox.getValue();
Map<Long, AccountEntry.Type> accountsMap = new HashMap<>();
if (debitAccount != null) accountsMap.put(debitAccount.getId(), AccountEntry.Type.DEBIT);
if (creditAccount != null) accountsMap.put(creditAccount.getId(), AccountEntry.Type.CREDIT);
return accountsMap;
}
private LocalDateTime parseTimestamp() {
List<DateTimeFormatter> formatters = List.of(
DateTimeFormatter.ISO_LOCAL_DATE_TIME,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("d/M/yyyy H:mm:ss")
);
for (var formatter : formatters) {
try {
return formatter.parse(timestampField.getText(), LocalDateTime::from);
} catch (DateTimeException e) {
// Ignore.
}
}
return null;
}
private void updateLinkAccountComboBoxes(Currency currency) {
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
List<Account> availableAccounts = new ArrayList<>();
if (currency != null) availableAccounts.addAll(repo.findAllByCurrency(currency));
availableAccounts.add(null);
Platform.runLater(() -> {
linkDebitAccountComboBox.getItems().clear();
linkDebitAccountComboBox.getItems().addAll(availableAccounts);
linkDebitAccountComboBox.getSelectionModel().selectLast();
linkDebitAccountComboBox.getButtonCell().updateIndex(availableAccounts.size() - 1);
linkCreditAccountComboBox.getItems().clear();
linkCreditAccountComboBox.getItems().addAll(availableAccounts);
linkCreditAccountComboBox.getSelectionModel().selectLast();
linkCreditAccountComboBox.getButtonCell().updateIndex(availableAccounts.size() - 1);
});
});
});
}
private void onLinkedAccountsUpdated() {
Account debitAccount = linkDebitAccountComboBox.getValue();
Account creditAccount = linkCreditAccountComboBox.getValue();
if (debitAccount == null && creditAccount == null) {
linkedAccountsErrorLabel.setText("At least one credit or debit account must be linked to the transaction for it to have any effect.");
} else if (debitAccount != null && creditAccount != null && debitAccount.getId() == creditAccount.getId()) {
linkedAccountsErrorLabel.setText("Cannot link the same account to both credit and debit.");
} else {
linkedAccountsErrorLabel.setText(null);
}
}
}

View File

@ -3,19 +3,24 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.Profile;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.fxml.FXML;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Optional;
import static com.andrewlalis.perfin.PerfinApp.router;
public class EditAccountController implements RouteSelectionListener {
private Account account;
private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false);
@FXML
public Label titleLabel;
@FXML
@ -26,10 +31,10 @@ public class EditAccountController implements RouteSelectionListener {
public ComboBox<Currency> accountCurrencyComboBox;
@FXML
public ChoiceBox<AccountType> accountTypeChoiceBox;
private boolean editingNewAccount() {
return account == null;
}
@FXML
public VBox initialBalanceContent;
@FXML
public TextField initialBalanceField;
@FXML
public void initialize() {
@ -47,12 +52,16 @@ public class EditAccountController implements RouteSelectionListener {
accountTypeChoiceBox.getItems().add(AccountType.SAVINGS);
accountTypeChoiceBox.getItems().add(AccountType.CREDIT_CARD);
accountTypeChoiceBox.getSelectionModel().select(AccountType.CHECKING);
initialBalanceContent.visibleProperty().bind(creatingNewAccount);
initialBalanceContent.managedProperty().bind(creatingNewAccount);
}
@Override
public void onRouteSelected(Object context) {
this.account = (Account) context;
if (editingNewAccount()) {
creatingNewAccount.set(account == null);
if (creatingNewAccount.get()) {
titleLabel.setText("Editing New Account");
} else {
titleLabel.setText("Editing Account: " + account.getName());
@ -62,19 +71,33 @@ public class EditAccountController implements RouteSelectionListener {
@FXML
public void save() {
try (var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository()) {
if (editingNewAccount()) {
try (
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
var balanceRepo = Profile.getCurrent().getDataSource().getBalanceRecordRepository()
) {
if (creatingNewAccount.get()) {
String name = accountNameField.getText().strip();
String number = accountNumberField.getText().strip();
AccountType type = accountTypeChoiceBox.getValue();
Currency currency = accountCurrencyComboBox.getValue();
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
Alert confirm = new Alert(
Alert.AlertType.CONFIRMATION,
"Are you sure you want to create this account?"
);
Optional<ButtonType> result = confirm.showAndWait();
boolean success = result.isPresent() && result.get().equals(ButtonType.OK);
if (success) {
Account newAccount = new Account(type, number, name, currency);
long id = accountRepo.insert(newAccount);
Account savedAccount = accountRepo.findById(id).orElseThrow();
balanceRepo.insert(new BalanceRecord(id, initialBalance, savedAccount.getCurrency()));
// Once we create the new account, go to the account.
router.getHistory().clear();
router.navigate("account", savedAccount);
}
} else {
System.out.println("Updating account " + account.getName());
account.setName(accountNameField.getText().strip());
@ -97,11 +120,12 @@ public class EditAccountController implements RouteSelectionListener {
}
public void resetForm() {
if (account == null) {
if (creatingNewAccount.get()) {
accountNameField.setText("");
accountNumberField.setText("");
accountTypeChoiceBox.getSelectionModel().selectFirst();
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
initialBalanceField.setText(String.format("%.02f", 0f));
} else {
accountNameField.setText(account.getName());
accountNumberField.setText(account.getAccountNumber());

View File

@ -21,6 +21,7 @@ public class MainViewController {
public void initialize() {
AnchorPaneRouterView routerView = (AnchorPaneRouterView) router.getView();
mainContainer.setCenter(routerView.getAnchorPane());
// Set up a simple breadcrumb display in the top bar.
BindingUtil.mapContent(
breadcrumbHBox.getChildren(),
@ -37,12 +38,6 @@ public class MainViewController {
router.navigate("accounts");
}
@FXML
public void goToAccounts() {
router.getHistory().clear();
router.navigate("accounts");
}
@FXML
public void goBack() {
router.navigateBack();
@ -52,4 +47,16 @@ public class MainViewController {
public void goForward() {
router.navigateForward();
}
@FXML
public void goToAccounts() {
router.getHistory().clear();
router.navigate("accounts");
}
@FXML
public void goToTransactions() {
router.getHistory().clear();
router.navigate("transactions");
}
}

View File

@ -0,0 +1,37 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.control.component.TransactionTile;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.model.Profile;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.layout.VBox;
import static com.andrewlalis.perfin.PerfinApp.router;
public class TransactionsViewController implements RouteSelectionListener {
@FXML
public VBox transactionsVBox;
@Override
public void onRouteSelected(Object context) {
refreshTransactions();
}
@FXML
public void addTransaction() {
router.navigate("create-transaction");
}
private void refreshTransactions() {
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
var page = repo.findAll(PageRequest.unpaged(Sort.desc("timestamp")));
var components = page.items().stream().map(transaction -> new TransactionTile(transaction, this::refreshTransactions)).toList();
Platform.runLater(() -> transactionsVBox.getChildren().setAll(components));
});
});
}
}

View File

@ -0,0 +1,101 @@
package com.andrewlalis.perfin.control.component;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.Profile;
import javafx.beans.value.ObservableValue;
import javafx.scene.control.Label;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import static com.andrewlalis.perfin.PerfinApp.router;
/**
* A compact tile that displays information about an account.
*/
public class AccountTile extends BorderPane {
public final Label accountNumberLabel = newPropertyValue();
public final Label accountBalanceLabel = newPropertyValue();
public final VBox accountNameBox = new VBox();
public final Label accountNameLabel = newPropertyValue();
private static final Map<AccountType, Color> ACCOUNT_TYPE_COLORS = Map.of(
AccountType.CHECKING, Color.rgb(214, 222, 255),
AccountType.SAVINGS, Color.rgb(219, 255, 214),
AccountType.CREDIT_CARD, Color.rgb(255, 250, 214)
);
public AccountTile(Account account) {
setPrefWidth(300.0);
setPrefHeight(100.0);
setStyle("""
-fx-border-color: lightgray;
-fx-border-width: 1px;
-fx-border-style: solid;
-fx-border-radius: 5px;
-fx-padding: 5px;
-fx-cursor: hand;
""");
Color color = ACCOUNT_TYPE_COLORS.get(account.getType());
var fill = new BackgroundFill(color, new CornerRadii(3.0), null);
setBackground(new Background(fill));
accountNameBox.getChildren().setAll(
newPropertyLabel("Account Name"),
accountNameLabel
);
Label currencyLabel = new Label(account.getCurrency().getCurrencyCode());
Label typeLabel = new Label(account.getType().toString() + " Account");
HBox footerHBox = new HBox(currencyLabel, typeLabel);
footerHBox.setStyle("-fx-font-size: x-small; -fx-spacing: 3px;");
setBottom(footerHBox);
setCenter(new VBox(
newPropertyLabel("Account Number"),
accountNumberLabel,
newPropertyLabel("Account Balance"),
accountBalanceLabel,
accountNameBox
));
ObservableValue<Boolean> accountNameTextPresent = accountNameLabel.textProperty().map(t -> t != null && !t.isBlank());
accountNameBox.visibleProperty().bind(accountNameTextPresent);
accountNameBox.managedProperty().bind(accountNameTextPresent);
accountNumberLabel.setText(account.getAccountNumber());
accountNameLabel.setText(account.getName());
accountBalanceLabel.setText("Loading balance...");
accountBalanceLabel.setDisable(true);
Profile.getCurrent().getDataSource().getAccountBalanceText(account, balanceText -> {
accountBalanceLabel.setText(balanceText);
accountBalanceLabel.setDisable(false);
});
this.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
router.navigate("account", account);
});
}
private static Label newPropertyLabel(String text) {
Label lbl = new Label(text);
lbl.setStyle("""
-fx-font-weight: bold;
""");
return lbl;
}
private static Label newPropertyValue() {
Label lbl = new Label();
lbl.setStyle("""
-fx-font-family: monospace;
-fx-font-size: large;
""");
return lbl;
}
}

View File

@ -0,0 +1,120 @@
package com.andrewlalis.perfin.control.component;
import com.andrewlalis.perfin.data.CurrencyUtil;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.util.Pair;
import java.util.concurrent.CompletableFuture;
import static com.andrewlalis.perfin.PerfinApp.router;
/**
* A tile that displays a transaction's basic information.
*/
public class TransactionTile extends BorderPane {
public TransactionTile(Transaction transaction, Runnable refresh) {
setStyle("""
-fx-border-color: lightgray;
-fx-border-width: 1px;
-fx-border-style: solid;
-fx-border-radius: 5px;
-fx-padding: 5px;
-fx-max-width: 500px;
""");
setTop(getHeader(transaction));
setCenter(getBody(transaction));
setBottom(getFooter(transaction, refresh));
}
private Node getHeader(Transaction transaction) {
Label currencyLabel = new Label(CurrencyUtil.formatMoney(transaction.getAmount(), transaction.getCurrency()));
currencyLabel.setStyle("-fx-font-family: monospace;");
HBox headerHBox = new HBox(
currencyLabel
);
headerHBox.setStyle("""
-fx-spacing: 3px;
""");
return headerHBox;
}
private Node getBody(Transaction transaction) {
Label descriptionLabel = new Label(transaction.getDescription());
descriptionLabel.setWrapText(true);
VBox bodyVBox = new VBox(
descriptionLabel
);
getCreditAndDebitAccounts(transaction).thenAccept(accounts -> {
Account creditAccount = accounts.getKey();
Account debitAccount = accounts.getValue();
if (creditAccount != null) {
Hyperlink link = new Hyperlink(creditAccount.getShortName());
link.setOnAction(event -> router.navigate("account", creditAccount));
TextFlow text = new TextFlow(new Text("Credited from"), link);
Platform.runLater(() -> bodyVBox.getChildren().add(text));
} if (debitAccount != null) {
Hyperlink link = new Hyperlink(debitAccount.getShortName());
link.setOnAction(event -> router.navigate("account", debitAccount));
TextFlow text = new TextFlow(new Text("Debited to"), link);
Platform.runLater(() -> bodyVBox.getChildren().add(text));
}
});
return bodyVBox;
}
private Node getFooter(Transaction transaction, Runnable refresh) {
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
Hyperlink deleteLink = new Hyperlink("Delete this transaction");
deleteLink.setOnAction(event -> {
var confirmResult = new Alert(Alert.AlertType.CONFIRMATION, "Are you sure you want to delete this transaction?").showAndWait();
if (confirmResult.isPresent() && confirmResult.get() == ButtonType.OK) {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
repo.delete(transaction.getId());
});
refresh.run();
}
});
HBox footerHBox = new HBox(
timestampLabel,
deleteLink
);
footerHBox.setStyle("""
-fx-spacing: 3px;
-fx-font-size: small;
""");
return footerHBox;
}
private CompletableFuture<Pair<Account, Account>> getCreditAndDebitAccounts(Transaction transaction) {
CompletableFuture<Pair<Account, Account>> cf = new CompletableFuture<>();
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
var entriesAndAccounts = repo.findEntriesWithAccounts(transaction.getId());
AccountEntry creditEntry = entriesAndAccounts.keySet().stream()
.filter(entry -> entry.getType() == AccountEntry.Type.CREDIT)
.findFirst().orElse(null);
AccountEntry debitEntry = entriesAndAccounts.keySet().stream()
.filter(entry -> entry.getType() == AccountEntry.Type.DEBIT)
.findFirst().orElse(null);
cf.complete(new Pair<>(entriesAndAccounts.get(creditEntry), entriesAndAccounts.get(debitEntry)));
});
});
return cf;
}
}

View File

@ -1,16 +1,28 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Account;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.util.Currency;
import java.util.List;
import java.util.Optional;
import java.util.Set;
public interface AccountRepository extends AutoCloseable {
long insert(Account account);
List<Account> findAll();
Page<Account> findAll(PageRequest pagination);
List<Account> findAllByCurrency(Currency currency);
Optional<Account> findById(long id);
BigDecimal deriveCurrentBalance(long id);
void update(Account account);
void delete(Account account);
BigDecimal deriveBalance(long id, Instant timestamp);
default BigDecimal deriveCurrentBalance(long id) {
return deriveBalance(id, Instant.now(Clock.systemUTC()));
}
Set<Currency> findAllUsedCurrencies();
}

View File

@ -3,5 +3,6 @@ package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.BalanceRecord;
public interface BalanceRecordRepository extends AutoCloseable {
long insert(BalanceRecord record);
BalanceRecord findLatestByAccountId(long accountId);
}

View File

@ -0,0 +1,17 @@
package com.andrewlalis.perfin.data;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.Currency;
public class CurrencyUtil {
public static String formatMoney(BigDecimal amount, Currency currency) {
NumberFormat nf = NumberFormat.getCurrencyInstance();
nf.setCurrency(currency);
nf.setMaximumFractionDigits(currency.getDefaultFractionDigits());
nf.setMinimumFractionDigits(currency.getDefaultFractionDigits());
BigDecimal displayValue = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
return nf.format(displayValue);
}
}

View File

@ -1,5 +1,18 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Account;
import javafx.application.Platform;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
public interface DataSource {
AccountRepository getAccountRepository();
default void useAccountRepository(ThrowableConsumer<AccountRepository> repoConsumer) {
@ -10,4 +23,34 @@ public interface DataSource {
default void useBalanceRecordRepository(ThrowableConsumer<BalanceRecordRepository> repoConsumer) {
DbUtil.useClosable(this::getBalanceRecordRepository, repoConsumer);
}
TransactionRepository getTransactionRepository();
default void useTransactionRepository(ThrowableConsumer<TransactionRepository> repoConsumer) {
DbUtil.useClosable(this::getTransactionRepository, repoConsumer);
}
// Utility methods:
default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) {
Thread.ofVirtual().start(() -> {
useAccountRepository(repo -> {
BigDecimal balance = repo.deriveCurrentBalance(account.getId());
Platform.runLater(() -> balanceConsumer.accept(CurrencyUtil.formatMoney(balance, account.getCurrency())));
});
});
}
default Map<Currency, BigDecimal> getCombinedAccountBalances() {
try (var accountRepo = getAccountRepository()) {
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged()).items();
Map<Currency, BigDecimal> totals = new HashMap<>();
for (var account : accounts) {
BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO);
totals.put(account.getCurrency(), currencyTotal.add(accountRepo.deriveCurrentBalance(account.getId())));
}
return totals;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,20 @@
package com.andrewlalis.perfin.data;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
public class DateUtil {
public static DateTimeFormatter DEFAULT_DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static DateTimeFormatter DEFAULT_DATETIME_FORMAT_WITH_ZONE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z");
public static DateTimeFormatter DEFAULT_DATETIME_FORMAT_PRECISE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
public static DateTimeFormatter DEFAULT_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static String formatUTCAsLocalWithZone(LocalDateTime utcTimestamp) {
return utcTimestamp.atOffset(ZoneOffset.UTC)
.atZoneSameInstant(ZoneId.systemDefault())
.format(DEFAULT_DATETIME_FORMAT_WITH_ZONE);
}
}

View File

@ -1,5 +1,8 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import java.sql.*;
import java.time.Clock;
import java.time.Instant;
@ -42,6 +45,15 @@ public final class DbUtil {
return findAll(conn, query, Collections.emptyList(), mapper);
}
public static <T> Page<T> findAll(Connection conn, String query, PageRequest pagination, List<Object> args, ResultSetMapper<T> mapper) {
List<T> items = findAll(conn, query + ' ' + pagination.toSQL(), args, mapper);
return new Page<>(items, pagination);
}
public static <T> Page<T> findAll(Connection conn, String query, PageRequest pagination, ResultSetMapper<T> mapper) {
return findAll(conn, query, pagination, Collections.emptyList(), mapper);
}
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);
@ -88,6 +100,10 @@ public final class DbUtil {
return Timestamp.from(Instant.now(Clock.systemUTC()));
}
public static Timestamp timestampFromInstant(Instant i) {
return Timestamp.from(i);
}
public static LocalDateTime utcLDTFromTimestamp(Timestamp ts) {
return ts.toInstant().atOffset(ZoneOffset.UTC).toLocalDateTime();
}
@ -99,4 +115,27 @@ public final class DbUtil {
throw new RuntimeException(e);
}
}
public static void doTransaction(Connection conn, SQLRunnable runnable) {
try {
conn.setAutoCommit(false);
runnable.run();
} catch (Exception e) {
try {
conn.rollback();
} catch (SQLException se) {
System.err.println("ERROR: Failed to rollback after a failed transaction!");
se.printStackTrace(System.err);
throw new UncheckedSqlException(se);
}
throw new RuntimeException(e);
} finally {
try {
conn.setAutoCommit(true);
} catch (SQLException e) {
System.err.println("ERROR: Failed to set auto-commit to true after transaction!");
e.printStackTrace(System.err);
}
}
}
}

View File

@ -2,7 +2,6 @@ package com.andrewlalis.perfin.data;
import java.sql.SQLException;
@FunctionalInterface
public interface SqlRunnable {
public interface SQLRunnable {
void run() throws SQLException;
}

View File

@ -0,0 +1,19 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.Transaction;
import java.util.List;
import java.util.Map;
import java.util.Set;
public interface TransactionRepository extends AutoCloseable {
long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap);
Page<Transaction> findAll(PageRequest pagination);
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId);
void delete(long transactionId);
}

View File

@ -0,0 +1,40 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.model.AccountEntry;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Currency;
import java.util.List;
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
@Override
public List<AccountEntry> findAllByAccountId(long accountId) {
return DbUtil.findAll(
conn,
"SELECT * FROM account_entry WHERE account_id = ? ORDER BY timestamp DESC",
List.of(accountId),
JdbcAccountEntryRepository::parse
);
}
@Override
public void close() throws Exception {
conn.close();
}
public static AccountEntry parse(ResultSet rs) throws SQLException {
return new AccountEntry(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getLong("account_id"),
rs.getLong("transaction_id"),
rs.getBigDecimal("amount"),
AccountEntry.Type.valueOf(rs.getString("type")),
Currency.getInstance(rs.getString("currency"))
);
}
}

View File

@ -3,17 +3,21 @@ package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.data.UncheckedSqlException;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.BalanceRecord;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
import java.util.Optional;
import java.util.*;
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
@Override
@ -32,8 +36,18 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
}
@Override
public List<Account> findAll() {
return DbUtil.findAll(conn, "SELECT * FROM account ORDER BY created_at", JdbcAccountRepository::parseAccount);
public Page<Account> findAll(PageRequest pagination) {
return DbUtil.findAll(conn, "SELECT * FROM account", pagination, JdbcAccountRepository::parseAccount);
}
@Override
public List<Account> findAllByCurrency(Currency currency) {
return DbUtil.findAll(
conn,
"SELECT * FROM account WHERE currency = ? ORDER BY created_at",
List.of(currency.getCurrencyCode()),
JdbcAccountRepository::parseAccount
);
}
@Override
@ -42,9 +56,69 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
}
@Override
public BigDecimal deriveCurrentBalance(long id) {
// TODO: Implement this!
return BigDecimal.valueOf(0, 4);
public BigDecimal deriveBalance(long id, Instant timestamp) {
// Find the most recent balance record before timestamp.
Optional<BalanceRecord> closestPastRecord = DbUtil.findOne(
conn,
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
List.of(id, DbUtil.timestampFromInstant(timestamp)),
JdbcBalanceRecordRepository::parse
);
if (closestPastRecord.isPresent()) {
// Then find any entries on the account since that balance record and the timestamp.
List<AccountEntry> accountEntries = DbUtil.findAll(
conn,
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC",
List.of(
id,
DbUtil.timestampFromUtcLDT(closestPastRecord.get().getTimestamp()),
DbUtil.timestampFromInstant(timestamp)
),
JdbcAccountEntryRepository::parse
);
// Apply all entries to the most recent known balance to obtain the balance at this point.
BigDecimal currentBalance = closestPastRecord.get().getBalance();
for (var entry : accountEntries) {
currentBalance = currentBalance.add(entry.getSignedAmount());
}
return currentBalance;
} else {
// There is no balance record present before the given timestamp. Try and find the closest one after.
Optional<BalanceRecord> closestFutureRecord = DbUtil.findOne(
conn,
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
List.of(id, DbUtil.timestampFromInstant(timestamp)),
JdbcBalanceRecordRepository::parse
);
if (closestFutureRecord.isEmpty()) {
throw new IllegalStateException("No balance record exists for account " + id);
}
// Now find any entries on the account from the timestamp until that balance record.
List<AccountEntry> accountEntries = DbUtil.findAll(
conn,
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp <= ? AND timestamp >= ? ORDER BY timestamp DESC",
List.of(
id,
DbUtil.timestampFromUtcLDT(closestFutureRecord.get().getTimestamp()),
DbUtil.timestampFromInstant(timestamp)
),
JdbcAccountEntryRepository::parse
);
BigDecimal currentBalance = closestFutureRecord.get().getBalance();
for (var entry : accountEntries) {
currentBalance = currentBalance.subtract(entry.getSignedAmount());
}
return currentBalance;
}
}
@Override
public Set<Currency> findAllUsedCurrencies() {
return new HashSet<>(DbUtil.findAll(
conn,
"SELECT currency FROM account ORDER BY currency ASC",
rs -> Currency.getInstance(rs.getString(1))
));
}
@Override
@ -73,7 +147,7 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
}
}
private static Account parseAccount(ResultSet rs) throws SQLException {
public static Account parseAccount(ResultSet rs) throws SQLException {
long id = rs.getLong("id");
LocalDateTime createdAt = DbUtil.utcLDTFromTimestamp(rs.getTimestamp("created_at"));
AccountType type = AccountType.valueOf(rs.getString("account_type").toUpperCase());

View File

@ -11,6 +11,20 @@ import java.util.Currency;
import java.util.List;
public record JdbcBalanceRecordRepository(Connection conn) implements BalanceRecordRepository {
@Override
public long insert(BalanceRecord record) {
return DbUtil.insertOne(
conn,
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
List.of(
DbUtil.timestampFromUtcNow(),
record.getAccountId(),
record.getBalance(),
record.getCurrency().getCurrencyCode()
)
);
}
@Override
public BalanceRecord findLatestByAccountId(long accountId) {
return DbUtil.findOne(
@ -26,7 +40,7 @@ public record JdbcBalanceRecordRepository(Connection conn) implements BalanceRec
conn.close();
}
private static BalanceRecord parse(ResultSet rs) throws SQLException {
public static BalanceRecord parse(ResultSet rs) throws SQLException {
return new BalanceRecord(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),

View File

@ -1,9 +1,6 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.UncheckedSqlException;
import com.andrewlalis.perfin.data.*;
import java.sql.Connection;
import java.sql.DriverManager;
@ -37,4 +34,9 @@ public class JdbcDataSource implements DataSource {
public BalanceRecordRepository getBalanceRecordRepository() {
return new JdbcBalanceRecordRepository(getConnection());
}
@Override
public TransactionRepository getTransactionRepository() {
return new JdbcTransactionRepository(getConnection());
}
}

View File

@ -0,0 +1,120 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.DbUtil;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.Transaction;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
public record JdbcTransactionRepository(Connection conn) implements TransactionRepository {
@Override
public long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap) {
final Timestamp timestamp = DbUtil.timestampFromUtcNow();
AtomicLong transactionId = new AtomicLong(-1);
DbUtil.doTransaction(conn, () -> {
// First insert the transaction itself, then add account entries, referencing this transaction.
transactionId.set(DbUtil.insertOne(
conn,
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
List.of(
timestamp,
transaction.getAmount(),
transaction.getCurrency().getCurrencyCode(),
transaction.getDescription()
)
));
// Now insert an account entry for each affected account.
try (var stmt = conn.prepareStatement(
"INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency) VALUES (?, ?, ?, ?, ?, ?)"
)) {
for (var entry : accountsMap.entrySet()) {
long accountId = entry.getKey();
AccountEntry.Type entryType = entry.getValue();
DbUtil.setArgs(stmt, List.of(
timestamp,
accountId,
transactionId.get(),
transaction.getAmount(),
entryType.name(),
transaction.getCurrency().getCurrencyCode()
));
stmt.executeUpdate();
}
}
});
return transactionId.get();
}
@Override
public Page<Transaction> findAll(PageRequest pagination) {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction",
pagination,
JdbcTransactionRepository::parse
);
}
@Override
public Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination) {
String idsStr = accountIds.stream().map(String::valueOf).collect(Collectors.joining(","));
String query = String.format("""
SELECT *
FROM transaction
LEFT JOIN account_entry ON account_entry.transaction_id = transaction.id
WHERE account_entry.account_id IN (%s)
""", idsStr);
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parse);
}
@Override
public Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId) {
List<AccountEntry> entries = DbUtil.findAll(
conn,
"SELECT * FROM account_entry WHERE transaction_id = ?",
List.of(transactionId),
JdbcAccountEntryRepository::parse
);
Map<AccountEntry, Account> map = new HashMap<>();
for (var entry : entries) {
Account account = DbUtil.findOne(
conn,
"SELECT * FROM account WHERE id = ?",
List.of(entry.getAccountId()),
JdbcAccountRepository::parseAccount
).orElseThrow();
map.put(entry, account);
}
return map;
}
@Override
public void delete(long transactionId) {
DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId));
}
@Override
public void close() throws Exception {
conn.close();
}
public static Transaction parse(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")
);
}
}

View File

@ -0,0 +1,5 @@
package com.andrewlalis.perfin.data.pagination;
import java.util.List;
public record Page<T>(List<T> items, PageRequest pagination) {}

View File

@ -0,0 +1,43 @@
package com.andrewlalis.perfin.data.pagination;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public record PageRequest(
int page,
int size,
List<Sort> sorts
) {
public static PageRequest of(int page, int size, Sort... sorts) {
return new PageRequest(page, size, Arrays.asList(sorts));
}
public static PageRequest unpaged(Sort... sorts) {
return new PageRequest(-1, -1, Arrays.asList(sorts));
}
public PageRequest next() {
return new PageRequest(page + 1, size, sorts);
}
public PageRequest previous() {
return new PageRequest(page - 1, size, sorts);
}
public String toSQL() {
StringBuilder sb = new StringBuilder();
if (!sorts.isEmpty()) {
sb.append("ORDER BY ");
sb.append(sorts.stream().map(Sort::toSQL).collect(Collectors.joining(", ")));
sb.append(' ');
}
if (page == -1 && size == -1) {
// Unpaged request, so return the string without any offset/limit.
return sb.toString().strip();
}
long offset = (long) page * size;
sb.append("LIMIT ").append(size).append(" OFFSET ").append(offset);
return sb.toString();
}
}

View File

@ -0,0 +1,17 @@
package com.andrewlalis.perfin.data.pagination;
public record Sort(String property, Direction direction) {
public enum Direction {ASC, DESC}
public static Sort asc(String property) {
return new Sort(property, Direction.ASC);
}
public static Sort desc(String property) {
return new Sort(property, Direction.DESC);
}
public String toSQL() {
return property + " " + direction.name();
}
}

View File

@ -40,6 +40,16 @@ public class Account {
return accountNumber;
}
public String getAccountNumberSuffix() {
int suffixLength = Math.min(4, accountNumber.length());
return "..." + accountNumber.substring(accountNumber.length() - suffixLength);
}
public String getShortName() {
String numberSuffix = getAccountNumberSuffix();
return name + " (" + numberSuffix + ")";
}
public String getName() {
return name;
}

View File

@ -53,4 +53,44 @@ public class AccountEntry {
this.type = type;
this.currency = currency;
}
public AccountEntry(long accountId, long transactionId, BigDecimal amount, Type type, Currency currency) {
this.accountId = accountId;
this.transactionId = transactionId;
this.amount = amount;
this.type = type;
this.currency = currency;
}
public long getId() {
return id;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public long getAccountId() {
return accountId;
}
public long getTransactionId() {
return transactionId;
}
public BigDecimal getAmount() {
return amount;
}
public Type getType() {
return type;
}
public Currency getCurrency() {
return currency;
}
public BigDecimal getSignedAmount() {
return type == Type.DEBIT ? amount : amount.negate();
}
}

View File

@ -24,4 +24,30 @@ public class BalanceRecord {
this.balance = balance;
this.currency = currency;
}
public BalanceRecord(long accountId, BigDecimal balance, Currency currency) {
this.accountId = accountId;
this.balance = balance;
this.currency = currency;
}
public long getId() {
return id;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public long getAccountId() {
return accountId;
}
public BigDecimal getBalance() {
return balance;
}
public Currency getCurrency() {
return currency;
}
}

View File

@ -6,7 +6,9 @@ import java.util.Currency;
/**
* A transaction is a permanent record of a transfer of funds between two
* accounts.
* accounts. Its amount is always recorded as an absolute value, and its
* actual positive/negative effect is determined by the associated account
* entries that apply this transaction's amount to one or more accounts.
*/
public class Transaction {
private long id;
@ -23,4 +25,31 @@ public class Transaction {
this.currency = currency;
this.description = description;
}
public Transaction(LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
this.timestamp = timestamp;
this.amount = amount;
this.currency = currency;
this.description = description;
}
public long getId() {
return id;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public BigDecimal getAmount() {
return amount;
}
public Currency getCurrency() {
return currency;
}
public String getDescription() {
return description;
}
}

View File

@ -0,0 +1,33 @@
package com.andrewlalis.perfin.view;
import com.andrewlalis.perfin.model.Account;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.util.Callback;
public class AccountComboBoxCellFactory implements Callback<ListView<Account>, ListCell<Account>> {
public static class AccountListCell extends ListCell<Account> {
private final Label label = new Label();
public AccountListCell() {
label.setStyle("-fx-text-fill: black;");
}
@Override
protected void updateItem(Account item, boolean empty) {
super.updateItem(item, empty);
if (item == null || empty) {
label.setText("None");
} else {
label.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
}
setGraphic(label);
}
}
@Override
public ListCell<Account> call(ListView<Account> param) {
return new AccountListCell();
}
}

View File

@ -11,5 +11,7 @@ module com.andrewlalis.perfin {
exports com.andrewlalis.perfin to javafx.graphics;
exports com.andrewlalis.perfin.view to javafx.graphics;
exports com.andrewlalis.perfin.model to javafx.graphics;
opens com.andrewlalis.perfin.control to javafx.fxml;
opens com.andrewlalis.perfin.control.component to javafx.fxml;
}

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<VBox
styleClass="account-tile-container"
prefHeight="100.0"
prefWidth="300.0"
stylesheets="@style/account-tile.css"
xmlns="http://javafx.com/javafx/17.0.2-ea"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="com.andrewlalis.perfin.control.AccountTileController"
fx:id="container"
>
<Label text="Account Number" styleClass="property-label"/>
<Label fx:id="accountNumberLabel" styleClass="property-value" text="Account Number placeholder" />
<Label text="Account Balance" styleClass="property-label"/>
<Label fx:id="accountBalanceLabel" styleClass="property-value" text="account balance placeholder" />
<VBox fx:id="accountNameBox">
<Label text="Account Name" styleClass="property-label"/>
<Label fx:id="accountNameLabel" styleClass="property-value" text="account name placeholder"/>
</VBox>
</VBox>

View File

@ -30,6 +30,10 @@
<Label text="Created At"/>
<TextField fx:id="accountCreatedAtField" editable="false"/>
</VBox>
<VBox styleClass="account-property-box">
<Label text="Current Balance"/>
<TextField fx:id="accountBalanceField" editable="false"/>
</VBox>
</VBox>
</center>
<right>

View File

@ -1,40 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.FlowPane?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.control.Button?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.*?>
<BorderPane
minHeight="400.0"
minWidth="600.0"
xmlns="http://javafx.com/javafx/17.0.2-ea"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="com.andrewlalis.perfin.control.AccountsViewController"
fx:id="mainContainer"
stylesheets="@style/accounts-view.css"
>
<!-- <top>-->
<!-- <MenuBar BorderPane.alignment="CENTER">-->
<!-- <Menu mnemonicParsing="false" text="File">-->
<!-- <MenuItem mnemonicParsing="false" text="Close" />-->
<!-- </Menu>-->
<!-- <Menu mnemonicParsing="false" text="Edit">-->
<!-- <MenuItem mnemonicParsing="false" text="Delete" />-->
<!-- </Menu>-->
<!-- <Menu mnemonicParsing="false" text="Help">-->
<!-- <MenuItem mnemonicParsing="false" text="About" />-->
<!-- </Menu>-->
<!-- </MenuBar>-->
<!-- </top>-->
<center>
<VBox>
<top>
<HBox styleClass="actionsBar">
<Button text="Add an Account" onAction="#createNewAccount"/>
<Label fx:id="totalLabel"/>
</HBox>
</top>
<center>
<VBox>
<ScrollPane fitToHeight="true" fitToWidth="true" VBox.vgrow="ALWAYS">
<FlowPane fx:id="accountsPane" BorderPane.alignment="TOP_LEFT" vgap="5" hgap="5"/>
</ScrollPane>
<Label fx:id="noAccountsLabel" BorderPane.alignment="TOP_LEFT" text="No accounts have been added to this profile."/>
</VBox>
</center>

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.CreateTransactionController"
stylesheets="@style/create-transaction.css"
>
<center>
<ScrollPane fitToWidth="true" fitToHeight="true">
<VBox styleClass="form-container">
<VBox>
<Label text="Timestamp" labelFor="${timestampField}" styleClass="bold-text"/>
<TextField fx:id="timestampField" styleClass="mono-font"/>
<Label fx:id="timestampInvalidLabel" text="Invalid timestamp." styleClass="error-text"/>
<Label fx:id="timestampFutureLabel" text="Timestamp cannot be in the future." styleClass="error-text"/>
</VBox>
<VBox>
<Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/>
<TextField fx:id="amountField" styleClass="mono-font"/>
</VBox>
<VBox>
<Label text="Currency" labelFor="${currencyChoiceBox}" styleClass="bold-text"/>
<ChoiceBox fx:id="currencyChoiceBox"/>
</VBox>
<VBox>
<Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
<Label text="Maximum of 256 characters." styleClass="small-text"/>
<TextArea fx:id="descriptionField" styleClass="mono-font"/>
</VBox>
<VBox>
<HBox spacing="3">
<VBox>
<Label text="Debited Account" labelFor="${linkDebitAccountComboBox}" styleClass="bold-text"/>
<ComboBox fx:id="linkDebitAccountComboBox">
<tooltip><Tooltip text="The account whose assets will increase as a result of this transaction."/></tooltip>
</ComboBox>
</VBox>
<VBox>
<Label text="Credited Account" labelFor="${linkCreditAccountComboBox}" styleClass="bold-text"/>
<ComboBox fx:id="linkCreditAccountComboBox">
<tooltip><Tooltip text="The account whose assets will decrease as a result of this transaction."/></tooltip>
</ComboBox>
</VBox>
</HBox>
<Label fx:id="linkedAccountsErrorLabel" styleClass="error-text" wrapText="true"/>
<Label
text="Every transaction must be linked to one of your accounts. In the case of transfer between accounts, then both the sender and receiver accounts should be linked."
styleClass="small-text"
style="-fx-min-width: 100px; -fx-min-height: 100px; -fx-alignment: top-left"
wrapText="true"
VBox.vgrow="NEVER"
/>
</VBox>
</VBox>
</ScrollPane>
</center>
<bottom>
<HBox styleClass="buttons-container">
<Button text="Save" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/>
</HBox>
</bottom>
</BorderPane>

View File

@ -13,22 +13,28 @@
<Label fx:id="titleLabel" text="Edit Account"/>
</top>
<center>
<GridPane BorderPane.alignment="CENTER" styleClass="fields-grid">
<GridPane BorderPane.alignment="TOP_LEFT" styleClass="fields-grid">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" minWidth="10.0" />
<ColumnConstraints hgrow="ALWAYS" minWidth="10.0" />
</columnConstraints>
<Label GridPane.columnIndex="0" GridPane.rowIndex="0" text="Name"/>
<TextField fx:id="accountNameField" GridPane.columnIndex="1" GridPane.rowIndex="0" text="Bleh"/>
<TextField fx:id="accountNameField" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
<Label GridPane.columnIndex="0" GridPane.rowIndex="1" text="Account Number"/>
<TextField fx:id="accountNumberField" GridPane.columnIndex="1" GridPane.rowIndex="1" text="1234"/>
<TextField fx:id="accountNumberField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
<Label GridPane.columnIndex="0" GridPane.rowIndex="2" text="Currency"/>
<ComboBox fx:id="accountCurrencyComboBox" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
<Label GridPane.columnIndex="0" GridPane.rowIndex="3" text="Account Type"/>
<ChoiceBox fx:id="accountTypeChoiceBox" GridPane.columnIndex="1" GridPane.rowIndex="3"/>
<VBox fx:id="initialBalanceContent" GridPane.columnIndex="0" GridPane.rowIndex="4" GridPane.columnSpan="2">
<Separator/>
<Label text="Initial Balance" style="-fx-font-weight: bold;"/>
<TextField fx:id="initialBalanceField"/>
</VBox>
</GridPane>
</center>
<bottom>

View File

@ -15,6 +15,7 @@
<Button text="Back" onAction="#goBack"/>
<Button text="Forward" onAction="#goForward"/>
<Button text="Accounts" onAction="#goToAccounts"/>
<Button text="Transactions" onAction="#goToTransactions"/>
</HBox>
<HBox fx:id="breadcrumbHBox"/>
<Separator/>
@ -23,7 +24,6 @@
<bottom>
<HBox fx:id="mainFooter" spacing="5">
<Label text="Perfin v0.0.1"/>
<Label text="Copyright Bleh"/>
</HBox>
</bottom>
</BorderPane>

View File

@ -1,25 +0,0 @@
.account-tile-container {
-fx-border-color: lightgray;
-fx-border-width: 1px;
-fx-border-style: solid;
-fx-border-radius: 5px;
-fx-padding: 5px;
}
.account-tile-container:hover {
-fx-cursor: hand;
}
.main-label {
-fx-font-weight: bold;
-fx-font-size: large;
}
.property-label {
-fx-font-weight: bold;
}
.property-value {
-fx-font-family: monospace;
-fx-font-size: large;
}

View File

@ -1,11 +1,11 @@
#accountsPane {
-fx-padding: 5px;
-fx-padding: 3px;
}
#noAccountsLabel {
-fx-padding: 5px;
-fx-padding: 3px;
}
.actionsBar {
-fx-padding: 5px;
-fx-padding: 3px;
}

View File

@ -0,0 +1,32 @@
.mono-font {
-fx-font-family: monospace;
}
.bold-text {
-fx-font-weight: bold;
}
.small-text {
-fx-font-size: small;
}
.error-text {
-fx-font-size: small;
-fx-text-fill: red;
}
.form-container {
-fx-max-width: 500px;
-fx-spacing: 3px;
-fx-padding: 3px;
}
.buttons-container {
-fx-padding: 3px;
-fx-spacing: 3px;
}
#descriptionField {
-fx-pref-height: 100px;
-fx-min-height: 100px;
}

View File

@ -6,6 +6,7 @@
-fx-hgap: 3px;
-fx-vgap: 3px;
-fx-padding: 5px;
-fx-max-width: 500px;
}
#titleLabel {

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.HBox?>
<BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
>
<top>
<HBox style="-fx-padding: 3px;">
<Button text="Add Transaction" onAction="#addTransaction"/>
</HBox>
</top>
<center>
<Label text="Center"/>
<ScrollPane fitToHeight="true" fitToWidth="true">
<VBox fx:id="transactionsVBox" style="-fx-padding: 3px; -fx-spacing: 5px;"/>
</ScrollPane>
</center>
</BorderPane>