From b74119a23308d60debcff6a93e54b2344ae55c46 Mon Sep 17 00:00:00 2001
From: andrewlalis
Date: Fri, 12 Jul 2024 22:08:09 -0400
Subject: [PATCH] Added credit limit and general purpose credit card properties
to only credit card accounts.
---
.../perfin/control/AccountViewController.java | 24 +++++++
.../perfin/control/EditAccountController.java | 45 ++++++++++++-
.../perfin/data/AccountRepository.java | 3 +
.../data/impl/JdbcAccountRepository.java | 65 ++++++++++++++++++-
.../data/impl/JdbcDataSourceFactory.java | 2 +-
.../data/impl/migration/Migrations.java | 1 +
.../andrewlalis/perfin/data/util/DbUtil.java | 9 ++-
.../perfin/model/CreditCardProperties.java | 8 +++
src/main/resources/account-view.fxml | 5 ++
src/main/resources/edit-account.fxml | 3 +
.../sql/migration/M005_AddCreditCardLimit.sql | 11 ++++
src/main/resources/sql/schema.sql | 8 +++
12 files changed, 178 insertions(+), 6 deletions(-)
create mode 100644 src/main/java/com/andrewlalis/perfin/model/CreditCardProperties.java
create mode 100644 src/main/resources/sql/migration/M005_AddCreditCardLimit.sql
diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java
index 9427669..b6747c9 100644
--- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java
+++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java
@@ -36,6 +36,7 @@ public class AccountViewController implements RouteSelectionListener {
private final ObservableValue accountArchived = accountProperty.map(a -> a != null && a.isArchived());
private final StringProperty balanceTextProperty = new SimpleStringProperty(null);
private final StringProperty assetValueTextProperty = new SimpleStringProperty(null);
+ private final StringProperty creditLimitTextProperty = new SimpleStringProperty(null);
@FXML public Label titleLabel;
@FXML public Label accountNameLabel;
@@ -45,6 +46,8 @@ public class AccountViewController implements RouteSelectionListener {
@FXML public Label accountBalanceLabel;
@FXML public PropertiesPane assetValuePane;
@FXML public Label latestAssetsValueLabel;
+ @FXML public PropertiesPane creditCardPropertiesPane;
+ @FXML public Label creditLimitLabel;
@FXML public PropertiesPane descriptionPane;
@FXML public Text accountDescriptionText;
@@ -65,10 +68,15 @@ public class AccountViewController implements RouteSelectionListener {
var hasDescription = accountProperty.map(a -> a.getDescription() != null);
BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription);
accountBalanceLabel.textProperty().bind(balanceTextProperty);
+
var isBrokerageAccount = accountProperty.map(a -> a.getType() == AccountType.BROKERAGE);
BindingUtil.bindManagedAndVisible(assetValuePane, isBrokerageAccount);
latestAssetsValueLabel.textProperty().bind(assetValueTextProperty);
+ var isCreditCardAccount = accountProperty.map(a -> a.getType() == AccountType.CREDIT_CARD);
+ BindingUtil.bindManagedAndVisible(creditCardPropertiesPane, isCreditCardAccount);
+ creditLimitLabel.textProperty().bind(creditLimitTextProperty);
+
actionsBox.getChildren().forEach(node -> {
Button button = (Button) node;
ObservableValue buttonDisabled = accountArchived;
@@ -126,6 +134,22 @@ public class AccountViewController implements RouteSelectionListener {
repo -> repo.getNearestAssetValue(account.id)
).thenApply(value -> CurrencyUtil.formatMoney(new MoneyValue(value, account.getCurrency())))
.thenAccept(text -> Platform.runLater(() -> assetValueTextProperty.set(text)));
+ } else if (account.getType() == AccountType.CREDIT_CARD) {
+ Profile.getCurrent().dataSource().mapRepoAsync(
+ AccountRepository.class,
+ repo -> repo.getCreditCardProperties(account.id)
+ ).thenAccept(props -> Platform.runLater(() -> {
+ if (props == null) {
+ creditLimitTextProperty.set("No credit card info.");
+ return;
+ }
+ if (props.creditLimit() == null) {
+ creditLimitTextProperty.set("No credit limit set.");
+ } else {
+ MoneyValue money = new MoneyValue(props.creditLimit(), account.getCurrency());
+ creditLimitTextProperty.set(CurrencyUtil.formatMoney(money));
+ }
+ }));
}
}
}
diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java
index 85a91ca..0f60904 100644
--- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java
+++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java
@@ -1,12 +1,15 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
+import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.*;
+import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
+import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -35,6 +38,7 @@ public class EditAccountController implements RouteSelectionListener {
@FXML public TextField accountNumberField;
@FXML public ComboBox accountCurrencyComboBox;
@FXML public ChoiceBox accountTypeChoiceBox;
+ @FXML public TextField creditLimitField;
@FXML public TextArea descriptionField;
@FXML public PropertiesPane initialBalanceContent;
@FXML public TextField initialBalanceField;
@@ -57,12 +61,25 @@ public class EditAccountController implements RouteSelectionListener {
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false)
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty());
+ var isEditingCreditCardAccount = accountTypeChoiceBox.valueProperty().isEqualTo(AccountType.CREDIT_CARD);
+ BindingUtil.bindManagedAndVisible(creditLimitField, isEditingCreditCardAccount);
+ var creditLimitValid = new ValidationApplier<>(new CurrencyAmountValidator(
+ () -> accountCurrencyComboBox.getValue(),
+ false,
+ true
+ )).validatedInitially().attachToTextField(creditLimitField)
+ .or(isEditingCreditCardAccount.not());
+
var descriptionValid = new ValidationApplier<>(new PredicateValidator()
.addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.")
).attach(descriptionField, descriptionField.textProperty());
// Combine validity of all fields for an expression that determines if the whole form is valid.
- BooleanExpression formValid = nameValid.and(numberValid).and(balanceValid.or(creatingNewAccount.not())).and(descriptionValid);
+ BooleanExpression formValid = nameValid
+ .and(numberValid)
+ .and(balanceValid.or(creatingNewAccount.not()))
+ .and(descriptionValid)
+ .and(creditLimitValid);
saveButton.disableProperty().bind(formValid.not());
List priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
@@ -111,6 +128,10 @@ public class EditAccountController implements RouteSelectionListener {
description = description.strip();
if (description.isBlank()) description = null;
}
+ BigDecimal creditLimit = null;
+ if (type == AccountType.CREDIT_CARD && creditLimitField.getText() != null && !creditLimitField.getText().isBlank()) {
+ creditLimit = new BigDecimal(creditLimitField.getText());
+ }
try (
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
@@ -130,12 +151,18 @@ public class EditAccountController implements RouteSelectionListener {
if (success) {
long id = accountRepo.insert(type, number, name, currency, description);
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, BalanceRecordType.CASH, initialBalance, currency, attachments);
+ if (type == AccountType.CREDIT_CARD && creditLimit != null) {
+ accountRepo.saveCreditCardProperties(new CreditCardProperties(id, creditLimit));
+ }
// Once we create the new account, go to the account.
Account newAccount = accountRepo.findById(id).orElseThrow();
router.replace("account", newAccount);
}
} else {
accountRepo.update(account.id, type, number, name, currency, description);
+ if (type == AccountType.CREDIT_CARD) {
+ accountRepo.saveCreditCardProperties(new CreditCardProperties(account.id, creditLimit));
+ }
Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
router.replace("account", updatedAccount);
}
@@ -158,12 +185,28 @@ public class EditAccountController implements RouteSelectionListener {
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
initialBalanceField.setText(String.format("%.02f", 0f));
descriptionField.setText(null);
+
+ creditLimitField.setText(null);
} else {
accountNameField.setText(account.getName());
accountNumberField.setText(account.getAccountNumber());
accountTypeChoiceBox.getSelectionModel().select(account.getType());
accountCurrencyComboBox.getSelectionModel().select(account.getCurrency());
descriptionField.setText(account.getDescription());
+
+ // Fetch the account's credit limit if it's a credit card account.
+ if (account.getType() == AccountType.CREDIT_CARD) {
+ Profile.getCurrent().dataSource().mapRepoAsync(
+ AccountRepository.class,
+ repo -> repo.getCreditCardProperties(account.id)
+ ).thenAccept(props -> Platform.runLater(() -> {
+ if (props != null && props.creditLimit() != null) {
+ creditLimitField.setText(CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(props.creditLimit(), account.getCurrency())));
+ } else {
+ creditLimitField.setText(null);
+ }
+ }));
+ }
}
}
}
diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java
index 4562a1b..0fb0f7c 100644
--- a/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java
+++ b/src/main/java/com/andrewlalis/perfin/data/AccountRepository.java
@@ -4,6 +4,7 @@ import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType;
+import com.andrewlalis.perfin.model.CreditCardProperties;
import com.andrewlalis.perfin.model.Timestamped;
import java.math.BigDecimal;
@@ -23,6 +24,8 @@ public interface AccountRepository extends Repository, AutoCloseable {
List findTopNRecentlyActive(int n, int daysSinceLastActive);
List findAllByCurrency(Currency currency);
Optional findById(long id);
+ CreditCardProperties getCreditCardProperties(long id);
+ void saveCreditCardProperties(CreditCardProperties properties);
void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description);
void delete(Account account);
void archive(long accountId);
diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java
index a451eb4..7918cec 100644
--- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java
@@ -25,7 +25,6 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
@Override
public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) {
return DbUtil.doTransaction(conn, () -> {
-
long accountId = DbUtil.insertOne(
conn,
"INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)",
@@ -36,6 +35,10 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
currency.getCurrencyCode(),
description
);
+ // If it's a credit card account, preemptively create a credit card properties record.
+ if (type == AccountType.CREDIT_CARD) {
+ saveCreditCardProperties(new CreditCardProperties(accountId, null));
+ }
// Insert a history item indicating the creation of the account.
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
@@ -113,6 +116,48 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
return DbUtil.findById(conn, "SELECT * FROM account WHERE id = ?", id, JdbcAccountRepository::parseAccount);
}
+ @Override
+ public CreditCardProperties getCreditCardProperties(long id) {
+ AccountType accountType = getAccountType(id);
+ if (accountType != AccountType.CREDIT_CARD) return null;
+ Optional optionalProperties = DbUtil.findOne(
+ conn,
+ "SELECT * FROM credit_card_account_properties WHERE account_id = ?",
+ List.of(id),
+ JdbcAccountRepository::parseCreditCardProperties
+ );
+ if (optionalProperties.isPresent()) return optionalProperties.get();
+ // No properties were found for the credit card account, so create an empty properties.
+ CreditCardProperties defaultProperties = new CreditCardProperties(id, null);
+ saveCreditCardProperties(defaultProperties);
+ return defaultProperties;
+ }
+
+ @Override
+ public void saveCreditCardProperties(CreditCardProperties properties) {
+ AccountType accountType = getAccountType(properties.accountId());
+ if (accountType != AccountType.CREDIT_CARD) return;
+ CreditCardProperties existingProperties = DbUtil.findOne(
+ conn,
+ "SELECT * FROM credit_card_account_properties WHERE account_id = ?",
+ List.of(properties.accountId()),
+ JdbcAccountRepository::parseCreditCardProperties
+ ).orElse(null);
+ if (existingProperties != null) {
+ DbUtil.updateOne(
+ conn,
+ "UPDATE credit_card_account_properties SET credit_limit = ? WHERE account_id = ?",
+ properties.creditLimit(), properties.accountId()
+ );
+ } else {
+ DbUtil.updateOne(
+ conn,
+ "INSERT INTO credit_card_account_properties (account_id, credit_limit) VALUES (?, ?)",
+ properties.accountId(), properties.creditLimit()
+ );
+ }
+ }
+
@Override
public BigDecimal deriveCashBalance(long accountId, Instant timestamp) {
// First find the account itself, since its properties influence the balance.
@@ -254,7 +299,10 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
@Override
public void delete(Account account) {
- DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", List.of(account.id));
+ DbUtil.doTransaction(conn, () -> {
+ DbUtil.update(conn, "DELETE FROM credit_card_account_properties WHERE account_id = ?", account.id);
+ DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", account.id);
+ });
}
@Override
@@ -289,6 +337,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
return new Account(id, createdAt, archived, type, accountNumber, name, currency, description);
}
+ public static CreditCardProperties parseCreditCardProperties(ResultSet rs) throws SQLException {
+ long accountId = rs.getLong("account_id");
+ BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
+ return new CreditCardProperties(accountId, creditLimit);
+ }
+
@Override
public void close() throws Exception {
conn.close();
@@ -310,4 +364,11 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
}
return balance;
}
+
+ private AccountType getAccountType(long id) {
+ String accountTypeStr = DbUtil.findOne(conn, "SELECT account_type FROM account WHERE id = ?", List.of(id), rs -> rs.getString(1))
+ .orElse(null);
+ if (accountTypeStr == null) return null;
+ return AccountType.valueOf(accountTypeStr.toUpperCase());
+ }
}
diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java
index 65c16e5..54c7cf7 100644
--- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java
@@ -38,7 +38,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
* This value should be one higher than the
*
*/
- public static final int SCHEMA_VERSION = 5;
+ public static final int SCHEMA_VERSION = 6;
public DataSource getDataSource(String profileName) throws ProfileLoadException {
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java
index 4f9641b..eda9ea7 100644
--- a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java
+++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java
@@ -20,6 +20,7 @@ public class Migrations {
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
migrations.put(4, new PlainSQLMigration("/sql/migration/M004_AddBrokerageValueRecords.sql"));
+ migrations.put(5, new PlainSQLMigration("/sql/migration/M005_AddCreditCardLimit.sql"));
return migrations;
}
diff --git a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java
index 5fedc3f..a8848b8 100644
--- a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java
+++ b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java
@@ -113,8 +113,13 @@ public final class DbUtil {
}
public static void updateOne(Connection conn, String query, List