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 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<Boolean> 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));
}
}));
}
}
}

View File

@ -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<Currency> accountCurrencyComboBox;
@FXML public ChoiceBox<AccountType> 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<String>()
.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<Currency> 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);
}
}));
}
}
}
}

View File

@ -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<Account> findTopNRecentlyActive(int n, int daysSinceLastActive);
List<Account> findAllByCurrency(Currency currency);
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 delete(Account account);
void archive(long accountId);

View File

@ -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<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
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());
}
}

View File

@ -38,7 +38,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
* This value should be one higher than the
* </p>
*/
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));

View File

@ -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;
}

View File

@ -113,8 +113,13 @@ public final class DbUtil {
}
public static void updateOne(Connection conn, String query, List<Object> 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) {

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>
</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">
<Label text="Description" styleClass="bold-text" labelFor="${accountDescriptionText}"/>
<TextFlow maxWidth="500"><Text fx:id="accountDescriptionText"/></TextFlow>

View File

@ -33,6 +33,9 @@
<Label text="Account Type" styleClass="bold-text"/>
<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"/>
<TextArea
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
);
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 (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
uploaded_at TIMESTAMP NOT NULL,