Add balance record "type" attribute, for cash and assets.

This commit is contained in:
Andrew Lalis 2024-07-10 08:45:30 -04:00
parent d4bd5cc6ec
commit feda2e1897
11 changed files with 78 additions and 30 deletions

View File

@ -6,6 +6,7 @@ import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.BalanceRecordType;
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;
@ -29,6 +30,10 @@ import java.time.format.DateTimeParseException;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
/**
* Controller for the page where users can create a balance record for an
* account.
*/
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;
@ -102,6 +107,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
repo.insert( repo.insert(
DateUtil.localToUTC(localTimestamp), DateUtil.localToUTC(localTimestamp),
account.id, account.id,
BalanceRecordType.CASH,
reportedBalance, reportedBalance,
account.getCurrency(), account.getCurrency(),
attachmentSelectionArea.getSelectedPaths() attachmentSelectionArea.getSelectedPaths()

View File

@ -2,10 +2,7 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.PropertiesPane; import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
@ -132,7 +129,7 @@ public class EditAccountController implements RouteSelectionListener {
boolean success = Popups.confirm(accountNameField, prompt); boolean success = Popups.confirm(accountNameField, prompt);
if (success) { if (success) {
long id = accountRepo.insert(type, number, name, currency, description); long id = accountRepo.insert(type, number, name, currency, description);
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments); balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, BalanceRecordType.CASH, initialBalance, currency, attachments);
// Once we create the new account, go to the account. // Once we create the new account, go to the account.
Account newAccount = accountRepo.findById(id).orElseThrow(); Account newAccount = accountRepo.findById(id).orElseThrow();
router.replace("account", newAccount); router.replace("account", newAccount);

View File

@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.Attachment; import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.BalanceRecord; import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.BalanceRecordType;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Path; import java.nio.file.Path;
@ -11,11 +12,11 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface BalanceRecordRepository extends Repository, AutoCloseable { public interface BalanceRecordRepository extends Repository, AutoCloseable {
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments); long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments);
BalanceRecord findLatestByAccountId(long accountId); BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type);
Optional<BalanceRecord> findById(long id); Optional<BalanceRecord> findById(long id);
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp); Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp); Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
List<Attachment> findAttachments(long recordId); List<Attachment> findAttachments(long recordId);
void deleteById(long id); void deleteById(long id);
} }

View File

@ -122,7 +122,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir); BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn); AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn);
// Find the most recent balance record before timestamp. // Find the most recent balance record before timestamp.
Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp); Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, BalanceRecordType.CASH, utcTimestamp);
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> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween( List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
@ -133,7 +133,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow); 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 = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp); Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, BalanceRecordType.CASH, utcTimestamp);
if (closestFutureRecord.isPresent()) { if (closestFutureRecord.isPresent()) {
// Now find any entries on the account from the timestamp until that balance record. // Now find any entries on the account from the timestamp until that balance record.
List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween( List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
@ -145,7 +145,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
} else { } else {
// No balance records exist for the account! Assume balance of 0 when the account was created. // 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()); 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()); BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BalanceRecordType.CASH, BigDecimal.ZERO, account.getCurrency());
List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp); List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp);
return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated); return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated);
} }
@ -177,7 +177,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
LEFT JOIN history_account ha ON history_item.history_id = ha.history_id LEFT JOIN history_account ha ON history_item.history_id = ha.history_id
UNION ALL UNION ALL
SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id
FROM balance_record FROM balance_record WHERE type = 'CASH'
) )
WHERE account_id = ? AND timestamp < ? WHERE account_id = ? AND timestamp < ?
ORDER BY timestamp DESC ORDER BY timestamp DESC

View File

@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Attachment; import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.BalanceRecord; import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.BalanceRecordType;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Path; import java.nio.file.Path;
@ -18,12 +19,12 @@ import java.util.Optional;
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository { public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
@Override @Override
public long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments) { public long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments) {
return DbUtil.doTransaction(conn, () -> { return DbUtil.doTransaction(conn, () -> {
long recordId = DbUtil.insertOne( long recordId = DbUtil.insertOne(
conn, conn,
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)", "INSERT INTO balance_record (timestamp, account_id, type, balance, currency) VALUES (?, ?, ?, ?, ?)",
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, balance, currency.getCurrencyCode()) List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, type.name(), balance, currency.getCurrencyCode())
); );
// Insert attachments. // Insert attachments.
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
@ -39,11 +40,11 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
} }
@Override @Override
public BalanceRecord findLatestByAccountId(long accountId) { public BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type) {
return DbUtil.findOne( return DbUtil.findOne(
conn, conn,
"SELECT * FROM balance_record WHERE account_id = ? ORDER BY timestamp DESC LIMIT 1", "SELECT * FROM balance_record WHERE account_id = ? AND type = ? ORDER BY timestamp DESC LIMIT 1",
List.of(accountId), List.of(accountId, type.name()),
JdbcBalanceRecordRepository::parse JdbcBalanceRecordRepository::parse
).orElse(null); ).orElse(null);
} }
@ -59,21 +60,21 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
} }
@Override @Override
public Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp) { public Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
return DbUtil.findOne( return DbUtil.findOne(
conn, conn,
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1", "SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)), List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
JdbcBalanceRecordRepository::parse JdbcBalanceRecordRepository::parse
); );
} }
@Override @Override
public Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp) { public Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
return DbUtil.findOne( return DbUtil.findOne(
conn, conn,
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1", "SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)), List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
JdbcBalanceRecordRepository::parse JdbcBalanceRecordRepository::parse
); );
} }
@ -108,6 +109,7 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
rs.getLong("id"), rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")), DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getLong("account_id"), rs.getLong("account_id"),
BalanceRecordType.valueOf(rs.getString("type").toUpperCase()),
rs.getBigDecimal("balance"), rs.getBigDecimal("balance"),
Currency.getInstance(rs.getString("currency")) Currency.getInstance(rs.getString("currency"))
); );

