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.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<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);
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() {

View File

@ -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<String>()

View File

@ -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<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.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<Path> attachments);
BalanceRecord findLatestByAccountId(long accountId);
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
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.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<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
public void close() throws Exception {
conn.close();

View File

@ -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<BalanceRecord> 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<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp);
if (closestPastRecord.isPresent()) {
// Then find any entries on the account since that balance record and the timestamp.
List<AccountEntry> 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<AccountEntry> 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<BalanceRecord> 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<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp);
if (closestFutureRecord.isPresent()) {
// Now find any entries on the account from the timestamp until that balance record.
List<AccountEntry> 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<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();
}
private BigDecimal computeBalanceWithEntriesAfter(Account account, BalanceRecord balanceRecord, List<AccountEntry> 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<AccountEntry> entriesBeforeRecord) {
private BigDecimal computeBalanceWithEntries(AccountType accountType, BalanceRecord balanceRecord, List<AccountEntry> entries) {
List<AccountEntry> entriesBeforeRecord = entries.stream()
.filter(entry -> entry.getTimestamp().isBefore(balanceRecord.getTimestamp()))
.toList();
List<AccountEntry> 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;
}

View File

@ -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<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
public void deleteById(long 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"?>
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.text.TextFlow?>
<BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.CreateBalanceRecordController"
stylesheets="@style/base.css"
>
<top>
<HBox styleClass="std-padding,std-spacing">
<Label text="Create New Balance Record" styleClass="large-text,bold-text"/>
</HBox>
<Label text="Create New Balance Record" styleClass="large-text,bold-text,std-padding"/>
</top>
<center>
<VBox styleClass="std-padding,std-spacing">
<VBox>
<Label text="Timestamp" labelFor="${timestampField}"/>
<TextField fx:id="timestampField"/>
</VBox>
<VBox>
<Label text="Balance" labelFor="${balanceField}"/>
<TextField fx:id="balanceField"/>
</VBox>
<VBox fx:id="attachmentsVBox">
<Label text="Attachments"/>
<VBox styleClass="std-padding,std-spacing" style="-fx-max-width: 500px;" BorderPane.alignment="TOP_LEFT">
<VBox styleClass="padding-extra,spacing-extra,small-font">
<TextFlow>
<Text styleClass="secondary-color-fill">
Create a new recording of the current balance of your
account. This will serve as a sort of key-frame; a true,
known balance that Perfin will use to derive your account's
current balance by applying recent transactions to it.
</Text>
</TextFlow>
<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>
<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>
</center>
<bottom>
<HBox styleClass="std-padding,std-spacing">
<Button text="Save" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/>
</HBox>
</bottom>
</BorderPane>

View File

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

View File

@ -15,7 +15,7 @@
</HBox>
</top>
<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">
<columnConstraints>
<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;
}
.largest-font {
-fx-font-size: 24px;
}
.large-font {
-fx-font-size: 18px;
}
.small-font {
-fx-font-size: 12px;
}
.smallest-font {
-fx-font-size: 10px;
}
@ -101,6 +106,9 @@ rather than with your own CSS.
-fx-text-fill: -fx-theme-text;
}
.secondary-color-text-fill {
-fx-text-fill: -fx-theme-text-secondary;
}
.secondary-color-fill {
-fx-fill: -fx-theme-text-secondary;
}