Refactored account history.

This commit is contained in:
Andrew Lalis 2024-02-03 22:59:29 -05:00
parent 3493003588
commit 8f36380e21
24 changed files with 349 additions and 333 deletions

View File

@ -1,12 +1,12 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.HistoryRepository;
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.Profile; import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.history.AccountHistoryItem; import com.andrewlalis.perfin.model.history.HistoryItem;
import com.andrewlalis.perfin.view.component.AccountHistoryItemTile; import com.andrewlalis.perfin.view.component.AccountHistoryItemTile;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
@ -131,20 +131,15 @@ public class AccountViewController implements RouteSelectionListener {
} }
@FXML public void loadMoreHistory() { @FXML public void loadMoreHistory() {
Profile.getCurrent().dataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(HistoryRepository.class, repo -> {
List<AccountHistoryItem> historyItems = repo.findMostRecentForAccount( long historyId = repo.getOrCreateHistoryForAccount(account.id);
account.id, List<HistoryItem> items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom);
loadHistoryFrom, if (items.size() < historyLoadSize) {
historyLoadSize
);
if (historyItems.size() < historyLoadSize) {
Platform.runLater(() -> loadMoreHistoryButton.setDisable(true)); Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
} else { } else {
loadHistoryFrom = historyItems.getLast().getTimestamp(); loadHistoryFrom = items.getLast().getTimestamp();
} }
List<? extends Node> nodes = historyItems.stream() List<? extends Node> nodes = items.stream().map(AccountHistoryItemTile::forItem).toList();
.map(item -> AccountHistoryItemTile.forItem(item, repo, this))
.toList();
Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes)); Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
}); });
} }

View File

@ -1,25 +0,0 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface AccountHistoryItemRepository extends Repository, AutoCloseable {
void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId);
void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId);
void recordText(LocalDateTime timestamp, long accountId, String text);
List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count);
default Optional<AccountHistoryItem> getMostRecentForAccount(long accountId) {
var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1);
if (items.isEmpty()) return Optional.empty();
return Optional.of(items.getFirst());
}
String getTextItem(long itemId);
AccountEntry getAccountEntryItem(long itemId);
BalanceRecord getBalanceRecordItem(long itemId);
}

View File

@ -33,7 +33,7 @@ public interface DataSource {
TransactionVendorRepository getTransactionVendorRepository(); TransactionVendorRepository getTransactionVendorRepository();
TransactionCategoryRepository getTransactionCategoryRepository(); TransactionCategoryRepository getTransactionCategoryRepository();
AttachmentRepository getAttachmentRepository(); AttachmentRepository getAttachmentRepository();
AccountHistoryItemRepository getAccountHistoryItemRepository(); HistoryRepository getHistoryRepository();
// Repository helper methods: // Repository helper methods:
@ -86,7 +86,7 @@ public interface DataSource {
TransactionVendorRepository.class, this::getTransactionVendorRepository, TransactionVendorRepository.class, this::getTransactionVendorRepository,
TransactionCategoryRepository.class, this::getTransactionCategoryRepository, TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
AttachmentRepository.class, this::getAttachmentRepository, AttachmentRepository.class, this::getAttachmentRepository,
AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository HistoryRepository.class, this::getHistoryRepository
); );
return (Supplier<R>) repoSuppliers.get(type); return (Supplier<R>) repoSuppliers.get(type);
} }

View File

@ -0,0 +1,28 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.history.HistoryItem;
import com.andrewlalis.perfin.model.history.HistoryTextItem;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
public interface HistoryRepository extends Repository, AutoCloseable {
long getOrCreateHistoryForAccount(long accountId);
long getOrCreateHistoryForTransaction(long transactionId);
void deleteHistoryForAccount(long accountId);
void deleteHistoryForTransaction(long transactionId);
HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description);
default HistoryTextItem addTextItem(long historyId, String description) {
return addTextItem(historyId, DateUtil.nowAsUTC(), description);
}
Page<HistoryItem> getItems(long historyId, PageRequest pagination);
List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
default List<HistoryItem> getNItemsBeforeNow(long historyId, int n) {
return getNItemsBefore(historyId, n, LocalDateTime.now(ZoneOffset.UTC));
}
}

