From feda2e189758b2c855e25a313ea115371bc0f109 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Wed, 10 Jul 2024 08:45:30 -0400 Subject: [PATCH] Add balance record "type" attribute, for cash and assets. --- .../CreateBalanceRecordController.java | 6 +++++ .../perfin/control/EditAccountController.java | 7 ++--- .../perfin/data/BalanceRecordRepository.java | 9 ++++--- .../data/impl/JdbcAccountRepository.java | 8 +++--- .../impl/JdbcBalanceRecordRepository.java | 26 ++++++++++--------- .../data/impl/JdbcDataSourceFactory.java | 5 +++- .../data/impl/migration/Migrations.java | 1 + .../perfin/model/BalanceRecord.java | 12 ++++++--- .../perfin/model/BalanceRecordType.java | 17 ++++++++++++ .../M004_AddBrokerageValueRecords.sql | 16 ++++++++++++ src/main/resources/sql/schema.sql | 1 + 11 files changed, 78 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/andrewlalis/perfin/model/BalanceRecordType.java create mode 100644 src/main/resources/sql/migration/M004_AddBrokerageValueRecords.sql diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java index b0a1978..53f372d 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -6,6 +6,7 @@ import com.andrewlalis.perfin.data.BalanceRecordRepository; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.Account; +import com.andrewlalis.perfin.model.BalanceRecordType; import com.andrewlalis.perfin.model.MoneyValue; import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.view.component.FileSelectionArea; @@ -29,6 +30,10 @@ import java.time.format.DateTimeParseException; 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 { @FXML public TextField timestampField; @FXML public TextField balanceField; @@ -102,6 +107,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { repo.insert( DateUtil.localToUTC(localTimestamp), account.id, + BalanceRecordType.CASH, reportedBalance, account.getCurrency(), attachmentSelectionArea.getSelectedPaths() diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java index 0f6d631..85a91ca 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java @@ -2,10 +2,7 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.perfin.data.util.CurrencyUtil; -import com.andrewlalis.perfin.model.Account; -import com.andrewlalis.perfin.model.AccountType; -import com.andrewlalis.perfin.model.MoneyValue; -import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.view.component.PropertiesPane; import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; @@ -132,7 +129,7 @@ public class EditAccountController implements RouteSelectionListener { boolean success = Popups.confirm(accountNameField, prompt); if (success) { 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. Account newAccount = accountRepo.findById(id).orElseThrow(); router.replace("account", newAccount); diff --git a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java index d4f3419..d7f0b6e 100644 --- a/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/BalanceRecordRepository.java @@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data; import com.andrewlalis.perfin.model.Attachment; import com.andrewlalis.perfin.model.BalanceRecord; +import com.andrewlalis.perfin.model.BalanceRecordType; import java.math.BigDecimal; import java.nio.file.Path; @@ -11,11 +12,11 @@ import java.util.List; import java.util.Optional; public interface BalanceRecordRepository extends Repository, AutoCloseable { - long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List attachments); - BalanceRecord findLatestByAccountId(long accountId); + long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List attachments); + BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type); Optional findById(long id); - Optional findClosestBefore(long accountId, LocalDateTime utcTimestamp); - Optional findClosestAfter(long accountId, LocalDateTime utcTimestamp); + Optional findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp); + Optional findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp); List findAttachments(long recordId); void deleteById(long id); } 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 c9eb1da..3da41e5 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -122,7 +122,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir); AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn); // Find the most recent balance record before timestamp. - Optional closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp); + Optional closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, BalanceRecordType.CASH, utcTimestamp); if (closestPastRecord.isPresent()) { // Then find any entries on the account since that balance record and the timestamp. List entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween( @@ -133,7 +133,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements 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 = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp); + Optional closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, BalanceRecordType.CASH, utcTimestamp); if (closestFutureRecord.isPresent()) { // Now find any entries on the account from the timestamp until that balance record. List entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween( @@ -145,7 +145,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements } 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()); + BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BalanceRecordType.CASH, BigDecimal.ZERO, account.getCurrency()); List entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp); 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 UNION ALL SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id - FROM balance_record + FROM balance_record WHERE type = 'CASH' ) WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC 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 8727a11..c5493eb 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java @@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.BalanceRecordRepository; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.Attachment; import com.andrewlalis.perfin.model.BalanceRecord; +import com.andrewlalis.perfin.model.BalanceRecordType; import java.math.BigDecimal; import java.nio.file.Path; @@ -18,12 +19,12 @@ import java.util.Optional; public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository { @Override - public long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List attachments) { + public long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List attachments) { return DbUtil.doTransaction(conn, () -> { long recordId = DbUtil.insertOne( conn, - "INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)", - List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, balance, currency.getCurrencyCode()) + "INSERT INTO balance_record (timestamp, account_id, type, balance, currency) VALUES (?, ?, ?, ?, ?)", + List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, type.name(), balance, currency.getCurrencyCode()) ); // Insert attachments. AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); @@ -39,11 +40,11 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl } @Override - public BalanceRecord findLatestByAccountId(long accountId) { + public BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type) { return DbUtil.findOne( conn, - "SELECT * FROM balance_record WHERE account_id = ? ORDER BY timestamp DESC LIMIT 1", - List.of(accountId), + "SELECT * FROM balance_record WHERE account_id = ? AND type = ? ORDER BY timestamp DESC LIMIT 1", + List.of(accountId, type.name()), JdbcBalanceRecordRepository::parse ).orElse(null); } @@ -59,21 +60,21 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl } @Override - public Optional findClosestBefore(long accountId, LocalDateTime utcTimestamp) { + public Optional findClosestBefore(long accountId, BalanceRecordType type, 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)), + "SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1", + List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)), JdbcBalanceRecordRepository::parse ); } @Override - public Optional findClosestAfter(long accountId, LocalDateTime utcTimestamp) { + public Optional findClosestAfter(long accountId, BalanceRecordType type, 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)), + "SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1", + List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)), JdbcBalanceRecordRepository::parse ); } @@ -108,6 +109,7 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl rs.getLong("id"), DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")), rs.getLong("account_id"), + BalanceRecordType.valueOf(rs.getString("type").toUpperCase()), rs.getBigDecimal("balance"), Currency.getInstance(rs.getString("currency")) ); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index 912d0c9..65c16e5 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -34,8 +34,11 @@ public class JdbcDataSourceFactory implements DataSourceFactory { * 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 * to update their app. + *

