diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index a7fb689..d4a8135 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -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"); } } \ No newline at end of file diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountTileController.java b/src/main/java/com/andrewlalis/perfin/control/AccountTileController.java deleted file mode 100644 index d6d8f27..0000000 --- a/src/main/java/com/andrewlalis/perfin/control/AccountTileController.java +++ /dev/null @@ -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 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)); - }); - } -} diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java index 7c375aa..42860c7 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java @@ -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 diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java index 1d0f74d..ff083d0 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java @@ -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 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) 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())); + }); }); + } } diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java new file mode 100644 index 0000000..27ab5a7 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java @@ -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 currencyChoiceBox; + @FXML public TextArea descriptionField; + + @FXML public ComboBox linkDebitAccountComboBox; + @FXML public ComboBox 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 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 getSelectedAccounts() { + Account debitAccount = linkDebitAccountComboBox.getValue(); + Account creditAccount = linkCreditAccountComboBox.getValue(); + Map 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 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 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); + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java index 16d6ec6..ad191c5 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java @@ -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 accountCurrencyComboBox; @FXML public ChoiceBox 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(); - Account newAccount = new Account(type, number, name, currency); - long id = accountRepo.insert(newAccount); - Account savedAccount = accountRepo.findById(id).orElseThrow(); + BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip()); - // Once we create the new account, go to the account. - router.getHistory().clear(); - router.navigate("account", savedAccount); + Alert confirm = new Alert( + Alert.AlertType.CONFIRMATION, + "Are you sure you want to create this account?" + ); + Optional 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()); diff --git a/src/main/java/com/andrewlalis/perfin/control/MainViewController.java b/src/main/java/com/andrewlalis/perfin/control/MainViewController.java index 34da78d..129ce30 100644 --- a/src/main/java/com/andrewlalis/perfin/control/MainViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/MainViewController.java @@ -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"); + } } diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java new file mode 100644 index 0000000..050dc44 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -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)); + }); + }); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/component/AccountTile.java b/src/main/java/com/andrewlalis/perfin/control/component/AccountTile.java new file mode 100644 index 0000000..b53f6de --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/component/AccountTile.java @@ -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 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 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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/component/TransactionTile.java b/src/main/java/com/andrewlalis/perfin/control/component/TransactionTile.java new file mode 100644 index 0000000..232f784 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/component/TransactionTile.java @@ -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> getCreditAndDebitAccounts(Transaction transaction) { + CompletableFuture> 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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java index 9dce9c0..acab5b9 100644 --- a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java @@ -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 findAll(); + Page findAll(PageRequest pagination); + List findAllByCurrency(Currency currency); Optional 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 findAllUsedCurrencies(); } diff --git a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java index c825bb8..19d73ca 100644 --- a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java @@ -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); } diff --git a/src/main/java/com/andrewlalis/perfin/data/CurrencyUtil.java b/src/main/java/com/andrewlalis/perfin/data/CurrencyUtil.java new file mode 100644 index 0000000..f6d187d --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/CurrencyUtil.java @@ -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); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java index 262c57f..99f5414 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java @@ -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 repoConsumer) { @@ -10,4 +23,34 @@ public interface DataSource { default void useBalanceRecordRepository(ThrowableConsumer repoConsumer) { DbUtil.useClosable(this::getBalanceRecordRepository, repoConsumer); } + + TransactionRepository getTransactionRepository(); + default void useTransactionRepository(ThrowableConsumer repoConsumer) { + DbUtil.useClosable(this::getTransactionRepository, repoConsumer); + } + + // Utility methods: + + default void getAccountBalanceText(Account account, Consumer balanceConsumer) { + Thread.ofVirtual().start(() -> { + useAccountRepository(repo -> { + BigDecimal balance = repo.deriveCurrentBalance(account.getId()); + Platform.runLater(() -> balanceConsumer.accept(CurrencyUtil.formatMoney(balance, account.getCurrency()))); + }); + }); + } + + default Map getCombinedAccountBalances() { + try (var accountRepo = getAccountRepository()) { + List accounts = accountRepo.findAll(PageRequest.unpaged()).items(); + Map 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); + } + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/DateUtil.java b/src/main/java/com/andrewlalis/perfin/data/DateUtil.java new file mode 100644 index 0000000..60b7e2b --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/DateUtil.java @@ -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); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/DbUtil.java index 608f588..df785c8 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DbUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/DbUtil.java @@ -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 Page findAll(Connection conn, String query, PageRequest pagination, List args, ResultSetMapper mapper) { + List items = findAll(conn, query + ' ' + pagination.toSQL(), args, mapper); + return new Page<>(items, pagination); + } + + public static Page findAll(Connection conn, String query, PageRequest pagination, ResultSetMapper mapper) { + return findAll(conn, query, pagination, Collections.emptyList(), mapper); + } + public static Optional findOne(Connection conn, String query, List args, ResultSetMapper 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); + } + } + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/SqlRunnable.java b/src/main/java/com/andrewlalis/perfin/data/SQLRunnable.java similarity index 67% rename from src/main/java/com/andrewlalis/perfin/data/SqlRunnable.java rename to src/main/java/com/andrewlalis/perfin/data/SQLRunnable.java index fd5f0fc..0bf842a 100644 --- a/src/main/java/com/andrewlalis/perfin/data/SqlRunnable.java +++ b/src/main/java/com/andrewlalis/perfin/data/SQLRunnable.java @@ -2,7 +2,6 @@ package com.andrewlalis.perfin.data; import java.sql.SQLException; -@FunctionalInterface -public interface SqlRunnable { +public interface SQLRunnable { void run() throws SQLException; } diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java new file mode 100644 index 0000000..8a6f171 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -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 accountsMap); + Page findAll(PageRequest pagination); + Page findAllByAccounts(Set accountIds, PageRequest pagination); + Map findEntriesWithAccounts(long transactionId); + void delete(long transactionId); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java new file mode 100644 index 0000000..3476c06 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java @@ -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 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")) + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java index 269e929..4cb9c9b 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -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 findAll() { - return DbUtil.findAll(conn, "SELECT * FROM account ORDER BY created_at", JdbcAccountRepository::parseAccount); + public Page findAll(PageRequest pagination) { + return DbUtil.findAll(conn, "SELECT * FROM account", pagination, JdbcAccountRepository::parseAccount); + } + + @Override + public List 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 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 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 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 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 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()); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java index e0d35ea..6a782f5 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java @@ -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")), diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java index dcbf499..d20006c 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java @@ -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()); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java new file mode 100644 index 0000000..ab64438 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -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 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 findAll(PageRequest pagination) { + return DbUtil.findAll( + conn, + "SELECT * FROM transaction", + pagination, + JdbcTransactionRepository::parse + ); + } + + @Override + public Page findAllByAccounts(Set 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 findEntriesWithAccounts(long transactionId) { + List entries = DbUtil.findAll( + conn, + "SELECT * FROM account_entry WHERE transaction_id = ?", + List.of(transactionId), + JdbcAccountEntryRepository::parse + ); + Map 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") + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/pagination/Page.java b/src/main/java/com/andrewlalis/perfin/data/pagination/Page.java new file mode 100644 index 0000000..8154eab --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/pagination/Page.java @@ -0,0 +1,5 @@ +package com.andrewlalis.perfin.data.pagination; + +import java.util.List; + +public record Page(List items, PageRequest pagination) {} diff --git a/src/main/java/com/andrewlalis/perfin/data/pagination/PageRequest.java b/src/main/java/com/andrewlalis/perfin/data/pagination/PageRequest.java new file mode 100644 index 0000000..429d151 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/pagination/PageRequest.java @@ -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 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(); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/pagination/Sort.java b/src/main/java/com/andrewlalis/perfin/data/pagination/Sort.java new file mode 100644 index 0000000..a52dd61 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/pagination/Sort.java @@ -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(); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/Account.java b/src/main/java/com/andrewlalis/perfin/model/Account.java index a62b2c2..69e2002 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Account.java +++ b/src/main/java/com/andrewlalis/perfin/model/Account.java @@ -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; } diff --git a/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java b/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java index f298ec5..ee52323 100644 --- a/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java +++ b/src/main/java/com/andrewlalis/perfin/model/AccountEntry.java @@ -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(); + } } diff --git a/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java b/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java index 9f0f561..f61db88 100644 --- a/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java +++ b/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java @@ -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; + } } diff --git a/src/main/java/com/andrewlalis/perfin/model/Transaction.java b/src/main/java/com/andrewlalis/perfin/model/Transaction.java index 56f5f81..ae00d4e 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Transaction.java +++ b/src/main/java/com/andrewlalis/perfin/model/Transaction.java @@ -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; + } } diff --git a/src/main/java/com/andrewlalis/perfin/view/AccountComboBoxCellFactory.java b/src/main/java/com/andrewlalis/perfin/view/AccountComboBoxCellFactory.java new file mode 100644 index 0000000..f544218 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/AccountComboBoxCellFactory.java @@ -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, ListCell> { + public static class AccountListCell extends ListCell { + 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 call(ListView param) { + return new AccountListCell(); + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index f349b22..00a482a 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -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; } \ No newline at end of file diff --git a/src/main/resources/account-tile.fxml b/src/main/resources/account-tile.fxml deleted file mode 100644 index 1c10a04..0000000 --- a/src/main/resources/account-tile.fxml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - diff --git a/src/main/resources/account-view.fxml b/src/main/resources/account-view.fxml index 0a251ef..6dbf9a1 100644 --- a/src/main/resources/account-view.fxml +++ b/src/main/resources/account-view.fxml @@ -30,6 +30,10 @@