View File

@ -1,7 +1,7 @@
package com.andrewlalis.perfin.data.impl; 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.HistoryRepository;
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;
@ -31,8 +31,9 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
) )
); );
// Insert an entry into the account's history. // Insert an entry into the account's history.
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
historyRepo.recordAccountEntry(timestamp, accountId, entryId); long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, timestamp, "Entry #" + entryId + " added as a " + type.name() + " from Transaction #" + transactionId + ".");
return entryId; return entryId;
} }

View File

@ -1,120 +0,0 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import com.andrewlalis.perfin.model.history.AccountHistoryItemType;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.List;
public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository {
@Override
public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) {
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY);
DbUtil.insertOne(
conn,
"INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)",
List.of(itemId, entryId)
);
}
@Override
public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) {
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD);
DbUtil.insertOne(
conn,
"INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)",
List.of(itemId, recordId)
);
}
@Override
public void recordText(LocalDateTime timestamp, long accountId, String text) {
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT);
DbUtil.insertOne(
conn,
"INSERT INTO account_history_item_text (item_id, description) VALUES (?, ?)",
List.of(itemId, text)
);
}
@Override
public List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count) {
return DbUtil.findAll(
conn,
"SELECT * FROM account_history_item WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT " + count,
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
JdbcAccountHistoryItemRepository::parseHistoryItem
);
}
@Override
public String getTextItem(long itemId) {
return DbUtil.findOne(
conn,
"SELECT description FROM account_history_item_text WHERE item_id = ?",
List.of(itemId),
rs -> rs.getString(1)
).orElse(null);
}
@Override
public AccountEntry getAccountEntryItem(long itemId) {
return DbUtil.findOne(
conn,
"""
SELECT *
FROM account_entry
LEFT JOIN account_history_item_account_entry h ON h.entry_id = account_entry.id
WHERE h.item_id = ?""",
List.of(itemId),
JdbcAccountEntryRepository::parse
).orElse(null);
}
@Override
public BalanceRecord getBalanceRecordItem(long itemId) {
return DbUtil.findOne(
conn,
"""
SELECT *
FROM balance_record
LEFT JOIN account_history_item_balance_record h ON h.record_id = balance_record.id
WHERE h.item_id = ?""",
List.of(itemId),
JdbcBalanceRecordRepository::parse
).orElse(null);
}
@Override
public void close() throws Exception {
conn.close();
}
public static AccountHistoryItem parseHistoryItem(ResultSet rs) throws SQLException {
return new AccountHistoryItem(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getLong("account_id"),
AccountHistoryItemType.valueOf(rs.getString("type"))
);
}
private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
return DbUtil.insertOne(
conn,
"INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)",
List.of(
DbUtil.timestampFromUtcLDT(timestamp),
accountId,
type.name()
)
);
}
}

View File

@ -1,12 +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.*;
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.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.AccountEntry;
@ -43,8 +39,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
) )
); );
// Insert a history item indicating the creation of the account. // Insert a history item indicating the creation of the account.
var historyRepo = new JdbcAccountHistoryItemRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile."); long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, "Account added to your Perfin profile.");
return accountId; return accountId;
}); });
} }
@ -59,11 +56,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
return DbUtil.findAll( return DbUtil.findAll(
conn, conn,
""" """
SELECT DISTINCT ON (account.id) account.*, ahi.timestamp AS _ SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
FROM account FROM account
LEFT OUTER JOIN account_history_item ahi ON ahi.account_id = account.id LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
WHERE NOT account.archived WHERE NOT account.archived
ORDER BY ahi.timestamp DESC, account.created_at DESC""", ORDER BY hi.timestamp DESC, account.created_at DESC""",
JdbcAccountRepository::parseAccount JdbcAccountRepository::parseAccount
); );
} }
@ -160,7 +158,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
public void archive(long accountId) { public void archive(long accountId) {
DbUtil.doTransaction(conn, () -> { DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId)); DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId));
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been archived."); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, "Account has been archived.");
}); });
} }
@ -168,7 +168,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
public void unarchive(long accountId) { public void unarchive(long accountId) {
DbUtil.doTransaction(conn, () -> { DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId)); DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId));
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been unarchived."); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, "Account has been unarchived.");
}); });
} }

