Refactor creating a balance record.

This commit is contained in:
Andrew Lalis 2024-01-08 09:49:34 -05:00
parent c02e5d3fc6
commit 5f692bf8e2
11 changed files with 190 additions and 90 deletions

View File

@ -8,28 +8,56 @@ import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.MoneyValue; import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.FileSelectionArea; 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.application.Platform;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import javafx.scene.layout.VBox;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
public class CreateBalanceRecordController implements RouteSelectionListener { public class CreateBalanceRecordController implements RouteSelectionListener {
@FXML public TextField timestampField; @FXML public TextField timestampField;
@FXML public TextField balanceField; @FXML public TextField balanceField;
@FXML public VBox attachmentsVBox;
private FileSelectionArea attachmentSelectionArea; private FileSelectionArea attachmentSelectionArea;
@FXML public PropertiesPane propertiesPane;
@FXML public Button saveButton;
private Account account; private Account account;
@FXML public void initialize() { @FXML public void initialize() {
attachmentSelectionArea = new FileSelectionArea(FileUtil::newAttachmentsFileChooser, () -> attachmentsVBox.getScene().getWindow()); var timestampValid = new ValidationApplier<String>(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); attachmentSelectionArea.allowMultiple.set(true);
attachmentsVBox.getChildren().add(attachmentSelectionArea); propertiesPane.getChildren().addLast(attachmentSelectionArea);
} }
@Override @Override
@ -48,19 +76,29 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
} }
@FXML public void save() { @FXML public void save() {
// TODO: Add validation. LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
Profile.getCurrent().getDataSource().useBalanceRecordRepository(repo -> { BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); boolean confirm = Popups.confirm("Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted(
BigDecimal reportedBalance = new BigDecimal(balanceField.getText()); account.getShortName(),
repo.insert( CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
DateUtil.localToUTC(localTimestamp), localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
account.id, ));
reportedBalance, if (confirm) {
account.getCurrency(), Profile.getCurrent().getDataSource().useAccountRepository(accountRepo -> {
attachmentSelectionArea.getSelectedFiles() BigDecimal currentDerivedBalance = accountRepo.deriveCurrentBalance(account.id);
);
}); });
router.navigateBackAndClear(); Profile.getCurrent().getDataSource().useBalanceRecordRepository(repo -> {
repo.insert(
DateUtil.localToUTC(localTimestamp),
account.id,
reportedBalance,
account.getCurrency(),
attachmentSelectionArea.getSelectedFiles()
);
});
router.navigateBackAndClear();
}
} }
@FXML public void cancel() { @FXML public void cancel() {

View File

@ -46,9 +46,6 @@ public class CreateTransactionController implements RouteSelectionListener {
@FXML public Button saveButton; @FXML public Button saveButton;
public CreateTransactionController() {
}
@FXML public void initialize() { @FXML public void initialize() {
// Setup error field validation. // Setup error field validation.
var timestampValid = new ValidationApplier<>(new PredicateValidator<String>() var timestampValid = new ValidationApplier<>(new PredicateValidator<String>()

View File

@ -1,5 +1,6 @@
package com.andrewlalis.perfin.data; package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.AccountEntry;
import java.math.BigDecimal; import java.math.BigDecimal;
@ -17,4 +18,5 @@ public interface AccountEntryRepository extends AutoCloseable {
Currency currency Currency currency
); );
List<AccountEntry> findAllByAccountId(long accountId); List<AccountEntry> findAllByAccountId(long accountId);
List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax);
} }

View File

@ -7,9 +7,12 @@ import java.nio.file.Path;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Currency; import java.util.Currency;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface BalanceRecordRepository extends AutoCloseable { public interface BalanceRecordRepository extends AutoCloseable {
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments); long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
BalanceRecord findLatestByAccountId(long accountId); BalanceRecord findLatestByAccountId(long accountId);
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
void deleteById(long id); void deleteById(long id);
} }

View File

@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository; import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.AccountEntry;
@ -46,6 +47,20 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
); );
} }
@Override
public List<AccountEntry> 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 @Override
public void close() throws Exception { public void close() throws Exception {
conn.close(); conn.close();

View File

@ -1,6 +1,8 @@
package com.andrewlalis.perfin.data.impl; package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.EntityNotFoundException; import com.andrewlalis.perfin.data.EntityNotFoundException;
import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest; 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.AccountEntry;
import com.andrewlalis.perfin.model.AccountType; import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.BalanceRecord; import com.andrewlalis.perfin.model.BalanceRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Path;
import java.sql.Connection; import java.sql.Connection;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*; 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 @Override
public long insert(AccountType type, String accountNumber, String name, Currency currency) { public long insert(AccountType type, String accountNumber, String name, Currency currency) {
return DbUtil.doTransaction(conn, () -> { 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. // First find the account itself, since its properties influence the balance.
Account account = findById(accountId).orElse(null); Account account = findById(accountId).orElse(null);
if (account == null) throw new EntityNotFoundException(Account.class, accountId); 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. // Find the most recent balance record before timestamp.
Optional<BalanceRecord> closestPastRecord = DbUtil.findOne( Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp);
conn,
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
List.of(accountId, DbUtil.timestampFromInstant(timestamp)),
JdbcBalanceRecordRepository::parse
);
if (closestPastRecord.isPresent()) { if (closestPastRecord.isPresent()) {
// Then find any entries on the account since that balance record and the timestamp. // Then find any entries on the account since that balance record and the timestamp.
List<AccountEntry> entriesAfterRecord = DbUtil.findAll( List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
conn, account.id,
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC", closestPastRecord.get().getTimestamp(),
List.of( utcTimestamp
accountId,
DbUtil.timestampFromUtcLDT(closestPastRecord.get().getTimestamp()),
DbUtil.timestampFromInstant(timestamp)
),
JdbcAccountEntryRepository::parse
); );
return computeBalanceWithEntriesAfter(account, closestPastRecord.get(), entriesAfterRecord); return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow);
} else { } else {
// There is no balance record present before the given timestamp. Try and find the closest one after. // There is no balance record present before the given timestamp. Try and find the closest one after.
Optional<BalanceRecord> closestFutureRecord = DbUtil.findOne( Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp);
conn, if (closestFutureRecord.isPresent()) {
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1", // Now find any entries on the account from the timestamp until that balance record.
List.of(accountId, DbUtil.timestampFromInstant(timestamp)), List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
JdbcBalanceRecordRepository::parse account.id,
); utcTimestamp,
if (closestFutureRecord.isEmpty()) { closestFutureRecord.get().getTimestamp()
throw new IllegalStateException("No balance record exists for account " + accountId); );
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<AccountEntry> 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<AccountEntry> 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(); conn.close();
} }
private BigDecimal computeBalanceWithEntriesAfter(Account account, BalanceRecord balanceRecord, List<AccountEntry> entriesAfterRecord) { private BigDecimal computeBalanceWithEntries(AccountType accountType, BalanceRecord balanceRecord, List<AccountEntry> entries) {
BigDecimal balance = balanceRecord.getBalance(); List<AccountEntry> entriesBeforeRecord = entries.stream()
for (AccountEntry entry : entriesAfterRecord) { .filter(entry -> entry.getTimestamp().isBefore(balanceRecord.getTimestamp()))
balance = balance.add(entry.getEffectiveValue(account.getType())); .toList();
} List<AccountEntry> entriesAfterRecord = entries.stream()
return balance; .filter(entry -> entry.getTimestamp().isAfter(balanceRecord.getTimestamp()))
} .toList();
private BigDecimal computeBalanceWithEntriesBefore(Account account, BalanceRecord balanceRecord, List<AccountEntry> entriesBeforeRecord) {
BigDecimal balance = balanceRecord.getBalance(); BigDecimal balance = balanceRecord.getBalance();
for (AccountEntry entry : entriesBeforeRecord) { 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; return balance;
} }

View File

@ -15,6 +15,7 @@ import java.sql.SQLException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Currency; import java.util.Currency;
import java.util.List; import java.util.List;
import java.util.Optional;
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository { public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
@Override @Override
@ -51,6 +52,26 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
).orElse(null); ).orElse(null);
} }
@Override
public Optional<BalanceRecord> 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<BalanceRecord> 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 @Override
public void deleteById(long id) { public void deleteById(long id) {
DbUtil.updateOne(conn, "DELETE FROM balance_record WHERE id = ?", List.of(id)); DbUtil.updateOne(conn, "DELETE FROM balance_record WHERE id = ?", List.of(id));

View File

@ -1,35 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?> <?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.text.TextFlow?>
<BorderPane xmlns="http://javafx.com/javafx" <BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.CreateBalanceRecordController" fx:controller="com.andrewlalis.perfin.control.CreateBalanceRecordController"
stylesheets="@style/base.css"
> >
<top> <top>
<HBox styleClass="std-padding,std-spacing"> <Label text="Create New Balance Record" styleClass="large-text,bold-text,std-padding"/>
<Label text="Create New Balance Record" styleClass="large-text,bold-text"/>
</HBox>
</top> </top>
<center> <center>
<VBox styleClass="std-padding,std-spacing"> <VBox styleClass="std-padding,std-spacing" style="-fx-max-width: 500px;" BorderPane.alignment="TOP_LEFT">
<VBox> <VBox styleClass="padding-extra,spacing-extra,small-font">
<Label text="Timestamp" labelFor="${timestampField}"/> <TextFlow>
<TextField fx:id="timestampField"/> <Text styleClass="secondary-color-fill">
</VBox> Create a new recording of the current balance of your
<VBox> account. This will serve as a sort of key-frame; a true,
<Label text="Balance" labelFor="${balanceField}"/> known balance that Perfin will use to derive your account's
<TextField fx:id="balanceField"/> current balance by applying recent transactions to it.
</VBox> </Text>
<VBox fx:id="attachmentsVBox"> </TextFlow>
<Label text="Attachments"/> <TextFlow>
<Text styleClass="secondary-color-fill">
Therefore, it's important to make balance records
periodically so that Perfin's calculations are accurate, and
it serves as a nice sanity-check to make sure all your
transactions add up.
</Text>
</TextFlow>
</VBox> </VBox>
<PropertiesPane vgap="5" hgap="5" fx:id="propertiesPane">
<Label text="Timestamp" labelFor="${timestampField}" styleClass="bold-text"/>
<TextField fx:id="timestampField" styleClass="mono-font"/>
<Label text="Balance" labelFor="${balanceField}" styleClass="bold-text"/>
<TextField fx:id="balanceField" styleClass="mono-font"/>
<Label text="Attachments" styleClass="bold-text"/>
</PropertiesPane>
<Separator/>
<HBox styleClass="std-padding,std-spacing">
<Button text="Save" fx:id="saveButton" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/>
</HBox>
</VBox> </VBox>
</center> </center>
<bottom>
<HBox styleClass="std-padding,std-spacing">
<Button text="Save" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/>
</HBox>
</bottom>
</BorderPane> </BorderPane>

View File

@ -10,7 +10,7 @@
> >
<center> <center>
<ScrollPane fitToWidth="true" fitToHeight="true"> <ScrollPane fitToWidth="true" fitToHeight="true">
<VBox style="-fx-max-width: 400px;"> <VBox style="-fx-max-width: 500px;">
<!-- Basic properties --> <!-- Basic properties -->
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding"> <PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
<columnConstraints> <columnConstraints>

View File

@ -15,7 +15,7 @@
</HBox> </HBox>
</top> </top>
<center> <center>
<VBox style="-fx-max-width: 400px;" BorderPane.alignment="TOP_LEFT"> <VBox style="-fx-max-width: 500px;" BorderPane.alignment="TOP_LEFT">
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding"> <PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
<columnConstraints> <columnConstraints>
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/> <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>

View File

@ -25,11 +25,16 @@ rather than with your own CSS.
-fx-font-family: "JetBrains Mono", monospace; -fx-font-family: "JetBrains Mono", monospace;
} }
.largest-font {
-fx-font-size: 24px;
}
.large-font { .large-font {
-fx-font-size: 18px; -fx-font-size: 18px;
} }
.small-font { .small-font {
-fx-font-size: 12px;
}
.smallest-font {
-fx-font-size: 10px; -fx-font-size: 10px;
} }
@ -101,6 +106,9 @@ rather than with your own CSS.
-fx-text-fill: -fx-theme-text; -fx-text-fill: -fx-theme-text;
} }
.secondary-color-text-fill {
-fx-text-fill: -fx-theme-text-secondary;
}
.secondary-color-fill { .secondary-color-fill {
-fx-fill: -fx-theme-text-secondary; -fx-fill: -fx-theme-text-secondary;
} }