Added credit limit and general purpose credit card properties to only credit card accounts.

This commit is contained in:
Andrew Lalis 2024-07-12 22:08:09 -04:00
parent 2abbd6ca43
commit b74119a233
12 changed files with 178 additions and 6 deletions

View File

@ -36,6 +36,7 @@ public class AccountViewController implements RouteSelectionListener {
private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived()); private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived());
private final StringProperty balanceTextProperty = new SimpleStringProperty(null); private final StringProperty balanceTextProperty = new SimpleStringProperty(null);
private final StringProperty assetValueTextProperty = new SimpleStringProperty(null); private final StringProperty assetValueTextProperty = new SimpleStringProperty(null);
private final StringProperty creditLimitTextProperty = new SimpleStringProperty(null);
@FXML public Label titleLabel; @FXML public Label titleLabel;
@FXML public Label accountNameLabel; @FXML public Label accountNameLabel;
@ -45,6 +46,8 @@ public class AccountViewController implements RouteSelectionListener {
@FXML public Label accountBalanceLabel; @FXML public Label accountBalanceLabel;
@FXML public PropertiesPane assetValuePane; @FXML public PropertiesPane assetValuePane;
@FXML public Label latestAssetsValueLabel; @FXML public Label latestAssetsValueLabel;
@FXML public PropertiesPane creditCardPropertiesPane;
@FXML public Label creditLimitLabel;
@FXML public PropertiesPane descriptionPane; @FXML public PropertiesPane descriptionPane;
@FXML public Text accountDescriptionText; @FXML public Text accountDescriptionText;
@ -65,10 +68,15 @@ public class AccountViewController implements RouteSelectionListener {
var hasDescription = accountProperty.map(a -> a.getDescription() != null); var hasDescription = accountProperty.map(a -> a.getDescription() != null);
BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription); BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription);
accountBalanceLabel.textProperty().bind(balanceTextProperty); accountBalanceLabel.textProperty().bind(balanceTextProperty);
var isBrokerageAccount = accountProperty.map(a -> a.getType() == AccountType.BROKERAGE); var isBrokerageAccount = accountProperty.map(a -> a.getType() == AccountType.BROKERAGE);
BindingUtil.bindManagedAndVisible(assetValuePane, isBrokerageAccount); BindingUtil.bindManagedAndVisible(assetValuePane, isBrokerageAccount);
latestAssetsValueLabel.textProperty().bind(assetValueTextProperty); 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 -> { actionsBox.getChildren().forEach(node -> {
Button button = (Button) node; Button button = (Button) node;
ObservableValue<Boolean> buttonDisabled = accountArchived; ObservableValue<Boolean> buttonDisabled = accountArchived;
@ -126,6 +134,22 @@ public class AccountViewController implements RouteSelectionListener {
repo -> repo.getNearestAssetValue(account.id) repo -> repo.getNearestAssetValue(account.id)
).thenApply(value -> CurrencyUtil.formatMoney(new MoneyValue(value, account.getCurrency()))) ).thenApply(value -> CurrencyUtil.formatMoney(new MoneyValue(value, account.getCurrency())))
.thenAccept(text -> Platform.runLater(() -> assetValueTextProperty.set(text))); .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));
}
}));
} }
} }
} }

View File