View File

@ -1,11 +1,13 @@
package com.andrewlalis.perfin.data.impl; package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.AttachmentRepository; import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository; import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
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.MoneyValue;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Path; import java.nio.file.Path;
@ -36,8 +38,9 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
} }
} }
// Add a history item entry. // Add a history item entry.
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId); long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, utcTimestamp, "Balance Record #" + recordId + " added with a value of " + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(balance, currency)));
return recordId; return recordId;
}); });
} }

View File

@ -65,7 +65,7 @@ public class JdbcDataSource implements DataSource {
} }
@Override @Override
public AccountHistoryItemRepository getAccountHistoryItemRepository() { public HistoryRepository getHistoryRepository() {
return new JdbcAccountHistoryItemRepository(getConnection()); return new JdbcHistoryRepository(getConnection());
} }
} }

View File

@ -35,7 +35,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
* 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.
*/ */
public static final int SCHEMA_VERSION = 2; public static final int SCHEMA_VERSION = 3;
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

@ -0,0 +1,125 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.history.HistoryItem;
import com.andrewlalis.perfin.model.history.HistoryTextItem;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.List;
public record JdbcHistoryRepository(Connection conn) implements HistoryRepository {
@Override
public long getOrCreateHistoryForAccount(long accountId) {
return getOrCreateHistoryForEntity(accountId, "history_account", "account_id");
}
@Override
public long getOrCreateHistoryForTransaction(long transactionId) {
return getOrCreateHistoryForEntity(transactionId, "history_transaction", "transaction_id");
}
private long getOrCreateHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
if (optionalHistoryId.isPresent()) return optionalHistoryId.get();
long historyId = DbUtil.insertOne(conn, "INSERT INTO history () VALUES ()");
String insertQuery = "INSERT INTO " + joinTableName + " (" + joinColumn + ", history_id) VALUES (?, ?)";
DbUtil.updateOne(conn, insertQuery, entityId, historyId);
return historyId;
}
@Override
public void deleteHistoryForAccount(long accountId) {
deleteHistoryForEntity(accountId, "history_account", "account_id");
}
@Override
public void deleteHistoryForTransaction(long transactionId) {
deleteHistoryForEntity(transactionId, "history_transaction", "transaction_id");
}
private void deleteHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
if (optionalHistoryId.isPresent()) {
long historyId = optionalHistoryId.get();
DbUtil.updateOne(conn, "DELETE FROM history WHERE id = ?", historyId);
}
}
@Override
public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) {
long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.TYPE_TEXT);
DbUtil.updateOne(
conn,
"INSERT INTO history_item_text (id, description) VALUES (?, ?)",
itemId,
description
);
return new HistoryTextItem(itemId, historyId, utcTimestamp, description);
}
private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) {
return DbUtil.insertOne(
conn,
"INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)",
historyId,
DbUtil.timestampFromUtcLDT(timestamp),
type
);
}
@Override
public Page<HistoryItem> getItems(long historyId, PageRequest pagination) {
return DbUtil.findAll(
conn,
"SELECT * FROM history_item WHERE history_id = ?",
pagination,
List.of(historyId),
JdbcHistoryRepository::parseItem
);
}
@Override
public List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp) {
return DbUtil.findAll(
conn,
"""
SELECT *
FROM history_item
WHERE history_id = ? AND timestamp <= ?
ORDER BY timestamp DESC""",
List.of(historyId, DbUtil.timestampFromUtcLDT(timestamp)),
JdbcHistoryRepository::parseItem
);
}
@Override
public void close() throws Exception {
conn.close();
}
public static HistoryItem parseItem(ResultSet rs) throws SQLException {
long id = rs.getLong(1);
long historyId = rs.getLong(2);
LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3));
String type = rs.getString(4);
if (type.equalsIgnoreCase(HistoryItem.TYPE_TEXT)) {
String description = DbUtil.findOne(
rs.getStatement().getConnection(),
"SELECT description FROM history_item_text WHERE id = ?",
List.of(id),
r -> r.getString(1)
).orElseThrow();
return new HistoryTextItem(id, historyId, timestamp, description);
}
throw new SQLException("Unknown history item type: " + type);
}
}

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.AttachmentRepository; import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.TransactionRepository;
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;
@ -386,9 +387,9 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
// Add a text history item to any linked accounts detailing the changes. // Add a text history item to any linked accounts detailing the changes.
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages); String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
var historyRepo = new JdbcAccountHistoryItemRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); long historyId = historyRepo.getOrCreateHistoryForTransaction(id);
linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); historyRepo.addTextItem(historyId, updateMessageStr);
}); });
} }

