Cleaned up entities, balance computation logic.

This commit is contained in:
Andrew Lalis 2024-01-04 09:54:06 -05:00
parent 087242396d
commit 4899d5e8b5
22 changed files with 135 additions and 107 deletions

View File

@ -35,7 +35,7 @@ public class AccountViewController implements RouteSelectionListener {
@Override @Override
public void onRouteSelected(Object context) { public void onRouteSelected(Object context) {
account = (Account) context; account = (Account) context;
titleLabel.setText("Account #" + account.getId()); titleLabel.setText("Account #" + account.id);
accountNameField.setText(account.getName()); accountNameField.setText(account.getName());
accountNumberField.setText(account.getAccountNumber()); accountNumberField.setText(account.getAccountNumber());
@ -100,7 +100,7 @@ public class AccountViewController implements RouteSelectionListener {
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
try (var historyRepo = Profile.getCurrent().getDataSource().getAccountHistoryItemRepository()) { try (var historyRepo = Profile.getCurrent().getDataSource().getAccountHistoryItemRepository()) {
List<AccountHistoryItem> historyItems = historyRepo.findMostRecentForAccount( List<AccountHistoryItem> historyItems = historyRepo.findMostRecentForAccount(
account.getId(), account.id,
loadHistoryFrom, loadHistoryFrom,
historyLoadSize historyLoadSize
); );

View File

@ -38,7 +38,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> { Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
BigDecimal value = repo.deriveCurrentBalance(account.getId()); BigDecimal value = repo.deriveCurrentBalance(account.id);
Platform.runLater(() -> balanceField.setText( Platform.runLater(() -> balanceField.setText(
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency())) CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
)); ));
@ -54,7 +54,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
BigDecimal reportedBalance = new BigDecimal(balanceField.getText()); BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
repo.insert( repo.insert(
DateUtil.localToUTC(localTimestamp), DateUtil.localToUTC(localTimestamp),
account.getId(), account.id,
reportedBalance, reportedBalance,
account.getCurrency(), account.getCurrency(),
attachmentSelectionArea.getSelectedFiles() attachmentSelectionArea.getSelectedFiles()

View File

@ -197,7 +197,7 @@ public class CreateTransactionController implements RouteSelectionListener {
Account creditAccount = linkCreditAccountComboBox.getValue(); Account creditAccount = linkCreditAccountComboBox.getValue();
if (debitAccount == null && creditAccount == null) { if (debitAccount == null && creditAccount == null) {
linkedAccountsErrorLabel.setText("At least one credit or debit account must be linked to the transaction for it to have any effect."); linkedAccountsErrorLabel.setText("At least one credit or debit account must be linked to the transaction for it to have any effect.");
} else if (debitAccount != null && creditAccount != null && debitAccount.getId() == creditAccount.getId()) { } else if (debitAccount != null && debitAccount.equals(creditAccount)) {
linkedAccountsErrorLabel.setText("Cannot link the same account to both credit and debit."); linkedAccountsErrorLabel.setText("Cannot link the same account to both credit and debit.");
} else { } else {
linkedAccountsErrorLabel.setText(null); linkedAccountsErrorLabel.setText(null);
@ -223,7 +223,7 @@ public class CreateTransactionController implements RouteSelectionListener {
if (debitAccount == null && creditAccount == null) { if (debitAccount == null && creditAccount == null) {
errorMessages.add("At least one account must be linked to this transaction."); errorMessages.add("At least one account must be linked to this transaction.");
} }
if (debitAccount != null && creditAccount != null && debitAccount.getId() == creditAccount.getId()) { if (debitAccount != null && debitAccount.equals(creditAccount)) {
errorMessages.add("Credit and debit accounts cannot be the same."); errorMessages.add("Credit and debit accounts cannot be the same.");
} }
return errorMessages; return errorMessages;

View File

@ -12,6 +12,8 @@ import javafx.scene.control.ComboBox;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Path; import java.nio.file.Path;
@ -23,6 +25,8 @@ import java.util.stream.Stream;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
public class EditAccountController implements RouteSelectionListener { public class EditAccountController implements RouteSelectionListener {
private static final Logger log = LoggerFactory.getLogger(EditAccountController.class);
private Account account; private Account account;
private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false); private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false);
@ -102,18 +106,19 @@ public class EditAccountController implements RouteSelectionListener {
router.navigate("account", newAccount); router.navigate("account", newAccount);
} }
} else { } else {
System.out.println("Updating account " + account.getName()); log.debug("Updating account {}", account.id);
account.setName(accountNameField.getText().strip()); account.setName(accountNameField.getText().strip());
account.setAccountNumber(accountNumberField.getText().strip()); account.setAccountNumber(accountNumberField.getText().strip());
account.setType(accountTypeChoiceBox.getValue()); account.setType(accountTypeChoiceBox.getValue());
account.setCurrency(accountCurrencyComboBox.getValue()); account.setCurrency(accountCurrencyComboBox.getValue());
accountRepo.update(account); accountRepo.update(account);
Account updatedAccount = accountRepo.findById(account.getId()).orElseThrow(); Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
router.getHistory().clear(); router.getHistory().clear();
router.navigate("account", updatedAccount); router.navigate("account", updatedAccount);
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(System.err); log.error("Failed to save (or update) account " + account.id, e);
Popups.error("Failed to save the account: " + e.getMessage());
} }
} }

View File

@ -43,7 +43,7 @@ public class TransactionViewController {
public void setTransaction(Transaction transaction) { public void setTransaction(Transaction transaction) {
this.transaction = transaction; this.transaction = transaction;
if (transaction == null) return; if (transaction == null) return;
titleLabel.setText("Transaction #" + transaction.getId()); titleLabel.setText("Transaction #" + transaction.id);
amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount())); amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp())); timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
descriptionLabel.setText(transaction.getDescription()); descriptionLabel.setText(transaction.getDescription());
@ -52,7 +52,7 @@ public class TransactionViewController {
configureAccountLinkBindings(creditAccountLink); configureAccountLinkBindings(creditAccountLink);
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.getId()); CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
Platform.runLater(() -> { Platform.runLater(() -> {
accounts.ifDebit(acc -> { accounts.ifDebit(acc -> {
debitAccountLink.setText(acc.getShortName()); debitAccountLink.setText(acc.getShortName());
@ -70,7 +70,7 @@ public class TransactionViewController {
attachmentsContainer.visibleProperty().bind(new SimpleListProperty<>(attachmentsList).emptyProperty().not()); attachmentsContainer.visibleProperty().bind(new SimpleListProperty<>(attachmentsList).emptyProperty().not());
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
List<Attachment> attachments = repo.findAttachments(transaction.getId()); List<Attachment> attachments = repo.findAttachments(transaction.id);
Platform.runLater(() -> attachmentsList.setAll(attachments)); Platform.runLater(() -> attachmentsList.setAll(attachments));
}); });
}); });
@ -94,7 +94,7 @@ public class TransactionViewController {
if (confirm) { if (confirm) {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
// TODO: Delete attachments first! // TODO: Delete attachments first!
repo.delete(transaction.getId()); repo.delete(transaction.id);
router.getHistory().clear(); router.getHistory().clear();
router.navigate("transactions"); router.navigate("transactions");
}); });

View File

@ -100,7 +100,7 @@ public class TransactionsViewController implements RouteSelectionListener {
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
repo.findById(ctx.selectedTransactionId).ifPresent(tx -> { repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
long offset = repo.countAllAfter(tx.getId()); long offset = repo.countAllAfter(tx.id);
int pageNumber = (int) (offset / DEFAULT_ITEMS_PER_PAGE) + 1; int pageNumber = (int) (offset / DEFAULT_ITEMS_PER_PAGE) + 1;
paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx)); paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx));
}); });