@ -1,12 +1,15 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.PropertiesPane; import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; 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.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleBooleanProperty;
@ -35,6 +38,7 @@ public class EditAccountController implements RouteSelectionListener {
@FXML public TextField accountNumberField; @FXML public TextField accountNumberField;
@FXML public ComboBox<Currency> accountCurrencyComboBox; @FXML public ComboBox<Currency> accountCurrencyComboBox;
@FXML public ChoiceBox<AccountType> accountTypeChoiceBox; @FXML public ChoiceBox<AccountType> accountTypeChoiceBox;
@FXML public TextField creditLimitField;
@FXML public TextArea descriptionField; @FXML public TextArea descriptionField;
@FXML public PropertiesPane initialBalanceContent; @FXML public PropertiesPane initialBalanceContent;
@FXML public TextField initialBalanceField; @FXML public TextField initialBalanceField;
@ -57,12 +61,25 @@ public class EditAccountController implements RouteSelectionListener {
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false) new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false)
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty()); ).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<String>() var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.") .addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.")
).attach(descriptionField, descriptionField.textProperty()); ).attach(descriptionField, descriptionField.textProperty());
// Combine validity of all fields for an expression that determines if the whole form is valid. // 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()); saveButton.disableProperty().bind(formValid.not());
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD") List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
@ -111,6 +128,10 @@ public class EditAccountController implements RouteSelectionListener {
description = description.strip(); description = description.strip();
if (description.isBlank()) description = null; 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 ( try (
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository(); var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository() var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
@ -130,12 +151,18 @@ public class EditAccountController implements RouteSelectionListener {
if (success) { if (success) {
long id = accountRepo.insert(type, number, name, currency, description); long id = accountRepo.insert(type, number, name, currency, description);
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, BalanceRecordType.CASH, initialBalance, currency, attachments); 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. // Once we create the new account, go to the account.
Account newAccount = accountRepo.findById(id).orElseThrow(); Account newAccount = accountRepo.findById(id).orElseThrow();
router.replace("account", newAccount); router.replace("account", newAccount);
} }
} else { } else {
accountRepo.update(account.id, type, number, name, currency, description); 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(); Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
router.replace("account", updatedAccount); router.replace("account", updatedAccount);
} }
@ -158,12 +185,28 @@ public class EditAccountController implements RouteSelectionListener {
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD")); accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
initialBalanceField.setText(String.format("%.02f", 0f)); initialBalanceField.setText(String.format("%.02f", 0f));
descriptionField.setText(null); descriptionField.setText(null);
creditLimitField.setText(null);
} else { } else {
accountNameField.setText(account.getName()); accountNameField.setText(account.getName());
accountNumberField.setText(account.getAccountNumber()); accountNumberField.setText(account.getAccountNumber());
accountTypeChoiceBox.getSelectionModel().select(account.getType()); accountTypeChoiceBox.getSelectionModel().select(account.getType());
accountCurrencyComboBox.getSelectionModel().select(account.getCurrency()); accountCurrencyComboBox.getSelectionModel().select(account.getCurrency());
descriptionField.setText(account.getDescription()); 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);
}
}));
}
} }
} }
} }

View File

@ -4,6 +4,7 @@ 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.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType; import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.CreditCardProperties;
import com.andrewlalis.perfin.model.Timestamped; import com.andrewlalis.perfin.model.Timestamped;
import java.math.BigDecimal; import java.math.BigDecimal;
@ -23,6 +24,8 @@ public interface AccountRepository extends Repository, AutoCloseable {
List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive); List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive);
List<Account> findAllByCurrency(Currency currency); List<Account> findAllByCurrency(Currency currency);
Optional<Account> findById(long id); Optional<Account> 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 update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description);
void delete(Account account); void delete(Account account);
void archive(long accountId); void archive(long accountId);

View File