View File

@ -17,6 +17,7 @@ public class Migrations {
public static Map<Integer, Migration> getMigrations() { public static Map<Integer, Migration> getMigrations() {
final Map<Integer, Migration> migrations = new HashMap<>(); final Map<Integer, Migration> migrations = new HashMap<>();
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"));
return migrations; return migrations;
} }

View File

@ -1,36 +0,0 @@
package com.andrewlalis.perfin.model.history;
import com.andrewlalis.perfin.model.IdEntity;
import java.time.LocalDateTime;
/**
* The base class representing account history items, a read-only record of an
* account's data and changes over time. The type of history item determines
* what exactly it means, and could be something like an account entry, balance
* record, or modifications to the account's properties.
*/
public class AccountHistoryItem extends IdEntity {
private final LocalDateTime timestamp;
private final long accountId;
private final AccountHistoryItemType type;
public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
super(id);
this.timestamp = timestamp;
this.accountId = accountId;
this.type = type;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public long getAccountId() {
return accountId;
}
public AccountHistoryItemType getType() {
return type;
}
}

View File

@ -1,7 +0,0 @@
package com.andrewlalis.perfin.model.history;
public enum AccountHistoryItemType {
TEXT,
ACCOUNT_ENTRY,
BALANCE_RECORD
}

View File

@ -0,0 +1,36 @@
package com.andrewlalis.perfin.model.history;
import com.andrewlalis.perfin.model.IdEntity;
import java.time.LocalDateTime;
/**
* Represents a single polymorphic history item. The item's "type" attribute
* tells where to find additional type-specific data.
*/
public abstract class HistoryItem extends IdEntity {
public static final String TYPE_TEXT = "TEXT";
private final long historyId;
private final LocalDateTime timestamp;
private final String type;
public HistoryItem(long id, long historyId, LocalDateTime timestamp, String type) {
super(id);
this.historyId = historyId;
this.timestamp = timestamp;
this.type = type;
}
public long getHistoryId() {
return historyId;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public String getType() {
return type;
}
}

View File

@ -0,0 +1,16 @@
package com.andrewlalis.perfin.model.history;
import java.time.LocalDateTime;
public class HistoryTextItem extends HistoryItem {
private final String description;
public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) {
super(id, historyId, timestamp, HistoryItem.TYPE_TEXT);
this.description = description;
}
public String getDescription() {
return description;
}
}

View File

@ -1,36 +0,0 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.control.TransactionsViewController;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import javafx.scene.control.Hyperlink;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import static com.andrewlalis.perfin.PerfinApp.router;
public class AccountHistoryAccountEntryTile extends AccountHistoryItemTile {
public AccountHistoryAccountEntryTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
super(item);
AccountEntry entry = repo.getAccountEntryItem(item.id);
if (entry == null) {
setCenter(new TextFlow(new Text("Deleted account entry because of deleted transaction.")));
return;
}
Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue()));
Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId());
transactionLink.setOnAction(event -> router.navigate(
"transactions",
new TransactionsViewController.RouteContext(entry.getTransactionId())
));
var text = new TextFlow(
transactionLink,
new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of "),
amountText
);
setCenter(text);
}
}

