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

View File

@ -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);

View File

@ -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<Path> attachments);
BalanceRecord findLatestByAccountId(long accountId);
long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments);
BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type);
Optional<BalanceRecord> findById(long id);
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
List<Attachment> findAttachments(long recordId);
void deleteById(long id);
}

View File

@ -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<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp);
Optional<BalanceRecord> 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<AccountEntry> 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<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp);
Optional<BalanceRecord> 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<AccountEntry> 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<AccountEntry> 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

View File

@ -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<Path> attachments) {
public long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> 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<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp) {
public Optional<BalanceRecord> 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<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp) {
public Optional<BalanceRecord> 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"))
);

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
* the profile has a newer schema version, we'll exit and prompt the user
* 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 {
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(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;
}

View File

@ -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;
}

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,
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