Cleaned up entities, balance computation logic.
This commit is contained in:
parent
087242396d
commit
4899d5e8b5
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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");
|
||||||
});
|
});
|
||||||
|
|
|
@ -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));
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
|
|
@ -1,3 +0,0 @@
|
||||||
module com.andrewlalis.perfin_test {
|
|
||||||
requires org.junit.jupiter.api;
|
|
||||||
}
|
|
Loading…
Reference in New Issue