View File

@ -1,31 +0,0 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.control.AccountViewController;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import javafx.scene.control.Hyperlink;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import static com.andrewlalis.perfin.PerfinApp.router;
public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile {
public AccountHistoryBalanceRecordTile(AccountHistoryItem item, AccountHistoryItemRepository repo, AccountViewController controller) {
super(item);
BalanceRecord balanceRecord = repo.getBalanceRecordItem(item.id);
if (balanceRecord == null) {
setCenter(new TextFlow(new Text("Deleted balance record was added.")));
return;
}
Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount()));
var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText);
setCenter(text);
Hyperlink viewLink = new Hyperlink("View this balance record");
viewLink.setOnAction(event -> router.navigate("balance-record", balanceRecord));
setBottom(viewLink);
}
}

View File

@ -1,9 +1,8 @@
package com.andrewlalis.perfin.view.component; package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.control.AccountViewController;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.history.AccountHistoryItem; import com.andrewlalis.perfin.model.history.HistoryItem;
import com.andrewlalis.perfin.model.history.HistoryTextItem;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
@ -11,7 +10,7 @@ import javafx.scene.layout.BorderPane;
* A tile that shows a brief bit of information about an account history item. * A tile that shows a brief bit of information about an account history item.
*/ */
public abstract class AccountHistoryItemTile extends BorderPane { public abstract class AccountHistoryItemTile extends BorderPane {
public AccountHistoryItemTile(AccountHistoryItem item) { public AccountHistoryItemTile(HistoryItem item) {
getStyleClass().add("tile"); getStyleClass().add("tile");
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp())); Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp()));
@ -20,14 +19,11 @@ public abstract class AccountHistoryItemTile extends BorderPane {
} }
public static AccountHistoryItemTile forItem( public static AccountHistoryItemTile forItem(
AccountHistoryItem item, HistoryItem item
AccountHistoryItemRepository repo,
AccountViewController controller
) { ) {
return switch (item.getType()) { if (item instanceof HistoryTextItem t) {
case TEXT -> new AccountHistoryTextTile(item, repo); return new AccountHistoryTextTile(t);
case ACCOUNT_ENTRY -> new AccountHistoryAccountEntryTile(item, repo); }
case BALANCE_RECORD -> new AccountHistoryBalanceRecordTile(item, repo, controller); throw new RuntimeException("Unsupported history item type: " + item.getType());
};
} }
} }

View File

@ -1,14 +1,12 @@
package com.andrewlalis.perfin.view.component; package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.model.history.HistoryTextItem;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import javafx.scene.text.Text; import javafx.scene.text.Text;
import javafx.scene.text.TextFlow; import javafx.scene.text.TextFlow;
public class AccountHistoryTextTile extends AccountHistoryItemTile { public class AccountHistoryTextTile extends AccountHistoryItemTile {
public AccountHistoryTextTile(AccountHistoryItem item, AccountHistoryItemRepository repo) { public AccountHistoryTextTile(HistoryTextItem item) {
super(item); super(item);
String text = repo.getTextItem(item.id); setCenter(new TextFlow(new Text(item.getDescription())));
setCenter(new TextFlow(new Text(text)));
} }
} }

View File

@ -19,4 +19,5 @@ module com.andrewlalis.perfin {
opens com.andrewlalis.perfin.view to javafx.fxml; opens com.andrewlalis.perfin.view to javafx.fxml;
opens com.andrewlalis.perfin.view.component to javafx.fxml; opens com.andrewlalis.perfin.view.component to javafx.fxml;
opens com.andrewlalis.perfin.view.component.validation to javafx.fxml; opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
exports com.andrewlalis.perfin.model.history to javafx.graphics;
} }