View File

@ -23,9 +23,9 @@ public interface AccountRepository extends AutoCloseable {
void delete(Account account); void delete(Account account);
void archive(Account account); void archive(Account account);
BigDecimal deriveBalance(long id, Instant timestamp); BigDecimal deriveBalance(long accountId, Instant timestamp);
default BigDecimal deriveCurrentBalance(long id) { default BigDecimal deriveCurrentBalance(long accountId) {
return deriveBalance(id, Instant.now(Clock.systemUTC())); return deriveBalance(accountId, Instant.now(Clock.systemUTC()));
} }
Set<Currency> findAllUsedCurrencies(); Set<Currency> findAllUsedCurrencies();
} }

View File

@ -5,6 +5,7 @@ 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.data.util.ThrowableConsumer; import com.andrewlalis.perfin.data.util.ThrowableConsumer;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.MoneyValue; import com.andrewlalis.perfin.model.MoneyValue;
import javafx.application.Platform; import javafx.application.Platform;
@ -29,32 +30,32 @@ public interface DataSource {
Path getContentDir(); Path getContentDir();
AccountRepository getAccountRepository(); AccountRepository getAccountRepository();
BalanceRecordRepository getBalanceRecordRepository();
TransactionRepository getTransactionRepository();
AttachmentRepository getAttachmentRepository();
AccountHistoryItemRepository getAccountHistoryItemRepository();
default void useAccountRepository(ThrowableConsumer<AccountRepository> repoConsumer) { default void useAccountRepository(ThrowableConsumer<AccountRepository> repoConsumer) {
DbUtil.useClosable(this::getAccountRepository, repoConsumer); DbUtil.useClosable(this::getAccountRepository, repoConsumer);
} }
BalanceRecordRepository getBalanceRecordRepository();
default void useBalanceRecordRepository(ThrowableConsumer<BalanceRecordRepository> repoConsumer) { default void useBalanceRecordRepository(ThrowableConsumer<BalanceRecordRepository> repoConsumer) {
DbUtil.useClosable(this::getBalanceRecordRepository, repoConsumer); DbUtil.useClosable(this::getBalanceRecordRepository, repoConsumer);
} }
TransactionRepository getTransactionRepository();
default void useTransactionRepository(ThrowableConsumer<TransactionRepository> repoConsumer) { default void useTransactionRepository(ThrowableConsumer<TransactionRepository> repoConsumer) {
DbUtil.useClosable(this::getTransactionRepository, repoConsumer); DbUtil.useClosable(this::getTransactionRepository, repoConsumer);
} }
AttachmentRepository getAttachmentRepository();
default void useAttachmentRepository(ThrowableConsumer<AttachmentRepository> repoConsumer) { default void useAttachmentRepository(ThrowableConsumer<AttachmentRepository> repoConsumer) {
DbUtil.useClosable(this::getAttachmentRepository, repoConsumer); DbUtil.useClosable(this::getAttachmentRepository, repoConsumer);
} }
AccountHistoryItemRepository getAccountHistoryItemRepository();
// Utility methods: // Utility methods:
default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) { default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) {
Thread.ofVirtual().start(() -> useAccountRepository(repo -> { Thread.ofVirtual().start(() -> useAccountRepository(repo -> {
BigDecimal balance = repo.deriveCurrentBalance(account.getId()); BigDecimal balance = repo.deriveCurrentBalance(account.id);
MoneyValue money = new MoneyValue(balance, account.getCurrency()); MoneyValue money = new MoneyValue(balance, account.getCurrency());
Platform.runLater(() -> balanceConsumer.accept(CurrencyUtil.formatMoney(money))); Platform.runLater(() -> balanceConsumer.accept(CurrencyUtil.formatMoney(money)));
})); }));
@ -66,7 +67,9 @@ public interface DataSource {
Map<Currency, BigDecimal> totals = new HashMap<>(); Map<Currency, BigDecimal> totals = new HashMap<>();
for (var account : accounts) { for (var account : accounts) {
BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO); BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO);
totals.put(account.getCurrency(), currencyTotal.add(accountRepo.deriveCurrentBalance(account.getId()))); BigDecimal accountBalance = accountRepo.deriveCurrentBalance(account.id);
if (account.getType() == AccountType.CREDIT_CARD) accountBalance = accountBalance.negate();
totals.put(account.getCurrency(), currencyTotal.add(accountBalance));
} }
return totals; return totals;
} catch (Exception e) { } catch (Exception e) {

View File

@ -0,0 +1,11 @@
package com.andrewlalis.perfin.data;
/**
* Exception that's thrown when an entity of a certain type is not found
* when it was expected that it would be.
*/
public class EntityNotFoundException extends RuntimeException {
public EntityNotFoundException(Class<?> type, Object id) {
super("Entity of type " + type.getName() + " with id " + id + " was not found.");
}
}

View File

@ -1,6 +1,7 @@
package com.andrewlalis.perfin.data.impl; package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.AccountRepository;
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.DateUtil;
@ -66,59 +67,53 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
} }
@Override @Override
public BigDecimal deriveBalance(long id, Instant timestamp) { public BigDecimal deriveBalance(long accountId, Instant timestamp) {
// 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);
// Find the most recent balance record before timestamp. // Find the most recent balance record before timestamp.
Optional<BalanceRecord> closestPastRecord = DbUtil.findOne( Optional<BalanceRecord> closestPastRecord = 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 timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
List.of(id, DbUtil.timestampFromInstant(timestamp)), List.of(accountId, DbUtil.timestampFromInstant(timestamp)),
JdbcBalanceRecordRepository::parse JdbcBalanceRecordRepository::parse
); );
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> accountEntries = DbUtil.findAll( List<AccountEntry> entriesAfterRecord = DbUtil.findAll(
conn, conn,
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC", "SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC",
List.of( List.of(
id, accountId,
DbUtil.timestampFromUtcLDT(closestPastRecord.get().getTimestamp()), DbUtil.timestampFromUtcLDT(closestPastRecord.get().getTimestamp()),
DbUtil.timestampFromInstant(timestamp) DbUtil.timestampFromInstant(timestamp)
), ),
JdbcAccountEntryRepository::parse JdbcAccountEntryRepository::parse
); );
// Apply all entries to the most recent known balance to obtain the balance at this point. return computeBalanceWithEntriesAfter(account, closestPastRecord.get(), entriesAfterRecord);
BigDecimal currentBalance = closestPastRecord.get().getBalance();
for (var entry : accountEntries) {
currentBalance = currentBalance.add(entry.getSignedAmount());
}
return currentBalance;
} 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 = DbUtil.findOne( Optional<BalanceRecord> closestFutureRecord = 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 timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
List.of(id, DbUtil.timestampFromInstant(timestamp)), List.of(accountId, DbUtil.timestampFromInstant(timestamp)),
JdbcBalanceRecordRepository::parse JdbcBalanceRecordRepository::parse
); );
if (closestFutureRecord.isEmpty()) { if (closestFutureRecord.isEmpty()) {
throw new IllegalStateException("No balance record exists for account " + id); throw new IllegalStateException("No balance record exists for account " + accountId);
} }
// 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> accountEntries = DbUtil.findAll( List<AccountEntry> entriesBeforeRecord = DbUtil.findAll(
conn, conn,
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp <= ? AND timestamp >= ? ORDER BY timestamp DESC", "SELECT * FROM account_entry WHERE account_id = ? AND timestamp <= ? AND timestamp >= ? ORDER BY timestamp DESC",
List.of( List.of(
id, accountId,
DbUtil.timestampFromUtcLDT(closestFutureRecord.get().getTimestamp()), DbUtil.timestampFromUtcLDT(closestFutureRecord.get().getTimestamp()),
DbUtil.timestampFromInstant(timestamp) DbUtil.timestampFromInstant(timestamp)
), ),
JdbcAccountEntryRepository::parse JdbcAccountEntryRepository::parse
); );
BigDecimal currentBalance = closestFutureRecord.get().getBalance(); return computeBalanceWithEntriesBefore(account, closestFutureRecord.get(), entriesBeforeRecord);
for (var entry : accountEntries) {
currentBalance = currentBalance.subtract(entry.getSignedAmount());
}
return currentBalance;
} }
} }
@ -141,19 +136,19 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
account.getAccountNumber(), account.getAccountNumber(),
account.getCurrency().getCurrencyCode(), account.getCurrency().getCurrencyCode(),
account.getType().name(), account.getType().name(),
account.getId() account.id
) )
); );
} }
@Override @Override
public void delete(Account account) { public void delete(Account account) {
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", List.of(account.getId())); DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", List.of(account.id));
} }
@Override @Override
public void archive(Account account) { public void archive(Account account) {
DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(account.getId())); DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(account.id));
} }
public static Account parseAccount(ResultSet rs) throws SQLException { public static Account parseAccount(ResultSet rs) throws SQLException {
@ -171,4 +166,20 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
public void close() throws Exception { public void close() throws Exception {
conn.close(); 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) {
BigDecimal balance = balanceRecord.getBalance();
for (AccountEntry entry : entriesBeforeRecord) {
balance = balance.subtract(entry.getEffectiveValue(account.getType()));
}
return balance;
}
} }

View File

@ -30,7 +30,7 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
try (var stmt = conn.prepareStatement("INSERT INTO balance_record_attachment(balance_record_id, attachment_id) VALUES (?, ?)")) { try (var stmt = conn.prepareStatement("INSERT INTO balance_record_attachment(balance_record_id, attachment_id) VALUES (?, ?)")) {
for (var attachmentPath : attachments) { for (var attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath); Attachment attachment = attachmentRepo.insert(attachmentPath);
DbUtil.setArgs(stmt, recordId, attachment.getId()); DbUtil.setArgs(stmt, recordId, attachment.id);
stmt.executeUpdate(); stmt.executeUpdate();
} }
} }