View File

@ -34,8 +34,11 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
* loaded with an old schema version, then we'll migrate to the latest. If * loaded with an old schema version, then we'll migrate to the latest. If
* the profile has a newer schema version, we'll exit and prompt the user * the profile has a newer schema version, we'll exit and prompt the user
* to update their app. * to update their app.
* <p>
* This value should be one higher than the
* </p>
*/ */
public static final int SCHEMA_VERSION = 4; public static final int SCHEMA_VERSION = 5;
public DataSource getDataSource(String profileName) throws ProfileLoadException { public DataSource getDataSource(String profileName) throws ProfileLoadException {
final boolean dbExists = Files.exists(getDatabaseFile(profileName)); final boolean dbExists = Files.exists(getDatabaseFile(profileName));

View File

@ -19,6 +19,7 @@ public class Migrations {
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql")); migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql")); migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql")); migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
migrations.put(4, new PlainSQLMigration("/sql/migration/M004_AddBrokerageValueRecords.sql"));
return migrations; return migrations;
} }

View File

@ -5,20 +5,20 @@ import java.time.LocalDateTime;
import java.util.Currency; import java.util.Currency;
/** /**
* A recording of an account's real reported balance at a given point in time, * A recording of an account's real reported balance at a given point in time.
* used as a sanity check for ensuring that an account's entries add up to the
* correct balance.
*/ */
public class BalanceRecord extends IdEntity implements Timestamped { public class BalanceRecord extends IdEntity implements Timestamped {
private final LocalDateTime timestamp; private final LocalDateTime timestamp;
private final long accountId; private final long accountId;
private final BalanceRecordType type;
private final BigDecimal balance; private final BigDecimal balance;
private final Currency currency; private final Currency currency;
public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BigDecimal balance, Currency currency) { public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency) {
super(id); super(id);
this.timestamp = timestamp; this.timestamp = timestamp;
this.accountId = accountId; this.accountId = accountId;
this.type = type;
this.balance = balance; this.balance = balance;
this.currency = currency; this.currency = currency;
} }
@ -31,6 +31,10 @@ public class BalanceRecord extends IdEntity implements Timestamped {
return accountId; return accountId;
} }
public BalanceRecordType getType() {
return type;
}
public BigDecimal getBalance() { public BigDecimal getBalance() {
return balance; return balance;
} }

View File

@ -0,0 +1,17 @@
package com.andrewlalis.perfin.model;
public enum BalanceRecordType {
CASH("Cash"),
ASSETS("Assets");
private final String name;
BalanceRecordType(String name) {
this.name = name;
}
@Override
public String toString() {
return name;
}
}

View File

@ -0,0 +1,16 @@
/*
This migration adds a new entity specifically for brokerage accounts: the asset
value record. This records the approximate value of the brokerage account assets
excluding cash (which is already recorded).
This allows users to include their brokerage/investment assets in their Perfin
profile for analysis, and paves the way for adding integrations with brokerage
APIs to automate asset value record fetching.
Note that at the moment, asset value records only make sense for brokerage
accounts, but in the future more account types might be added for which this
would make sense.
*/
ALTER TABLE balance_record
ADD COLUMN type ENUM('CASH', 'ASSETS') NOT NULL DEFAULT 'CASH' AFTER account_id;

View File

@ -128,6 +128,7 @@ CREATE TABLE balance_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
timestamp TIMESTAMP NOT NULL, timestamp TIMESTAMP NOT NULL,
account_id BIGINT NOT NULL, account_id BIGINT NOT NULL,
type ENUM('CASH', 'ASSETS') NOT NULL DEFAULT 'CASH',
balance NUMERIC(12, 4) NOT NULL, balance NUMERIC(12, 4) NOT NULL,
currency VARCHAR(3) NOT NULL, currency VARCHAR(3) NOT NULL,
CONSTRAINT fk_balance_record_account CONSTRAINT fk_balance_record_account