View File

@ -0,0 +1,61 @@
/*
Migration to clean up history entities so that they are easier to work with, and
less prone to errors.
- Removes existing account history items.
- Adds a generic history table and history items that are linked to a history.
- Adds history links to accounts and transactions.
*/
CREATE TABLE history (
id BIGINT PRIMARY KEY AUTO_INCREMENT
);
CREATE TABLE history_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
history_id BIGINT NOT NULL,
timestamp TIMESTAMP NOT NULL,
type VARCHAR(63) NOT NULL,
CONSTRAINT fk_history_item_history
FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE history_item_text (
id BIGINT NOT NULL,
description VARCHAR(255) NOT NULL,
CONSTRAINT fk_history_item_text_pk
FOREIGN KEY (id) REFERENCES history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE history_account (
account_id BIGINT NOT NULL,
history_id BIGINT NOT NULL,
PRIMARY KEY (account_id, history_id),
CONSTRAINT fk_history_account_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_history_account_history
FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE history_transaction (
transaction_id BIGINT NOT NULL,
history_id BIGINT NOT NULL,
PRIMARY KEY (transaction_id, history_id),
CONSTRAINT fk_history_transaction_transaction
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_history_transaction_history
FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
DROP TABLE IF EXISTS account_history_item_text;
DROP TABLE IF EXISTS account_history_item_account_entry;
DROP TABLE IF EXISTS account_history_item_balance_record;
DROP TABLE IF EXISTS account_history_item;

View File

@ -134,42 +134,49 @@ CREATE TABLE balance_record_attachment (
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE account_history_item ( /* HISTORY */
CREATE TABLE history (
id BIGINT PRIMARY KEY AUTO_INCREMENT
);
CREATE TABLE history_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
history_id BIGINT NOT NULL,
timestamp TIMESTAMP NOT NULL, timestamp TIMESTAMP NOT NULL,
account_id BIGINT NOT NULL,
type VARCHAR(63) NOT NULL, type VARCHAR(63) NOT NULL,
CONSTRAINT fk_account_history_item_account CONSTRAINT fk_history_item_history
FOREIGN KEY (account_id) REFERENCES account(id) FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE account_history_item_text ( CREATE TABLE history_item_text (
item_id BIGINT NOT NULL PRIMARY KEY, id BIGINT PRIMARY KEY,
description VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL,
CONSTRAINT fk_account_history_item_text_pk CONSTRAINT fk_history_item_text_pk
FOREIGN KEY (item_id) REFERENCES account_history_item(id) FOREIGN KEY (id) REFERENCES history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE account_history_item_account_entry ( CREATE TABLE history_account (
item_id BIGINT NOT NULL PRIMARY KEY, account_id BIGINT NOT NULL,
entry_id BIGINT NOT NULL, history_id BIGINT NOT NULL,
CONSTRAINT fk_account_history_item_account_entry_pk PRIMARY KEY (account_id, history_id),
FOREIGN KEY (item_id) REFERENCES account_history_item(id) CONSTRAINT fk_history_account_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_history_item_account_entry CONSTRAINT fk_history_account_history
FOREIGN KEY (entry_id) REFERENCES account_entry(id) FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
CREATE TABLE account_history_item_balance_record ( CREATE TABLE history_transaction (
item_id BIGINT NOT NULL PRIMARY KEY, transaction_id BIGINT NOT NULL,
record_id BIGINT NOT NULL, history_id BIGINT NOT NULL,
CONSTRAINT fk_account_history_item_balance_record_pk PRIMARY KEY (transaction_id, history_id),
FOREIGN KEY (item_id) REFERENCES account_history_item(id) CONSTRAINT fk_history_transaction_transaction
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_history_item_balance_record CONSTRAINT fk_history_transaction_history
FOREIGN KEY (record_id) REFERENCES balance_record(id) FOREIGN KEY (history_id) REFERENCES history(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );