From 5f692bf8e229225f403cd9b0e93c36766fb313f2 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Mon, 8 Jan 2024 09:49:34 -0500 Subject: [PATCH] Refactor creating a balance record. --- .../CreateBalanceRecordController.java | 72 +++++++++++---- .../control/CreateTransactionController.java | 3 - .../perfin/data/AccountEntryRepository.java | 2 + .../perfin/data/BalanceRecordRepository.java | 3 + .../data/impl/JdbcAccountEntryRepository.java | 15 +++ .../data/impl/JdbcAccountRepository.java | 91 +++++++++---------- .../impl/JdbcBalanceRecordRepository.java | 21 +++++ src/main/resources/create-balance-record.fxml | 59 ++++++++---- src/main/resources/create-transaction.fxml | 2 +- src/main/resources/edit-account.fxml | 2 +- src/main/resources/style/base.css | 10 +- 11 files changed, 190 insertions(+), 90 deletions(-) diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java index 56f8d17..0361415 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -8,28 +8,56 @@ import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.MoneyValue; import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.view.component.FileSelectionArea; +import com.andrewlalis.perfin.view.component.PropertiesPane; +import com.andrewlalis.perfin.view.component.validation.ValidationApplier; +import com.andrewlalis.perfin.view.component.validation.ValidationResult; +import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; import javafx.application.Platform; import javafx.fxml.FXML; +import javafx.scene.control.Button; import javafx.scene.control.TextField; -import javafx.scene.layout.VBox; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; import static com.andrewlalis.perfin.PerfinApp.router; public class CreateBalanceRecordController implements RouteSelectionListener { @FXML public TextField timestampField; @FXML public TextField balanceField; - @FXML public VBox attachmentsVBox; private FileSelectionArea attachmentSelectionArea; + @FXML public PropertiesPane propertiesPane; + + @FXML public Button saveButton; private Account account; @FXML public void initialize() { - attachmentSelectionArea = new FileSelectionArea(FileUtil::newAttachmentsFileChooser, () -> attachmentsVBox.getScene().getWindow()); + var timestampValid = new ValidationApplier(input -> { + try { + DateUtil.DEFAULT_DATETIME_FORMAT.parse(input); + return ValidationResult.valid(); + } catch (DateTimeParseException e) { + return ValidationResult.of("Invalid timestamp format."); + } + }).validatedInitially().attachToTextField(timestampField); + + var balanceValid = new ValidationApplier<>( + new CurrencyAmountValidator(() -> account == null ? null : account.getCurrency(), true, false) + ).validatedInitially().attachToTextField(balanceField); + + var formValid = timestampValid.and(balanceValid); + saveButton.disableProperty().bind(formValid.not()); + + // Manually append the attachment selection area to the end of the properties pane. + attachmentSelectionArea = new FileSelectionArea( + FileUtil::newAttachmentsFileChooser, + () -> timestampField.getScene().getWindow() + ); attachmentSelectionArea.allowMultiple.set(true); - attachmentsVBox.getChildren().add(attachmentSelectionArea); + propertiesPane.getChildren().addLast(attachmentSelectionArea); } @Override @@ -48,19 +76,29 @@ public class CreateBalanceRecordController implements RouteSelectionListener { } @FXML public void save() { - // TODO: Add validation. - Profile.getCurrent().getDataSource().useBalanceRecordRepository(repo -> { - LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); - BigDecimal reportedBalance = new BigDecimal(balanceField.getText()); - repo.insert( - DateUtil.localToUTC(localTimestamp), - account.id, - reportedBalance, - account.getCurrency(), - attachmentSelectionArea.getSelectedFiles() - ); - }); - router.navigateBackAndClear(); + LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); + BigDecimal reportedBalance = new BigDecimal(balanceField.getText()); + boolean confirm = Popups.confirm("Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted( + account.getShortName(), + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())), + localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE) + )); + if (confirm) { + Profile.getCurrent().getDataSource().useAccountRepository(accountRepo -> { + BigDecimal currentDerivedBalance = accountRepo.deriveCurrentBalance(account.id); + + }); + Profile.getCurrent().getDataSource().useBalanceRecordRepository(repo -> { + repo.insert( + DateUtil.localToUTC(localTimestamp), + account.id, + reportedBalance, + account.getCurrency(), + attachmentSelectionArea.getSelectedFiles() + ); + }); + router.navigateBackAndClear(); + } } @FXML public void cancel() { diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java index 34d252b..fcc1db0 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java @@ -46,9 +46,6 @@ public class CreateTransactionController implements RouteSelectionListener { @FXML public Button saveButton; - public CreateTransactionController() { - } - @FXML public void initialize() { // Setup error field validation. var timestampValid = new ValidationApplier<>(new PredicateValidator() diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java index 8472b16..794885f 100644 --- a/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/AccountEntryRepository.java @@ -1,5 +1,6 @@ package com.andrewlalis.perfin.data; +import com.andrewlalis.perfin.data.pagination.Sort; import com.andrewlalis.perfin.model.AccountEntry; import java.math.BigDecimal; @@ -17,4 +18,5 @@ public interface AccountEntryRepository extends AutoCloseable { Currency currency ); List findAllByAccountId(long accountId); + List findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax); } diff --git a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java index 94a5d85..22c7c67 100644 --- a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java @@ -7,9 +7,12 @@ import java.nio.file.Path; import java.time.LocalDateTime; import java.util.Currency; import java.util.List; +import java.util.Optional; public interface BalanceRecordRepository extends AutoCloseable { long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List attachments); BalanceRecord findLatestByAccountId(long accountId); + Optional findClosestBefore(long accountId, LocalDateTime utcTimestamp); + Optional findClosestAfter(long accountId, LocalDateTime utcTimestamp); void deleteById(long id); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java index 3e1e837..44cc93f 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java @@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data.impl; import com.andrewlalis.perfin.data.AccountEntryRepository; import com.andrewlalis.perfin.data.AccountHistoryItemRepository; +import com.andrewlalis.perfin.data.pagination.Sort; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.AccountEntry; @@ -46,6 +47,20 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr ); } + @Override + public List findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax) { + return DbUtil.findAll( + conn, + "SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC", + List.of( + accountId, + DbUtil.timestampFromUtcLDT(utcMin), + DbUtil.timestampFromUtcLDT(utcMax) + ), + JdbcAccountEntryRepository::parse + ); + } + @Override public void close() throws Exception { conn.close(); 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 0d426d1..d2910e2 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -1,6 +1,8 @@ package com.andrewlalis.perfin.data.impl; +import com.andrewlalis.perfin.data.AccountEntryRepository; import com.andrewlalis.perfin.data.AccountRepository; +import com.andrewlalis.perfin.data.BalanceRecordRepository; import com.andrewlalis.perfin.data.EntityNotFoundException; import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; @@ -10,16 +12,22 @@ 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.math.BigDecimal; +import java.nio.file.Path; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.*; -public record JdbcAccountRepository(Connection conn) implements AccountRepository { +public record JdbcAccountRepository(Connection conn, Path contentDir) implements AccountRepository { + private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class); + @Override public long insert(AccountType type, String accountNumber, String name, Currency currency) { return DbUtil.doTransaction(conn, () -> { @@ -84,49 +92,37 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor // First find the account itself, since its properties influence the balance. Account account = findById(accountId).orElse(null); if (account == null) throw new EntityNotFoundException(Account.class, accountId); + LocalDateTime utcTimestamp = timestamp.atZone(ZoneOffset.UTC).toLocalDateTime(); + BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir); + AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn); // 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(accountId, DbUtil.timestampFromInstant(timestamp)), - JdbcBalanceRecordRepository::parse - ); + Optional closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp); if (closestPastRecord.isPresent()) { // Then find any entries on the account since that balance record and the timestamp. - List entriesAfterRecord = DbUtil.findAll( - conn, - "SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC", - List.of( - accountId, - DbUtil.timestampFromUtcLDT(closestPastRecord.get().getTimestamp()), - DbUtil.timestampFromInstant(timestamp) - ), - JdbcAccountEntryRepository::parse + List entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween( + account.id, + closestPastRecord.get().getTimestamp(), + utcTimestamp ); - return computeBalanceWithEntriesAfter(account, closestPastRecord.get(), entriesAfterRecord); + return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow); } 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(accountId, DbUtil.timestampFromInstant(timestamp)), - JdbcBalanceRecordRepository::parse - ); - if (closestFutureRecord.isEmpty()) { - throw new IllegalStateException("No balance record exists for account " + accountId); + Optional closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp); + if (closestFutureRecord.isPresent()) { + // Now find any entries on the account from the timestamp until that balance record. + List entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween( + account.id, + utcTimestamp, + closestFutureRecord.get().getTimestamp() + ); + return computeBalanceWithEntries(account.getType(), closestFutureRecord.get(), entriesBetweenNowAndFutureRecord); + } else { + // No balance records exist for the account! Assume balance of 0 when the account was created. + log.warn("No balance record exists for account {}! Assuming balance was 0 at account creation.", account.getShortName()); + BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BigDecimal.ZERO, account.getCurrency()); + List entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp); + return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated); } - // Now find any entries on the account from the timestamp until that balance record. - List entriesBeforeRecord = DbUtil.findAll( - conn, - "SELECT * FROM account_entry WHERE account_id = ? AND timestamp <= ? AND timestamp >= ? ORDER BY timestamp DESC", - List.of( - accountId, - DbUtil.timestampFromUtcLDT(closestFutureRecord.get().getTimestamp()), - DbUtil.timestampFromInstant(timestamp) - ), - JdbcAccountEntryRepository::parse - ); - return computeBalanceWithEntriesBefore(account, closestFutureRecord.get(), entriesBeforeRecord); } } @@ -191,18 +187,19 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor conn.close(); } - private BigDecimal computeBalanceWithEntriesAfter(Account account, BalanceRecord balanceRecord, List entriesAfterRecord) { - BigDecimal balance = balanceRecord.getBalance(); - for (AccountEntry entry : entriesAfterRecord) { - balance = balance.add(entry.getEffectiveValue(account.getType())); - } - return balance; - } - - private BigDecimal computeBalanceWithEntriesBefore(Account account, BalanceRecord balanceRecord, List entriesBeforeRecord) { + private BigDecimal computeBalanceWithEntries(AccountType accountType, BalanceRecord balanceRecord, List entries) { + List entriesBeforeRecord = entries.stream() + .filter(entry -> entry.getTimestamp().isBefore(balanceRecord.getTimestamp())) + .toList(); + List entriesAfterRecord = entries.stream() + .filter(entry -> entry.getTimestamp().isAfter(balanceRecord.getTimestamp())) + .toList(); BigDecimal balance = balanceRecord.getBalance(); for (AccountEntry entry : entriesBeforeRecord) { - balance = balance.subtract(entry.getEffectiveValue(account.getType())); + balance = balance.subtract(entry.getEffectiveValue(accountType)); + } + for (AccountEntry entry : entriesAfterRecord) { + balance = balance.add(entry.getEffectiveValue(accountType)); } return balance; } 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 4a69161..5055eb9 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java @@ -15,6 +15,7 @@ import java.sql.SQLException; import java.time.LocalDateTime; import java.util.Currency; import java.util.List; +import java.util.Optional; public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository { @Override @@ -51,6 +52,26 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl ).orElse(null); } + @Override + public Optional findClosestBefore(long accountId, LocalDateTime utcTimestamp) { + return DbUtil.findOne( + conn, + "SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1", + List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)), + JdbcBalanceRecordRepository::parse + ); + } + + @Override + public Optional findClosestAfter(long accountId, LocalDateTime utcTimestamp) { + return DbUtil.findOne( + conn, + "SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1", + List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)), + JdbcBalanceRecordRepository::parse + ); + } + @Override public void deleteById(long id) { DbUtil.updateOne(conn, "DELETE FROM balance_record WHERE id = ?", List.of(id)); diff --git a/src/main/resources/create-balance-record.fxml b/src/main/resources/create-balance-record.fxml index 6a4f4ee..53cf526 100644 --- a/src/main/resources/create-balance-record.fxml +++ b/src/main/resources/create-balance-record.fxml @@ -1,35 +1,54 @@ + + + - - +
- - - - - - -
- - -