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 args) { - Object[] argsArray = args.toArray(); - updateOne(conn, query, argsArray); + try (var stmt = conn.prepareStatement(query)) { + setArgs(stmt, args); + int updateCount = stmt.executeUpdate(); + if (updateCount != 1) throw new UncheckedSqlException("Update count is " + updateCount + "; expected 1."); + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } } public static void updateOne(Connection conn, String query, Object... args) { diff --git a/src/main/java/com/andrewlalis/perfin/model/CreditCardProperties.java b/src/main/java/com/andrewlalis/perfin/model/CreditCardProperties.java new file mode 100644 index 0000000..e91844c --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/CreditCardProperties.java @@ -0,0 +1,8 @@ +package com.andrewlalis.perfin.model; + +import java.math.BigDecimal; + +public record CreditCardProperties( + long accountId, + BigDecimal creditLimit +) {} diff --git a/src/main/resources/account-view.fxml b/src/main/resources/account-view.fxml index fc372cd..c9fd6ce 100644 --- a/src/main/resources/account-view.fxml +++ b/src/main/resources/account-view.fxml @@ -49,6 +49,11 @@ + + +