@ -25,7 +25,6 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
@Override @Override
public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) { public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) {
return DbUtil.doTransaction(conn, () -> { return DbUtil.doTransaction(conn, () -> {
long accountId = DbUtil.insertOne( long accountId = DbUtil.insertOne(
conn, conn,
"INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)", "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(), currency.getCurrencyCode(),
description 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. // Insert a history item indicating the creation of the account.
HistoryRepository historyRepo = new JdbcHistoryRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); 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); 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<CreditCardProperties> 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 @Override
public BigDecimal deriveCashBalance(long accountId, Instant timestamp) { public BigDecimal deriveCashBalance(long accountId, Instant timestamp) {
// First find the account itself, since its properties influence the balance. // First find the account itself, since its properties influence the balance.
@ -254,7 +299,10 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
@Override @Override
public void delete(Account account) { 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 @Override
@ -289,6 +337,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
return new Account(id, createdAt, archived, type, accountNumber, name, currency, description); 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 @Override
public void close() throws Exception { public void close() throws Exception {
conn.close(); conn.close();
@ -310,4 +364,11 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
} }
return balance; 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());
}
} }

View File

@ -38,7 +38,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
* This value should be one higher than the * This value should be one higher than the
* </p> * </p>
*/ */
public static final int SCHEMA_VERSION = 5; public static final int SCHEMA_VERSION = 6;
public DataSource getDataSource(String profileName) throws ProfileLoadException { public DataSource getDataSource(String profileName) throws ProfileLoadException {
final boolean dbExists = Files.exists(getDatabaseFile(profileName)); final boolean dbExists = Files.exists(getDatabaseFile(profileName));

View File

@ -20,6 +20,7 @@ public class Migrations {
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql")); migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql")); migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
migrations.put(4, new PlainSQLMigration("/sql/migration/M004_AddBrokerageValueRecords.sql")); migrations.put(4, new PlainSQLMigration("/sql/migration/M004_AddBrokerageValueRecords.sql"));
migrations.put(5, new PlainSQLMigration("/sql/migration/M005_AddCreditCardLimit.sql"));
return migrations; return migrations;
} }

View File

@ -113,8 +113,13 @@ public final class DbUtil {
} }
public static void updateOne(Connection conn, String query, List<Object> args) { public static void updateOne(Connection conn, String query, List<Object> args) {
Object[] argsArray = args.toArray(); try (var stmt = conn.prepareStatement(query)) {
updateOne(conn, query, argsArray); 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) { public static void updateOne(Connection conn, String query, Object... args) {

View File

@ -0,0 +1,8 @@
package com.andrewlalis.perfin.model;
import java.math.BigDecimal;
public record CreditCardProperties(
long accountId,
BigDecimal creditLimit
) {}

View File

@ -49,6 +49,11 @@
</VBox> </VBox>
</PropertiesPane> </PropertiesPane>
<PropertiesPane vgap="5" hgap="5" fx:id="creditCardPropertiesPane" styleClass="std-padding,std-spacing">
<Label text="Credit Limit" styleClass="bold-text" labelFor="${creditLimitLabel}"/>
<Label fx:id="creditLimitLabel" styleClass="mono-font"/>
</PropertiesPane>
<PropertiesPane vgap="5" hgap="5" fx:id="descriptionPane" styleClass="std-padding,std-spacing"> <PropertiesPane vgap="5" hgap="5" fx:id="descriptionPane" styleClass="std-padding,std-spacing">
<Label text="Description" styleClass="bold-text" labelFor="${accountDescriptionText}"/> <Label text="Description" styleClass="bold-text" labelFor="${accountDescriptionText}"/>
<TextFlow maxWidth="500"><Text fx:id="accountDescriptionText"/></TextFlow> <TextFlow maxWidth="500"><Text fx:id="accountDescriptionText"/></TextFlow>

View File

@ -33,6 +33,9 @@
<Label text="Account Type" styleClass="bold-text"/> <Label text="Account Type" styleClass="bold-text"/>
<ChoiceBox fx:id="accountTypeChoiceBox"/> <ChoiceBox fx:id="accountTypeChoiceBox"/>
<Label text="Credit Limit" styleClass="bold-text" managed="${creditLimitField.managed}" visible="${creditLimitField.visible}"/>
<TextField fx:id="creditLimitField" styleClass="mono-font"/>
<Label text="Description" styleClass="bold-text"/> <Label text="Description" styleClass="bold-text"/>
<TextArea <TextArea
fx:id="descriptionField" fx:id="descriptionField"

View File

@ -0,0 +1,11 @@
/*
This migration adds a new table for credit-card specific properties.
*/
CREATE TABLE credit_card_account_properties (
account_id BIGINT PRIMARY KEY,
credit_limit NUMERIC(12, 4) NULL,
CONSTRAINT fk_credit_card_account_properties_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE
);

View File

@ -17,6 +17,14 @@ CREATE TABLE account (
description VARCHAR(255) DEFAULT NULL description VARCHAR(255) DEFAULT NULL
); );
CREATE TABLE credit_card_account_properties (
account_id BIGINT PRIMARY KEY,
credit_limit NUMERIC(12, 4) NULL,
CONSTRAINT fk_credit_card_account_properties_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE attachment ( CREATE TABLE attachment (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
uploaded_at TIMESTAMP NOT NULL, uploaded_at TIMESTAMP NOT NULL,