View File

@ -36,15 +36,15 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
); );
// 2. Insert linked account entries. // 2. Insert linked account entries.
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn); AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.getId(), txId, amount, AccountEntry.Type.DEBIT, currency)); linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.getId(), txId, amount, AccountEntry.Type.CREDIT, currency)); linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
// 3. Add attachments. // 3. Add attachments.
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
try (var stmt = conn.prepareStatement("INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)")) { try (var stmt = conn.prepareStatement("INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)")) {
for (var attachmentPath : attachments) { for (var attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath); Attachment attachment = attachmentRepo.insert(attachmentPath);
// Insert the link-table entry. // Insert the link-table entry.
DbUtil.setArgs(stmt, txId, attachment.getId()); DbUtil.setArgs(stmt, txId, attachment.id);
stmt.executeUpdate(); stmt.executeUpdate();
} }
} }

View File

@ -7,8 +7,7 @@ import java.util.Currency;
* The representation of a physical account of some sort (checking, savings, * The representation of a physical account of some sort (checking, savings,
* credit-card, etc.). * credit-card, etc.).
*/ */
public class Account { public class Account extends IdEntity {
private final long id;
private final LocalDateTime createdAt; private final LocalDateTime createdAt;
private final boolean archived; private final boolean archived;
@ -18,7 +17,7 @@ public class Account {
private Currency currency; private Currency currency;
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency) { public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency) {
this.id = id; super(id);
this.createdAt = createdAt; this.createdAt = createdAt;
this.archived = archived; this.archived = archived;
this.type = type; this.type = type;
@ -73,10 +72,6 @@ public class Account {
return createdAt; return createdAt;
} }
public long getId() {
return id;
}
public boolean isArchived() { public boolean isArchived() {
return archived; return archived;
} }

View File

@ -30,13 +30,12 @@ import java.util.Currency;
* all those extra accounts would be a burden to casual users. * all those extra accounts would be a burden to casual users.
* </p> * </p>
*/ */
public class AccountEntry { public class AccountEntry extends IdEntity {
public enum Type { public enum Type {
CREDIT, CREDIT,
DEBIT DEBIT
} }
private final long id;
private final LocalDateTime timestamp; private final LocalDateTime timestamp;
private final long accountId; private final long accountId;
private final long transactionId; private final long transactionId;
@ -45,7 +44,7 @@ public class AccountEntry {
private final Currency currency; private final Currency currency;
public AccountEntry(long id, LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, Type type, Currency currency) { public AccountEntry(long id, LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, Type type, Currency currency) {
this.id = id; super(id);
this.timestamp = timestamp; this.timestamp = timestamp;
this.accountId = accountId; this.accountId = accountId;
this.transactionId = transactionId; this.transactionId = transactionId;
@ -54,10 +53,6 @@ public class AccountEntry {
this.currency = currency; this.currency = currency;
} }
public long getId() {
return id;
}
public LocalDateTime getTimestamp() { public LocalDateTime getTimestamp() {
return timestamp; return timestamp;
} }
@ -82,11 +77,19 @@ public class AccountEntry {
return currency; return currency;
} }
public BigDecimal getSignedAmount() {
return type == Type.DEBIT ? amount : amount.negate();
}
public MoneyValue getMoneyValue() { public MoneyValue getMoneyValue() {
return new MoneyValue(amount, currency); return new MoneyValue(amount, currency);
} }
/**
* Gets the effective value of this entry for the account's type.
* @param accountType The type of the account.
* @return The effective value of this entry, either positive or negative.
*/
public BigDecimal getEffectiveValue(AccountType accountType) {
return switch (accountType) {
case CHECKING, SAVINGS -> type == Type.DEBIT ? amount : amount.negate();
case CREDIT_CARD -> type == Type.DEBIT ? amount.negate() : amount;
};
}
} }

View File

@ -11,25 +11,20 @@ import java.time.format.DateTimeFormatter;
* entity, like a receipt attached to a transaction, or a bank statement to an * entity, like a receipt attached to a transaction, or a bank statement to an
* account balance record. * account balance record.
*/ */
public class Attachment { public class Attachment extends IdEntity {
private final long id;
private final LocalDateTime timestamp; private final LocalDateTime timestamp;
private final String identifier; private final String identifier;
private final String filename; private final String filename;
private final String contentType; private final String contentType;
public Attachment(long id, LocalDateTime timestamp, String identifier, String filename, String contentType) { public Attachment(long id, LocalDateTime timestamp, String identifier, String filename, String contentType) {
this.id = id; super(id);
this.timestamp = timestamp; this.timestamp = timestamp;
this.identifier = identifier; this.identifier = identifier;
this.filename = filename; this.filename = filename;
this.contentType = contentType; this.contentType = contentType;
} }
public long getId() {
return id;
}
public LocalDateTime getTimestamp() { public LocalDateTime getTimestamp() {
return timestamp; return timestamp;
} }

View File

@ -9,26 +9,20 @@ import java.util.Currency;
* used as a sanity check for ensuring that an account's entries add up to the * used as a sanity check for ensuring that an account's entries add up to the
* correct balance. * correct balance.
*/ */
public class BalanceRecord { public class BalanceRecord extends IdEntity {
private final long id;
private final LocalDateTime timestamp; private final LocalDateTime timestamp;
private final long accountId; private final long accountId;
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, BigDecimal balance, Currency currency) {
this.id = id; super(id);
this.timestamp = timestamp; this.timestamp = timestamp;
this.accountId = accountId; this.accountId = accountId;
this.balance = balance; this.balance = balance;
this.currency = currency; this.currency = currency;
} }
public long getId() {
return id;
}
public LocalDateTime getTimestamp() { public LocalDateTime getTimestamp() {
return timestamp; return timestamp;
} }

View File

@ -0,0 +1,30 @@
package com.andrewlalis.perfin.model;
/**
* Base class for all entities that are identified by an id.
*/
public abstract class IdEntity {
/**
* The unique identifier for this entity. It distinguishes this entity from
* all others of its type.
*/
public final long id;
/**
* Constructs the entity with a given id.
* @param id The id to use.
*/
protected IdEntity(long id) {
this.id = id;
}
@Override
public boolean equals(Object other) {
return other instanceof IdEntity e && e.id == this.id;
}
@Override
public int hashCode() {
return Long.hashCode(id);
}
}

View File

@ -10,26 +10,20 @@ import java.util.Currency;
* actual positive/negative effect is determined by the associated account * actual positive/negative effect is determined by the associated account
* entries that apply this transaction's amount to one or more accounts. * entries that apply this transaction's amount to one or more accounts.
*/ */
public class Transaction { public class Transaction extends IdEntity {
private final long id;
private final LocalDateTime timestamp; private final LocalDateTime timestamp;
private final BigDecimal amount; private final BigDecimal amount;
private final Currency currency; private final Currency currency;
private final String description; private final String description;
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) { public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
this.id = id; super(id);
this.timestamp = timestamp; this.timestamp = timestamp;
this.amount = amount; this.amount = amount;
this.currency = currency; this.currency = currency;
this.description = description; this.description = description;
} }
public long getId() {
return id;
}
public LocalDateTime getTimestamp() { public LocalDateTime getTimestamp() {
return timestamp; return timestamp;
} }
@ -49,9 +43,4 @@ public class Transaction {
public MoneyValue getMoneyAmount() { public MoneyValue getMoneyAmount() {
return new MoneyValue(amount, currency); return new MoneyValue(amount, currency);
} }
@Override
public boolean equals(Object other) {
return other instanceof Transaction tx && id == tx.id;
}
} }

View File

@ -22,7 +22,7 @@ public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile {
} }
Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount())); Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount()));
var text = new TextFlow(new Text("Balance record #" + balanceRecord.getId() + " added with value of "), amountText); var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText);
setCenter(text); setCenter(text);
Hyperlink deleteLink = new Hyperlink("Delete this balance record"); Hyperlink deleteLink = new Hyperlink("Delete this balance record");
@ -30,7 +30,7 @@ public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile {
boolean confirm = Popups.confirm("Are you sure you want to delete this balance record? It will be removed permanently, and cannot be undone."); boolean confirm = Popups.confirm("Are you sure you want to delete this balance record? It will be removed permanently, and cannot be undone.");
if (confirm) { if (confirm) {
Profile.getCurrent().getDataSource().useBalanceRecordRepository(balanceRecordRepo -> { Profile.getCurrent().getDataSource().useBalanceRecordRepository(balanceRecordRepo -> {
balanceRecordRepo.deleteById(balanceRecord.getId()); balanceRecordRepo.deleteById(balanceRecord.id);
Platform.runLater(controller::reloadHistory); Platform.runLater(controller::reloadHistory);
}); });
} }

View File

@ -107,7 +107,7 @@ public class TransactionTile extends BorderPane {
CompletableFuture<CreditAndDebitAccounts> cf = new CompletableFuture<>(); CompletableFuture<CreditAndDebitAccounts> cf = new CompletableFuture<>();
Thread.ofVirtual().start(() -> { Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.getId()); CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
cf.complete(accounts); cf.complete(accounts);
}); });
}); });

View File

@ -1,5 +0,0 @@
ALTER TABLE balance_record
ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE AFTER currency;
ALTER TABLE account_entry
ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE AFTER currency;

View File

@ -1,3 +0,0 @@
module com.andrewlalis.perfin_test {
requires org.junit.jupiter.api;
}