+ * This value should be one higher than the + *

*/ - public static final int SCHEMA_VERSION = 4; + public static final int SCHEMA_VERSION = 5; public DataSource getDataSource(String profileName) throws ProfileLoadException { final boolean dbExists = Files.exists(getDatabaseFile(profileName)); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java index 2e28bbe..4f9641b 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java @@ -19,6 +19,7 @@ public class Migrations { migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql")); migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql")); migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql")); + migrations.put(4, new PlainSQLMigration("/sql/migration/M004_AddBrokerageValueRecords.sql")); return migrations; } diff --git a/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java b/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java index 6ea213b..aaa8e1d 100644 --- a/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java +++ b/src/main/java/com/andrewlalis/perfin/model/BalanceRecord.java @@ -5,20 +5,20 @@ import java.time.LocalDateTime; import java.util.Currency; /** - * 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. + * A recording of an account's real reported balance at a given point in time. */ public class BalanceRecord extends IdEntity implements Timestamped { private final LocalDateTime timestamp; private final long accountId; + private final BalanceRecordType type; private final BigDecimal balance; 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); this.timestamp = timestamp; this.accountId = accountId; + this.type = type; this.balance = balance; this.currency = currency; } @@ -31,6 +31,10 @@ public class BalanceRecord extends IdEntity implements Timestamped { return accountId; } + public BalanceRecordType getType() { + return type; + } + public BigDecimal getBalance() { return balance; } diff --git a/src/main/java/com/andrewlalis/perfin/model/BalanceRecordType.java b/src/main/java/com/andrewlalis/perfin/model/BalanceRecordType.java new file mode 100644 index 0000000..71a2cf2 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/BalanceRecordType.java @@ -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; + } +} diff --git a/src/main/resources/sql/migration/M004_AddBrokerageValueRecords.sql b/src/main/resources/sql/migration/M004_AddBrokerageValueRecords.sql new file mode 100644 index 0000000..0565fd7 --- /dev/null +++ b/src/main/resources/sql/migration/M004_AddBrokerageValueRecords.sql @@ -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; diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index 5b22999..a5e4541 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -128,6 +128,7 @@ CREATE TABLE balance_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, timestamp TIMESTAMP NOT NULL, account_id BIGINT NOT NULL, + type ENUM('CASH', 'ASSETS') NOT NULL DEFAULT 'CASH', balance NUMERIC(12, 4) NOT NULL, currency VARCHAR(3) NOT NULL, CONSTRAINT fk_balance_record_account