Compare commits
34 Commits
Author | SHA1 | Date |
---|---|---|
Andrew Lalis | 86ee9f8187 | |
Andrew Lalis | 36c29e0d06 | |
Andrew Lalis | b91b0a8263 | |
Andrew Lalis | 6b63b777cf | |
Andrew Lalis | 71cc5b1612 | |
Andrew Lalis | 6d720b9645 | |
Andrew Lalis | 408d5e415d | |
Andrew Lalis | 3908515ca4 | |
Andrew Lalis | b74119a233 | |
Andrew Lalis | 2abbd6ca43 | |
Andrew Lalis | f23d2c85a9 | |
Andrew Lalis | ec6bc83353 | |
Andrew Lalis | feda2e1897 | |
Andrew Lalis | d4bd5cc6ec | |
Andrew Lalis | 83e9043057 | |
Andrew Lalis | ea94f09702 | |
Andrew Lalis | 411f384775 | |
Andrew Lalis | 72d624afdc | |
Andrew Lalis | 2dbb3d944d | |
Andrew Lalis | a88ebc8e13 | |
Andrew Lalis | d360de5d6f | |
Andrew Lalis | 6e862a2709 | |
Andrew Lalis | b6fef8d42f | |
Andrew Lalis | e08c528b71 | |
Andrew Lalis | 28002fd32d | |
Andrew Lalis | a3558b33e6 | |
Andrew Lalis | 5ce2360f05 | |
Andrew Lalis | 4cf95dba85 | |
Andrew Lalis | e6d5b280aa | |
Andrew Lalis | 1898783c56 | |
Andrew Lalis | 77f2966291 | |
Andrew Lalis | 20eed2108f | |
Andrew Lalis | e4783e5a47 | |
Andrew Lalis | a13c9c22df |
|
@ -1,8 +1,5 @@
|
|||
# Perfin
|
||||
|
||||
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/andrewlalis/perfin/run-tests.yaml?style=flat-square&logo=github)
|
||||
![GitHub release (with filter)](https://img.shields.io/github/v/release/andrewlalis/perfin?style=flat-square)
|
||||
|
||||
A personal accounting desktop app to track your finances using an approachable
|
||||
interface and interoperable file formats for maximum compatibility.
|
||||
|
||||
|
|
2
pom.xml
2
pom.xml
|
@ -6,7 +6,7 @@
|
|||
|
||||
<groupId>com.andrewlalis</groupId>
|
||||
<artifactId>perfin</artifactId>
|
||||
<version>1.11.0</version>
|
||||
<version>1.19.0</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
|
|
|
@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar"
|
|||
|
||||
jpackage \
|
||||
--name "Perfin" \
|
||||
--app-version "1.11.0" \
|
||||
--app-version "1.19.0" \
|
||||
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
|
||||
--icon design/perfin-logo_256.png \
|
||||
--vendor "Andrew Lalis" \
|
||||
|
|
|
@ -12,7 +12,7 @@ $modulePath = "$modulePath;target\modules\h2-2.2.224.jar"
|
|||
|
||||
jpackage `
|
||||
--name "Perfin" `
|
||||
--app-version "1.11.0" `
|
||||
--app-version "1.19.0" `
|
||||
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
|
||||
--icon design\perfin-logo_256.ico `
|
||||
--vendor "Andrew Lalis" `
|
||||
|
|
|
@ -30,7 +30,9 @@ import java.util.function.Consumer;
|
|||
public class PerfinApp extends Application {
|
||||
private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
|
||||
public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
|
||||
/** The singleton instance of the application. */
|
||||
public static PerfinApp instance;
|
||||
/** The singleton profile loader for the application. */
|
||||
public static ProfileLoader profileLoader;
|
||||
|
||||
/**
|
||||
|
@ -66,20 +68,28 @@ public class PerfinApp extends Application {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of the app's startup, where the main scene is initialized.
|
||||
* @param stage The JavaFX stage where the scene will be placed.
|
||||
* @param msgConsumer A message consumer to relay status messages to the user.
|
||||
*/
|
||||
private void initMainScreen(Stage stage, Consumer<String> msgConsumer) {
|
||||
msgConsumer.accept("Initializing main screen.");
|
||||
Platform.runLater(() -> {
|
||||
stage.hide();
|
||||
Scene mainViewScene = SceneUtil.load("/main-view.fxml");
|
||||
mainViewScene.getStylesheets().addAll(
|
||||
PerfinApp.class.getResource("/style/base.css").toExternalForm()
|
||||
);
|
||||
SceneUtil.addStylesheets(mainViewScene, "/style/base.css");
|
||||
stage.setScene(mainViewScene);
|
||||
stage.setTitle("Perfin");
|
||||
stage.getIcons().add(ImageCache.getLogo256());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of the app's startup, where all the app's routes to various views
|
||||
* are defined.
|
||||
* @param msgConsumer A message consumer to relay status messages to the user.
|
||||
*/
|
||||
private static void defineRoutes(Consumer<String> msgConsumer) {
|
||||
msgConsumer.accept("Initializing application views.");
|
||||
Platform.runLater(() -> {
|
||||
|
@ -97,6 +107,7 @@ public class PerfinApp extends Application {
|
|||
router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
|
||||
router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
|
||||
router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
|
||||
router.map("sql-console", PerfinApp.class.getResource("/sql-console-view.fxml"));
|
||||
|
||||
// Help pages.
|
||||
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
|
||||
|
@ -106,9 +117,15 @@ public class PerfinApp extends Application {
|
|||
helpRouter.map("adding-a-transaction", PerfinApp.class.getResource("/help-pages/adding-a-transaction.fxml"));
|
||||
helpRouter.map("profiles", PerfinApp.class.getResource("/help-pages/profiles.fxml"));
|
||||
helpRouter.map("about", PerfinApp.class.getResource("/help-pages/about.fxml"));
|
||||
helpRouter.map("sql-console", PerfinApp.class.getResource("/help-pages/sql-console.fxml"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A part of the app's startup which ensures that the main directory exists.
|
||||
* @param msgConsumer A message consumer to relay status messages to the user.
|
||||
* @throws Exception If file operations fail.
|
||||
*/
|
||||
private static void initAppDir(Consumer<String> msgConsumer) throws Exception {
|
||||
msgConsumer.accept("Validating application files.");
|
||||
if (Files.notExists(APP_DIR)) {
|
||||
|
@ -122,6 +139,13 @@ public class PerfinApp extends Application {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The final part of the app's startup sequence, where the last profile is
|
||||
* loaded and set as the current profile. Calling `Profile.setCurrent`
|
||||
* triggers many components to refresh their data for the current profile.
|
||||
* @param msgConsumer A message consumer to relay status messages to the user.
|
||||
* @throws Exception If the profile could not be loaded for some reason.
|
||||
*/
|
||||
private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
|
||||
String lastProfile = ProfileLoader.getLastProfile();
|
||||
msgConsumer.accept("Loading the most recent profile: \"" + lastProfile + "\".");
|
||||
|
@ -133,6 +157,9 @@ public class PerfinApp extends Application {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all application fonts from the bundled resource files.
|
||||
*/
|
||||
private static void loadFonts() {
|
||||
List<String> fontResources = List.of(
|
||||
"/font/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Regular.ttf",
|
||||
|
|
|
@ -4,9 +4,7 @@ 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.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.MoneyValue;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.*;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import com.andrewlalis.perfin.view.component.AccountHistoryView;
|
||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||
|
@ -14,7 +12,10 @@ import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
|||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.BooleanExpression;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
|
@ -23,7 +24,10 @@ import javafx.scene.control.Label;
|
|||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.text.Text;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
|
@ -31,6 +35,8 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
private final ObjectProperty<Account> accountProperty = new SimpleObjectProperty<>(null);
|
||||
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;
|
||||
|
@ -38,6 +44,10 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
@FXML public Label accountCurrencyLabel;
|
||||
@FXML public Label accountCreatedAtLabel;
|
||||
@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;
|
||||
|
||||
|
@ -59,13 +69,27 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
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> buttonActive = accountArchived;
|
||||
ObservableValue<Boolean> buttonDisabled = accountArchived;
|
||||
if (button.getText().equalsIgnoreCase("Unarchive")) {
|
||||
buttonActive = BooleanExpression.booleanExpression(buttonActive).not();
|
||||
buttonDisabled = BooleanExpression.booleanExpression(buttonDisabled).not();
|
||||
}
|
||||
button.disableProperty().bind(buttonActive);
|
||||
if (button.getText().equalsIgnoreCase("Record Asset Value")) {
|
||||
buttonDisabled = BooleanExpression.booleanExpression(
|
||||
accountProperty.map(Account::getType)
|
||||
.map(t -> !t.equals(AccountType.BROKERAGE))
|
||||
).or(BooleanExpression.booleanExpression(accountArchived));
|
||||
}
|
||||
button.disableProperty().bind(buttonDisabled);
|
||||
button.managedProperty().bind(button.visibleProperty());
|
||||
button.visibleProperty().bind(button.disableProperty().not());
|
||||
});
|
||||
|
@ -81,7 +105,7 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
.toInstant();
|
||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
AccountRepository.class,
|
||||
repo -> repo.deriveBalance(getAccount().id, timestamp)
|
||||
repo -> repo.deriveCashBalance(getAccount().id, timestamp)
|
||||
).thenAccept(balance -> Platform.runLater(() -> {
|
||||
String msg = String.format(
|
||||
"Your balance as of %s is %s, according to Perfin's data.",
|
||||
|
@ -91,23 +115,43 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
Popups.message(balanceCheckerButton, msg);
|
||||
}));
|
||||
});
|
||||
|
||||
accountProperty.addListener((observable, oldValue, newValue) -> {
|
||||
accountHistory.clear();
|
||||
if (newValue == null) {
|
||||
balanceTextProperty.set(null);
|
||||
} else {
|
||||
accountHistory.setAccountId(newValue.id);
|
||||
accountHistory.loadMoreHistory();
|
||||
Profile.getCurrent().dataSource().getAccountBalanceText(newValue)
|
||||
.thenAccept(s -> Platform.runLater(() -> balanceTextProperty.set(s)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
this.accountProperty.set((Account) context);
|
||||
accountHistory.clear();
|
||||
balanceTextProperty.set(null);
|
||||
assetValueTextProperty.set(null);
|
||||
if (context instanceof Account account) {
|
||||
this.accountProperty.set(account);
|
||||
accountHistory.setAccountId(account.id);
|
||||
accountHistory.loadMoreHistory();
|
||||
Profile.getCurrent().dataSource().getAccountBalanceText(account)
|
||||
.thenAccept(s -> Platform.runLater(() -> balanceTextProperty.set(s)));
|
||||
if (account.getType() == AccountType.BROKERAGE) {
|
||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
AccountRepository.class,
|
||||
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));
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FXML
|
||||
|
@ -116,7 +160,11 @@ public class AccountViewController implements RouteSelectionListener {
|
|||
}
|
||||
|
||||
@FXML public void goToCreateBalanceRecord() {
|
||||
router.navigate("create-balance-record", getAccount());
|
||||
router.navigate("create-balance-record", new CreateBalanceRecordController.RouteContext(getAccount(), BalanceRecordType.CASH));
|
||||
}
|
||||
|
||||
@FXML public void goToCreateAssetRecord() {
|
||||
router.navigate("create-balance-record", new CreateBalanceRecordController.RouteContext(getAccount(), BalanceRecordType.ASSETS));
|
||||
}
|
||||
|
||||
@FXML
|
||||
|
|
|
@ -24,6 +24,7 @@ public class BalanceRecordViewController implements RouteSelectionListener {
|
|||
|
||||
@FXML public Label titleLabel;
|
||||
|
||||
@FXML public Label typeLabel;
|
||||
@FXML public Label timestampLabel;
|
||||
@FXML public Label balanceLabel;
|
||||
@FXML public Label currencyLabel;
|
||||
|
@ -38,6 +39,7 @@ public class BalanceRecordViewController implements RouteSelectionListener {
|
|||
this.balanceRecord = (BalanceRecord) context;
|
||||
if (balanceRecord == null) return;
|
||||
titleLabel.setText("Balance Record #" + balanceRecord.id);
|
||||
typeLabel.setText(balanceRecord.getType().toString());
|
||||
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
|
||||
balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
|
||||
currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
|
||||
|
|
|
@ -6,6 +6,7 @@ import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
|||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.BalanceRecordType;
|
||||
import com.andrewlalis.perfin.model.MoneyValue;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||
|
@ -29,7 +30,13 @@ import java.time.format.DateTimeParseException;
|
|||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
/**
|
||||
* Controller for the page where users can create a balance record for an
|
||||
* account.
|
||||
*/
|
||||
public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||
public record RouteContext (Account account, BalanceRecordType type) {}
|
||||
|
||||
@FXML public TextField timestampField;
|
||||
@FXML public TextField balanceField;
|
||||
@FXML public Label balanceWarningLabel;
|
||||
|
@ -39,6 +46,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
@FXML public Button saveButton;
|
||||
|
||||
private Account account;
|
||||
private BalanceRecordType type = BalanceRecordType.CASH;
|
||||
|
||||
@FXML public void initialize() {
|
||||
var timestampValid = new ValidationApplier<>((ValidationFunction<String>) input -> {
|
||||
|
@ -57,7 +65,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
balanceWarningLabel.managedProperty().bind(balanceWarningLabel.visibleProperty());
|
||||
balanceWarningLabel.visibleProperty().set(false);
|
||||
balanceField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (!balanceValidator.validate(newValue).isValid() || !timestampValid.get()) {
|
||||
if (!balanceValidator.validate(newValue).isValid() || !timestampValid.get() || type != BalanceRecordType.CASH) {
|
||||
balanceWarningLabel.visibleProperty().set(false);
|
||||
return;
|
||||
}
|
||||
|
@ -65,7 +73,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
||||
LocalDateTime utcTimestamp = DateUtil.localToUTC(localTimestamp);
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal derivedBalance = repo.deriveBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC));
|
||||
BigDecimal derivedBalance = repo.deriveCashBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC));
|
||||
boolean balancesMatch = reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance);
|
||||
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(!balancesMatch));
|
||||
});
|
||||
|
@ -77,14 +85,19 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
this.account = (Account) context;
|
||||
RouteContext ctx = (RouteContext) context;
|
||||
this.account = ctx.account();
|
||||
this.type = ctx.type();
|
||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||
balanceField.setText(null);
|
||||
if (ctx.type() == BalanceRecordType.CASH) {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal value = repo.deriveCurrentBalance(account.id);
|
||||
BigDecimal value = repo.deriveCurrentCashBalance(account.id);
|
||||
Platform.runLater(() -> balanceField.setText(
|
||||
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
|
||||
));
|
||||
});
|
||||
}
|
||||
attachmentSelectionArea.clear();
|
||||
}
|
||||
|
||||
|
@ -92,16 +105,26 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
||||
BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
|
||||
|
||||
boolean confirm = Popups.confirm(timestampField, "Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted(
|
||||
String valueNoun = switch (type) {
|
||||
case CASH -> "cash balance";
|
||||
case ASSETS -> "asset value";
|
||||
};
|
||||
|
||||
boolean confirm = Popups.confirm(timestampField, "Are you sure that you want to record the %s of account\n%s\nas %s,\nas of %s?".formatted(
|
||||
valueNoun,
|
||||
account.getShortName(),
|
||||
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
|
||||
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
|
||||
));
|
||||
if (confirm && confirmIfInconsistentBalance(reportedBalance, DateUtil.localToUTC(localTimestamp))) {
|
||||
if (
|
||||
confirm &&
|
||||
(type != BalanceRecordType.CASH || confirmIfInconsistentBalance(reportedBalance, DateUtil.localToUTC(localTimestamp)))
|
||||
) {
|
||||
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
|
||||
repo.insert(
|
||||
DateUtil.localToUTC(localTimestamp),
|
||||
account.id,
|
||||
type,
|
||||
reportedBalance,
|
||||
account.getCurrency(),
|
||||
attachmentSelectionArea.getSelectedPaths()
|
||||
|
@ -118,7 +141,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
|||
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance, LocalDateTime utcTimestamp) {
|
||||
BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
|
||||
AccountRepository.class,
|
||||
repo -> repo.deriveBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC))
|
||||
repo -> repo.deriveCashBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC))
|
||||
);
|
||||
if (!reportedBalance.setScale(currentDerivedBalance.scale(), RoundingMode.HALF_UP).equals(currentDerivedBalance)) {
|
||||
String msg = "The balance you reported (%s) doesn't match the balance that Perfin derived from your account's transactions (%s). It's encouraged to go back and add any missing transactions first, but you may proceed now if you understand the consequences of an inconsistent account balance history.\n\nAre you absolutely sure you want to create this balance record?".formatted(
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.andrewlalis.perfin.control;
|
|||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.component.module.TotalAssetsGraphModule;
|
||||
import com.andrewlalis.perfin.view.component.module.*;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.geometry.Bounds;
|
||||
|
@ -30,7 +31,10 @@ public class DashboardController implements RouteSelectionListener {
|
|||
var m4 = new VendorSpendChartModule(modulesFlowPane);
|
||||
m4.columnsProperty.set(2);
|
||||
|
||||
modulesFlowPane.getChildren().addAll(accountsModule, transactionsModule, m3, m4);
|
||||
var m5 = new TotalAssetsGraphModule(modulesFlowPane);
|
||||
m5.columnsProperty.set(1);
|
||||
|
||||
modulesFlowPane.getChildren().addAll(accountsModule, transactionsModule, m3, m4, m5);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -1,15 +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.Account;
|
||||
import com.andrewlalis.perfin.model.AccountType;
|
||||
import com.andrewlalis.perfin.model.MoneyValue;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
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;
|
||||
|
@ -38,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;
|
||||
|
@ -60,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")
|
||||
|
@ -114,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()
|
||||
|
@ -132,13 +150,19 @@ public class EditAccountController implements RouteSelectionListener {
|
|||
boolean success = Popups.confirm(accountNameField, prompt);
|
||||
if (success) {
|
||||
long id = accountRepo.insert(type, number, name, currency, description);
|
||||
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, 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.
|
||||
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);
|
||||
}
|
||||
|
@ -161,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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,12 +13,12 @@ import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
|||
import com.andrewlalis.perfin.view.component.CategorySelectionBox;
|
||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
|
||||
import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
||||
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.Bindings;
|
||||
import javafx.beans.binding.BooleanExpression;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
|
@ -41,6 +41,7 @@ import java.time.DateTimeException;
|
|||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
|
@ -57,6 +58,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
@FXML public TextField timestampField;
|
||||
@FXML public TextField amountField;
|
||||
@FXML public ChoiceBox<Currency> currencyChoiceBox;
|
||||
private final BooleanProperty basicTransactionInfoValid = new SimpleBooleanProperty(false);
|
||||
@FXML public TextArea descriptionField;
|
||||
|
||||
@FXML public HBox linkedAccountsContainer;
|
||||
|
@ -132,11 +134,13 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
||||
initializeTagSelectionUi();
|
||||
initializeLineItemsUi();
|
||||
initializeDuplicateTransactionUi();
|
||||
|
||||
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
|
||||
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
|
||||
tagsHyperlink.setOnAction(event -> router.navigate("tags"));
|
||||
|
||||
basicTransactionInfoValid.bind(timestampValid.and(amountValid).and(currencyChoiceBox.valueProperty().isNotNull()));
|
||||
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
|
||||
saveButton.disableProperty().bind(formValid.not());
|
||||
}
|
||||
|
@ -313,6 +317,49 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
.attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty());
|
||||
}
|
||||
|
||||
record BasicTransactionInfo(LocalDateTime timestamp, BigDecimal amount, Currency currency) {}
|
||||
|
||||
private BasicTransactionInfo getBasicTransactionInfo() {
|
||||
if (!basicTransactionInfoValid.get()) return null;
|
||||
return new BasicTransactionInfo(
|
||||
DateUtil.localToUTC(parseTimestamp()),
|
||||
new BigDecimal(amountField.getText()),
|
||||
currencyChoiceBox.getValue()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the duplicate transaction validation, which operates on the
|
||||
* basic transaction properties: timestamp, amount, and currency. We listen
|
||||
* for changes to these, and if they're all at least valid, we search for
|
||||
* existing transactions with the same values.
|
||||
*/
|
||||
private void initializeDuplicateTransactionUi() {
|
||||
Property<BasicTransactionInfo> txInfoProperty = new SimpleObjectProperty<>(getBasicTransactionInfo());
|
||||
basicTransactionInfoValid.addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue) {
|
||||
txInfoProperty.setValue(new BasicTransactionInfo(
|
||||
DateUtil.localToUTC(parseTimestamp()),
|
||||
new BigDecimal(amountField.getText()),
|
||||
currencyChoiceBox.getValue()
|
||||
));
|
||||
} else {
|
||||
txInfoProperty.setValue(null);
|
||||
}
|
||||
});
|
||||
AsyncValidationFunction<BasicTransactionInfo> validationFunction = info -> {
|
||||
if (info == null || transaction != null) return CompletableFuture.completedFuture(ValidationResult.valid());
|
||||
return Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.findDuplicates(info.timestamp(), info.amount(), info.currency())
|
||||
)
|
||||
.thenApply(matches -> matches.stream().map(m -> "Found possible duplicate transaction: #" + m.id).toList())
|
||||
.thenApply(ValidationResult::new);
|
||||
};
|
||||
new ValidationApplier<>(validationFunction)
|
||||
.attach(descriptionField.getParent(), txInfoProperty);
|
||||
}
|
||||
|
||||
private void initializeTagSelectionUi() {
|
||||
addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
|
||||
addTagButton.setOnAction(event -> {
|
||||
|
@ -403,12 +450,10 @@ public class EditTransactionController implements RouteSelectionListener {
|
|||
}
|
||||
});
|
||||
BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false);
|
||||
lineItemsTotalValue.addListener((observable, oldValue, newValue) -> {
|
||||
lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0);
|
||||
});
|
||||
amountFieldValue.addListener((observable, oldValue, newValue) -> {
|
||||
lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0);
|
||||
});
|
||||
lineItemsTotalValue.addListener((observable, oldValue, newValue) ->
|
||||
lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0));
|
||||
amountFieldValue.addListener((observable, oldValue, newValue) ->
|
||||
lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0));
|
||||
BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not()));
|
||||
|
||||
// Logic for button that syncs line items total to the amount field.
|
||||
|
|
|
@ -1,19 +1,24 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.AnalyticsRepository;
|
||||
import com.andrewlalis.perfin.data.DataSource;
|
||||
import com.andrewlalis.perfin.data.TimestampRange;
|
||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextArea;
|
||||
import javafx.scene.control.TextField;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
|
@ -24,6 +29,8 @@ public class EditVendorController implements RouteSelectionListener {
|
|||
@FXML public TextArea descriptionField;
|
||||
@FXML public Button saveButton;
|
||||
|
||||
@FXML public Label totalSpentField;
|
||||
|
||||
@FXML public void initialize() {
|
||||
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
|
||||
|
@ -63,9 +70,19 @@ public class EditVendorController implements RouteSelectionListener {
|
|||
this.vendor = tv;
|
||||
nameField.setText(vendor.getName());
|
||||
descriptionField.setText(vendor.getDescription());
|
||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
AnalyticsRepository.class,
|
||||
repo -> repo.getVendorSpend(TimestampRange.unbounded(), vendor.id)
|
||||
).thenAccept(amounts -> {
|
||||
String text = amounts.stream()
|
||||
.map(CurrencyUtil::formatMoney)
|
||||
.collect(Collectors.joining(", "));
|
||||
Platform.runLater(() -> totalSpentField.setText(text.isBlank() ? "None" : text));
|
||||
});
|
||||
} else {
|
||||
nameField.setText(null);
|
||||
descriptionField.setText(null);
|
||||
totalSpentField.setText(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -102,4 +102,8 @@ public class MainViewController {
|
|||
@FXML public void goToDashboard() {
|
||||
router.replace("dashboard");
|
||||
}
|
||||
|
||||
@FXML public void goToSqlConsole() {
|
||||
router.replace("sql-console");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.andrewlalis.perfin.control;
|
|||
|
||||
import com.andrewlalis.perfin.PerfinApp;
|
||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||
import com.andrewlalis.perfin.data.SampleProfileGenerator;
|
||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.ProfileBackups;
|
||||
|
@ -58,6 +59,16 @@ public class ProfilesViewController {
|
|||
}
|
||||
}
|
||||
|
||||
@FXML public void createSampleProfile() {
|
||||
SampleProfileGenerator generator = new SampleProfileGenerator(PerfinApp.profileLoader);
|
||||
try {
|
||||
generator.createSampleProfile();
|
||||
refreshAvailableProfiles();
|
||||
} catch (Exception e) {
|
||||
Popups.error(profilesVBox, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshAvailableProfiles() {
|
||||
List<String> profileNames = ProfileLoader.getAvailableProfiles();
|
||||
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name();
|
||||
|
|
|
@ -0,0 +1,285 @@
|
|||
package com.andrewlalis.perfin.control;
|
||||
|
||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||
import com.andrewlalis.perfin.data.SavedQueryRepository;
|
||||
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
|
||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.StageStyle;
|
||||
import javafx.stage.Window;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Controller for the SQL Console View, in which the user can write and execute
|
||||
* arbitrary SQL queries on the database. This allows power users to create
|
||||
* custom analytics queries and get exactly the data they want, without fiddling
|
||||
* with user-friendly search fields.
|
||||
*/
|
||||
public class SqlConsoleViewController implements RouteSelectionListener {
|
||||
|
||||
@FXML public TextArea sqlEditorTextArea;
|
||||
@FXML public TextArea outputTextArea;
|
||||
@FXML public VBox savedQueriesVBox;
|
||||
|
||||
@Override
|
||||
public void onRouteSelected(Object context) {
|
||||
sqlEditorTextArea.clear();
|
||||
outputTextArea.clear();
|
||||
refreshSavedQueries();
|
||||
}
|
||||
|
||||
@FXML public void executeQuery() {
|
||||
List<String> queries = getCurrentQueries();
|
||||
outputTextArea.clear();
|
||||
JdbcDataSource dataSource = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||
try (
|
||||
var conn = dataSource.getConnection();
|
||||
var stmt = conn.createStatement()
|
||||
) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int queryIdx = 0; queryIdx < queries.size(); queryIdx++) {
|
||||
sb.append("Query ").append(queryIdx + 1).append(" of ").append(queries.size()).append(":\n");
|
||||
String query = queries.get(queryIdx);
|
||||
ResultSet rs = stmt.executeQuery(query);
|
||||
int columnCount = rs.getMetaData().getColumnCount();
|
||||
|
||||
for (int i = 1; i <= columnCount; i++) {
|
||||
sb.append(rs.getMetaData().getColumnLabel(i));
|
||||
if (i < columnCount) sb.append(", ");
|
||||
}
|
||||
sb.append('\n');
|
||||
while (rs.next()) {
|
||||
for (int i = 1; i <= columnCount; i++) {
|
||||
sb.append(rs.getString(i));
|
||||
if (i < columnCount) sb.append(", ");
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
if (queryIdx < queries.size() - 1) {
|
||||
sb.append("-----\n\n");
|
||||
}
|
||||
}
|
||||
outputTextArea.setText(sb.toString());
|
||||
} catch (SQLException e) {
|
||||
outputTextArea.setText("Error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@FXML public void saveQuery() {
|
||||
if (sqlEditorTextArea.getText().isBlank()) {
|
||||
Popups.message(sqlEditorTextArea, "Cannot save an empty query.");
|
||||
return;
|
||||
}
|
||||
TextInputDialog dialog = new TextInputDialog();
|
||||
dialog.setTitle("Save Query");
|
||||
dialog.setContentText("Enter a name for this query.");
|
||||
Optional<String> result = dialog.showAndWait();
|
||||
if (result.isPresent()) {
|
||||
SavedQueryRepository repo = Profile.getCurrent().dataSource().getSavedQueryRepository();
|
||||
String name = result.get().strip();
|
||||
if (
|
||||
repo.getSavedQueries().contains(name) &&
|
||||
!Popups.confirm(sqlEditorTextArea, "Are you sure you want to overwrite this saved query?")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
String content = sqlEditorTextArea.getText().strip();
|
||||
repo.createSavedQuery(name, content);
|
||||
refreshSavedQueries();
|
||||
}
|
||||
}
|
||||
|
||||
@FXML public void exportToFile() {
|
||||
if (sqlEditorTextArea.getText().isBlank()) {
|
||||
Popups.message(sqlEditorTextArea, "Cannot export the results of an empty query.");
|
||||
return;
|
||||
}
|
||||
if (getCurrentQueries().size() > 1) {
|
||||
Popups.message(sqlEditorTextArea, "Note: Export to file will only export the results of your first query.");
|
||||
}
|
||||
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Export to File");
|
||||
fileChooser.setInitialFileName("export.csv");
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
|
||||
"CSV Files", ".csv"
|
||||
));
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
|
||||
"JSON Files", ".json"
|
||||
));
|
||||
fileChooser.setInitialDirectory(Profile.getCurrent().dataSource().getContentDir().toFile());
|
||||
File chosenFile = fileChooser.showSaveDialog(sqlEditorTextArea.getScene().getWindow());
|
||||
if (chosenFile == null) return;
|
||||
|
||||
String name = chosenFile.getName().strip().toLowerCase();
|
||||
if (!name.endsWith(".csv") && !name.endsWith(".json")) {
|
||||
Popups.error(sqlEditorTextArea, "Invalid file format. Only CSV and JSON are permitted.");
|
||||
return;
|
||||
}
|
||||
String query = getCurrentQueries().getFirst();
|
||||
JdbcDataSource dataSource = (JdbcDataSource) Profile.getCurrent().dataSource();
|
||||
try (
|
||||
var conn = dataSource.getConnection();
|
||||
var stmt = conn.createStatement()
|
||||
) {
|
||||
ResultSet rs = stmt.executeQuery(query);
|
||||
if (name.endsWith(".csv")) {
|
||||
writeQueryResultsToCsv(rs, chosenFile.toPath());
|
||||
} else if (name.endsWith(".json")) {
|
||||
writeQueryResultsToJson(rs, chosenFile.toPath());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Popups.error(sqlEditorTextArea, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeQueryResultsToCsv(ResultSet rs, Path file) throws SQLException, IOException {
|
||||
try (var out = Files.newOutputStream(file); var writer = new PrintWriter(out)) {
|
||||
final int columnCount = rs.getMetaData().getColumnCount();
|
||||
// First write the header.
|
||||
for (int i = 1; i <= columnCount; i++) {
|
||||
writer.append(FileUtil.escapeCSVText(rs.getMetaData().getColumnLabel(i)));
|
||||
if (i < columnCount) writer.append(',');
|
||||
}
|
||||
writer.println();
|
||||
// Then write the body rows.
|
||||
while (rs.next()) {
|
||||
for (int i = 1; i <= columnCount; i++) {
|
||||
writer.append(FileUtil.escapeCSVText(rs.getString(i)));
|
||||
if (i < columnCount) writer.append(',');
|
||||
}
|
||||
writer.println();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeQueryResultsToJson(ResultSet rs, Path file) throws SQLException, IOException {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNodeFactory nf = mapper.getNodeFactory();
|
||||
final int columnCount = rs.getMetaData().getColumnCount();
|
||||
try (
|
||||
var out = Files.newOutputStream(file);
|
||||
var arrayWriter = mapper.writerWithDefaultPrettyPrinter().writeValuesAsArray(out)
|
||||
) {
|
||||
while (rs.next()) {
|
||||
ObjectNode obj = mapper.createObjectNode();
|
||||
for (int i = 1; i <= columnCount; i++) {
|
||||
String label = rs.getMetaData().getColumnLabel(i);
|
||||
int type = rs.getMetaData().getColumnType(i);
|
||||
JsonNode valueNode = switch (type) {
|
||||
case Types.INTEGER | Types.BIGINT -> nf.numberNode(rs.getLong(i));
|
||||
case Types.FLOAT | Types.DECIMAL -> nf.numberNode(rs.getDouble(i));
|
||||
case Types.NUMERIC -> nf.numberNode(rs.getBigDecimal(i));
|
||||
case Types.BOOLEAN -> nf.booleanNode(rs.getBoolean(i));
|
||||
default -> nf.textNode(rs.getString(i));
|
||||
};
|
||||
obj.set(label, valueNode);
|
||||
}
|
||||
arrayWriter.write(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshSavedQueries() {
|
||||
savedQueriesVBox.getChildren().clear();
|
||||
List<String> savedQueries = Profile.getCurrent().dataSource()
|
||||
.getSavedQueryRepository().getSavedQueries();
|
||||
savedQueriesVBox.getChildren().addAll(savedQueries.stream().map(this::makeQueryTile).toList());
|
||||
}
|
||||
|
||||
private Node makeQueryTile(String name) {
|
||||
AnchorPane pane = new AnchorPane();
|
||||
pane.getStyleClass().addAll("tile");
|
||||
Label nameLabel = new Label(name);
|
||||
AnchorPane.setLeftAnchor(nameLabel, 0.0);
|
||||
AnchorPane.setTopAnchor(nameLabel, 0.0);
|
||||
AnchorPane.setBottomAnchor(nameLabel, 0.0);
|
||||
pane.getChildren().add(nameLabel);
|
||||
|
||||
HBox buttonsBox = new HBox();
|
||||
buttonsBox.getStyleClass().addAll("std-spacing", "small-font");
|
||||
Button loadButton = new Button("Load");
|
||||
loadButton.setOnAction(event -> sqlEditorTextArea.setText(
|
||||
Profile.getCurrent().dataSource().getSavedQueryRepository()
|
||||
.getSavedQueryContent(name)
|
||||
));
|
||||
buttonsBox.getChildren().add(loadButton);
|
||||
Button deleteButton = new Button("Delete");
|
||||
deleteButton.setOnAction(event -> {
|
||||
if (Popups.confirm(pane, "Are you sure you want to delete this query?")) {
|
||||
Profile.getCurrent().dataSource().getSavedQueryRepository().deleteSavedQuery(name);
|
||||
refreshSavedQueries();
|
||||
}
|
||||
});
|
||||
buttonsBox.getChildren().add(deleteButton);
|
||||
|
||||
AnchorPane.setRightAnchor(buttonsBox, 0.0);
|
||||
AnchorPane.setTopAnchor(buttonsBox, 0.0);
|
||||
AnchorPane.setBottomAnchor(buttonsBox, 0.0);
|
||||
pane.getChildren().add(buttonsBox);
|
||||
return pane;
|
||||
}
|
||||
|
||||
private List<String> getCurrentQueries() {
|
||||
String queryText = sqlEditorTextArea.getText().strip();
|
||||
String[] rawQueries = queryText.split("\\s*;\\s*");
|
||||
return Arrays.stream(rawQueries)
|
||||
.filter(s -> !s.isBlank())
|
||||
.filter(s -> !s.startsWith("#") && !s.startsWith("//"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@FXML public void showSchema() {
|
||||
SchemaDialog dialog = new SchemaDialog(sqlEditorTextArea.getScene().getWindow());
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private static class SchemaDialog extends Dialog<Void> {
|
||||
public SchemaDialog(Window owner) {
|
||||
DialogPane pane = new DialogPane();
|
||||
TextArea schemaTextArea = new TextArea();
|
||||
schemaTextArea.setEditable(false);
|
||||
schemaTextArea.getStyleClass().addAll("mono-font", "small-font");
|
||||
try (var in = SqlConsoleViewController.class.getResourceAsStream("/sql/schema.sql")) {
|
||||
if (in == null) throw new IOException("Could not load database schema from resource location.");
|
||||
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
schemaTextArea.setText(schemaStr);
|
||||
} catch (IOException e) {
|
||||
schemaTextArea.setText("Failed to load schema file!");
|
||||
}
|
||||
pane.setContent(schemaTextArea);
|
||||
pane.getButtonTypes().add(ButtonType.OK);
|
||||
|
||||
initOwner(owner);
|
||||
initModality(Modality.NONE);
|
||||
initStyle(StageStyle.DECORATED);
|
||||
setResizable(true);
|
||||
setTitle("Perfin Database Schema");
|
||||
setDialogPane(pane);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,7 +9,6 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
|
|||
import com.andrewlalis.perfin.data.pagination.Sort;
|
||||
import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
|
||||
import com.andrewlalis.perfin.data.search.SearchFilter;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.data.util.Pair;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
|
@ -29,11 +28,7 @@ import javafx.scene.control.TextField;
|
|||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
@ -47,13 +42,19 @@ import static com.andrewlalis.perfin.PerfinApp.router;
|
|||
* to a specific page.
|
||||
*/
|
||||
public class TransactionsViewController implements RouteSelectionListener {
|
||||
public static List<Sort> DEFAULT_SORTS = List.of(Sort.desc("timestamp"));
|
||||
public static List<Sort> DEFAULT_SORTS = List.of(
|
||||
Sort.desc("timestamp"),
|
||||
Sort.desc("amount"),
|
||||
Sort.desc("currency")
|
||||
);
|
||||
public record RouteContext(Long selectedTransactionId) {}
|
||||
|
||||
@FXML public BorderPane transactionsListBorderPane;
|
||||
@FXML public TextField searchField;
|
||||
@FXML public AccountSelectionBox filterByAccountComboBox;
|
||||
|
||||
@FXML public VBox transactionsVBox;
|
||||
|
||||
private DataSourcePaginationControls paginationControls;
|
||||
|
||||
|
||||
|
@ -66,6 +67,9 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
paginationControls.setPage(1);
|
||||
selectedTransaction.set(null);
|
||||
});
|
||||
// Add a listener to the search field that sets the page to 1 (thus
|
||||
// doing a new search with the contents of the field), and deselects any
|
||||
// selected transaction.
|
||||
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
paginationControls.setPage(1);
|
||||
selectedTransaction.set(null);
|
||||
|
@ -134,6 +138,7 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
|
||||
// If a transaction id is given in the route context, navigate to the page it's on and select it.
|
||||
if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
|
||||
searchField.setText(null);// First clear the search field if it's already populated.
|
||||
Profile.getCurrent().dataSource().useRepoAsync(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
|
||||
|
@ -155,37 +160,34 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
}
|
||||
|
||||
@FXML public void exportTransactions() {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Export Transactions");
|
||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("CSV Files", ".csv"));
|
||||
File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow());
|
||||
if (file != null) {
|
||||
try (
|
||||
var repo = Profile.getCurrent().dataSource().getTransactionRepository();
|
||||
var out = new PrintWriter(file, StandardCharsets.UTF_8)
|
||||
) {
|
||||
out.println("id,utc-timestamp,amount,currency,description");
|
||||
|
||||
List<Transaction> allTransactions = repo.findAll(PageRequest.unpaged(Sort.desc("timestamp"))).items();
|
||||
for (Transaction tx : allTransactions) {
|
||||
out.println("%d,%s,%s,%s,%s".formatted(
|
||||
tx.id,
|
||||
tx.getTimestamp().format(DateUtil.DEFAULT_DATETIME_FORMAT),
|
||||
tx.getAmount().toPlainString(),
|
||||
tx.getCurrency().getCurrencyCode(),
|
||||
tx.getDescription() == null ? "" : tx.getDescription()
|
||||
));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Popups.error(transactionsListBorderPane, e);
|
||||
}
|
||||
}
|
||||
Popups.message(transactionsListBorderPane, "Exporting transactions is not yet supported.");
|
||||
}
|
||||
|
||||
private List<SearchFilter> getCurrentSearchFilters() {
|
||||
List<SearchFilter> filters = new ArrayList<>();
|
||||
if (searchField.getText() != null && !searchField.getText().isBlank()) {
|
||||
var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+"))
|
||||
final String text = searchField.getText().strip();
|
||||
// Special case: for input like "#123", search directly for the transaction id.
|
||||
if (text.matches("#\\d+")) {
|
||||
int idQuery = Integer.parseInt(text.substring(1));
|
||||
var filter = new SearchFilter.Builder().where("id = ?").withArg(idQuery).build();
|
||||
return List.of(filter);
|
||||
}
|
||||
// Special case: for input like "tag: abc", search directly for transactions with tags like that.
|
||||
if (text.matches("tag:\\s*.+")) {
|
||||
String tagQuery = "%" + text.substring(4).strip().toLowerCase() + "%";
|
||||
var filter = new SearchFilter.Builder().where("""
|
||||
id IN (
|
||||
SELECT ttj.transaction_id
|
||||
FROM transaction_tag_join ttj
|
||||
LEFT JOIN transaction_tag tt ON tt.id = ttj.tag_id
|
||||
WHERE LOWER(tt.name) LIKE ?
|
||||
)""").withArg(tagQuery).build();
|
||||
return List.of(filter);
|
||||
}
|
||||
|
||||
// General case: split the input into a list of terms, then apply each term in a LIKE %term% query.
|
||||
var likeTerms = Arrays.stream(text.toLowerCase().split("\\s+"))
|
||||
.map(t -> '%'+t+'%')
|
||||
.toList();
|
||||
var builder = new SearchFilter.Builder();
|
||||
|
@ -213,13 +215,6 @@ public class TransactionsViewController implements RouteSelectionListener {
|
|||
return filters;
|
||||
}
|
||||
|
||||
// Temporary utility to try out the new filter builder.
|
||||
private List<SearchFilter> tmpFilter() {
|
||||
return new JdbcTransactionSearcher.FilterBuilder()
|
||||
.byHasLineItems(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
private TransactionTile makeTile(Transaction transaction) {
|
||||
var tile = new TransactionTile(transaction);
|
||||
tile.setOnMouseClicked(event -> {
|
||||
|
|
|
@ -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,15 +24,22 @@ 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);
|
||||
void unarchive(long accountId);
|
||||
|
||||
BigDecimal deriveBalance(long accountId, Instant timestamp);
|
||||
default BigDecimal deriveCurrentBalance(long accountId) {
|
||||
return deriveBalance(accountId, Instant.now(Clock.systemUTC()));
|
||||
BigDecimal deriveCashBalance(long accountId, Instant timestamp);
|
||||
default BigDecimal deriveCurrentCashBalance(long accountId) {
|
||||
return deriveCashBalance(accountId, Instant.now(Clock.systemUTC()));
|
||||
}
|
||||
BigDecimal getNearestAssetValue(long accountId, Instant timestamp);
|
||||
default BigDecimal getNearestAssetValue(long accountId) {
|
||||
return getNearestAssetValue(accountId, Instant.now(Clock.systemUTC()));
|
||||
}
|
||||
|
||||
Set<Currency> findAllUsedCurrencies();
|
||||
List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.data.util.Pair;
|
||||
import com.andrewlalis.perfin.model.MoneyValue;
|
||||
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||
|
||||
|
@ -14,4 +15,13 @@ public interface AnalyticsRepository extends Repository, AutoCloseable {
|
|||
List<Pair<TransactionCategory, BigDecimal>> getIncomeByCategory(TimestampRange range, Currency currency);
|
||||
List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency);
|
||||
List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(TimestampRange range, Currency currency);
|
||||
|
||||
/**
|
||||
* Gets the amount spent, grouped by currency, on a specific vendor.
|
||||
* @param range The time range to search in.
|
||||
* @param vendorId The id of the vendor to search with.
|
||||
* @return A list of money values with the total amount spent in each
|
||||
* currency. An empty list is returned if no money is spent.
|
||||
*/
|
||||
List<MoneyValue> getVendorSpend(TimestampRange range, long vendorId);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data;
|
|||
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import com.andrewlalis.perfin.model.BalanceRecordType;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
|
@ -11,11 +12,11 @@ import java.util.List;
|
|||
import java.util.Optional;
|
||||
|
||||
public interface BalanceRecordRepository extends Repository, AutoCloseable {
|
||||
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
|
||||
BalanceRecord findLatestByAccountId(long accountId);
|
||||
long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments);
|
||||
BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type);
|
||||
Optional<BalanceRecord> findById(long id);
|
||||
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
|
||||
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
|
||||
Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
|
||||
Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
|
||||
List<Attachment> findAttachments(long recordId);
|
||||
void deleteById(long id);
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import javafx.application.Platform;
|
|||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
@ -35,6 +36,7 @@ public interface DataSource {
|
|||
TransactionLineItemRepository getTransactionLineItemRepository();
|
||||
AttachmentRepository getAttachmentRepository();
|
||||
HistoryRepository getHistoryRepository();
|
||||
SavedQueryRepository getSavedQueryRepository();
|
||||
|
||||
AnalyticsRepository getAnalyticsRepository();
|
||||
|
||||
|
@ -91,6 +93,7 @@ public interface DataSource {
|
|||
TransactionLineItemRepository.class, this::getTransactionLineItemRepository,
|
||||
AttachmentRepository.class, this::getAttachmentRepository,
|
||||
HistoryRepository.class, this::getHistoryRepository,
|
||||
SavedQueryRepository.class, this::getSavedQueryRepository,
|
||||
AnalyticsRepository.class, this::getAnalyticsRepository
|
||||
);
|
||||
return (Supplier<R>) repoSuppliers.get(type);
|
||||
|
@ -101,7 +104,7 @@ public interface DataSource {
|
|||
default CompletableFuture<String> getAccountBalanceText(Account account) {
|
||||
CompletableFuture<String> cf = new CompletableFuture<>();
|
||||
mapRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal balance = repo.deriveCurrentBalance(account.id);
|
||||
BigDecimal balance = repo.deriveCurrentCashBalance(account.id);
|
||||
MoneyValue money = new MoneyValue(balance, account.getCurrency());
|
||||
return CurrencyUtil.formatMoney(money);
|
||||
}).thenAccept(s -> Platform.runLater(() -> cf.complete(s)));
|
||||
|
@ -111,17 +114,20 @@ public interface DataSource {
|
|||
/**
|
||||
* Gets a list of combined total assets for each currency that's tracked,
|
||||
* ordered with highest assets first.
|
||||
* @param timestamp The timestamp at which to get the balance.
|
||||
* @return A future that resolves to the list of amounts for each currency.
|
||||
*/
|
||||
default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances() {
|
||||
default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances(Instant timestamp) {
|
||||
return mapRepoAsync(AccountRepository.class, repo -> {
|
||||
List<Account> accounts = repo.findAll(PageRequest.unpaged()).items();
|
||||
Map<Currency, BigDecimal> totals = new HashMap<>();
|
||||
for (var account : accounts) {
|
||||
BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO);
|
||||
BigDecimal accountBalance = repo.deriveCurrentBalance(account.id);
|
||||
BigDecimal accountBalance = repo.deriveCashBalance(account.id, timestamp);
|
||||
BigDecimal accountAssetsValue = repo.getNearestAssetValue(account.id, timestamp);
|
||||
if (account.getType() == AccountType.CREDIT_CARD) accountBalance = accountBalance.negate();
|
||||
totals.put(account.getCurrency(), currencyTotal.add(accountBalance));
|
||||
BigDecimal accountTotal = accountBalance.add(accountAssetsValue);
|
||||
totals.put(account.getCurrency(), currencyTotal.add(accountTotal));
|
||||
}
|
||||
List<MoneyValue> values = new ArrayList<>(totals.size());
|
||||
for (var entry : totals.entrySet()) {
|
||||
|
@ -131,4 +137,8 @@ public interface DataSource {
|
|||
return values;
|
||||
});
|
||||
}
|
||||
|
||||
default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances() {
|
||||
return getCombinedAccountBalances(Instant.now());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,186 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.model.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.file.Files;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
public class SampleProfileGenerator {
|
||||
private final ProfileLoader profileLoader;
|
||||
|
||||
private final Random random;
|
||||
|
||||
public SampleProfileGenerator(ProfileLoader profileLoader) {
|
||||
this.profileLoader = profileLoader;
|
||||
this.random = new Random();
|
||||
}
|
||||
|
||||
public Profile createSampleProfile() throws ProfileLoadException, SQLException, IOException {
|
||||
String name = getNewSampleProfileName();
|
||||
Profile profile = profileLoader.load(name);
|
||||
generateRandomAccounts(profile);
|
||||
generateBrokerageAccountAssetRecords(profile);
|
||||
generateRandomTransactions(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
private String getNewSampleProfileName() {
|
||||
int i = 1;
|
||||
while (true) {
|
||||
String name = "sample-" + i;
|
||||
if (Files.notExists(Profile.getDir(name))) {
|
||||
return name;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
private void generateRandomAccounts(Profile profile) {
|
||||
final int accountsToCreate = random.nextInt(5, 11);
|
||||
AccountRepository accountRepo = profile.dataSource().getAccountRepository();
|
||||
BalanceRecordRepository balanceRecordRepo = profile.dataSource().getBalanceRecordRepository();
|
||||
for (int i = 0; i < accountsToCreate; i++) {
|
||||
long id = accountRepo.insert(
|
||||
randomChoice(AccountType.values()),
|
||||
randomAccountNumber(),
|
||||
"Sample Account " + i,
|
||||
randomChoice(Currency.getInstance("USD"), Currency.getInstance("EUR")),
|
||||
"Description for sample account " + i + "."
|
||||
);
|
||||
Account account = accountRepo.findById(id).orElseThrow();
|
||||
BigDecimal initialBalance = randomMoneyValue(account.getCurrency(), 0, 5000, true);
|
||||
if (account.getType() == AccountType.CREDIT_CARD) {
|
||||
BigDecimal creditLimit = randomMoneyValue(account.getCurrency(), 200, 10000, false);
|
||||
accountRepo.saveCreditCardProperties(new CreditCardProperties(id, creditLimit));
|
||||
}
|
||||
balanceRecordRepo.insert(DateUtil.nowAsUTC(), account.id, BalanceRecordType.CASH, initialBalance, account.getCurrency(), Collections.emptyList());
|
||||
}
|
||||
}
|
||||
|
||||
private void generateBrokerageAccountAssetRecords(Profile profile) {
|
||||
AccountRepository accountRepo = profile.dataSource().getAccountRepository();
|
||||
BalanceRecordRepository balanceRecordRepo = profile.dataSource().getBalanceRecordRepository();
|
||||
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged()).items();
|
||||
for (var account : accounts) {
|
||||
if (account.getType() == AccountType.BROKERAGE) {
|
||||
LocalDateTime cutoff = account.getCreatedAt().minusYears(5);
|
||||
LocalDateTime currentTimestamp = account.getCreatedAt().minusDays(random.nextInt(1, 30));
|
||||
BigDecimal assetValue = randomMoneyValue(account.getCurrency(), 1000, 1_000_000, true);
|
||||
while (currentTimestamp.isAfter(cutoff)) {
|
||||
balanceRecordRepo.insert(
|
||||
currentTimestamp,
|
||||
account.id,
|
||||
BalanceRecordType.ASSETS,
|
||||
assetValue,
|
||||
account.getCurrency(),
|
||||
Collections.emptyList()
|
||||
);
|
||||
double valueAdjustment = random.nextGaussian() * assetValue.doubleValue() / 100.0 - 0.2;
|
||||
assetValue = assetValue.subtract(BigDecimal.valueOf(valueAdjustment)).setScale(4, RoundingMode.HALF_UP);
|
||||
currentTimestamp = currentTimestamp.minusDays(random.nextInt(7, 60));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void generateRandomTransactions(Profile profile) {
|
||||
AccountRepository accountRepo = profile.dataSource().getAccountRepository();
|
||||
TransactionRepository transactionRepo = profile.dataSource().getTransactionRepository();
|
||||
TransactionVendorRepository vendorRepo = profile.dataSource().getTransactionVendorRepository();
|
||||
TransactionCategoryRepository categoryRepo = profile.dataSource().getTransactionCategoryRepository();
|
||||
final int vendorCount = 50;
|
||||
for (int i = 0; i < vendorCount; i++) {
|
||||
vendorRepo.insert("Vendor " + i);
|
||||
}
|
||||
List<String> vendors = vendorRepo.findAll().stream().map(TransactionVendor::getName).toList();
|
||||
final int tagCount = 10;
|
||||
List<String> tags = new ArrayList<>(tagCount);
|
||||
for (int i = 0; i < tagCount; i++) {
|
||||
tags.add("tag-" + i);
|
||||
}
|
||||
List<String> categories = categoryRepo.findAll().stream().map(TransactionCategory::getName).toList();
|
||||
|
||||
for (var account : accountRepo.findAll(PageRequest.unpaged()).items()) {
|
||||
LocalDateTime cutoff = account.getCreatedAt().minusMonths(3);
|
||||
LocalDateTime timestamp = account.getCreatedAt().minusSeconds(random.nextInt(60, 60*60*24));
|
||||
while (timestamp.isAfter(cutoff)) {
|
||||
String vendor = null;
|
||||
if (randomChance(0.75)) {
|
||||
vendor = randomChoice(vendors);
|
||||
}
|
||||
String category = null;
|
||||
if (randomChance(0.8)) {
|
||||
category = randomChoice(categories);
|
||||
}
|
||||
Set<String> tagsToUse = new HashSet<>();
|
||||
if (randomChance(0.75)) {
|
||||
for (int i = 0; i < random.nextInt(3); i++) {
|
||||
tagsToUse.add(randomChoice(tags));
|
||||
}
|
||||
}
|
||||
BigDecimal transactionAmount = randomMoneyValue(account.getCurrency(), 1, 500, true);
|
||||
CreditAndDebitAccounts accounts = new CreditAndDebitAccounts(account, null);
|
||||
if (randomChance(0.1)) {
|
||||
accounts = new CreditAndDebitAccounts(null, account);
|
||||
transactionAmount = randomMoneyValue(account.getCurrency(), 500, 2000, true);
|
||||
}
|
||||
|
||||
transactionRepo.insert(
|
||||
timestamp,
|
||||
transactionAmount,
|
||||
account.getCurrency(),
|
||||
"Sample transaction description.",
|
||||
accounts,
|
||||
vendor,
|
||||
category,
|
||||
tagsToUse,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList()
|
||||
);
|
||||
|
||||
timestamp = timestamp.minusSeconds(random.nextInt(60, 60*60*24 * 30));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal randomMoneyValue(Currency currency, int min, int max, boolean includeDecimals) {
|
||||
int wholeValue = random.nextInt(min, max + 1);
|
||||
BigDecimal value = BigDecimal.valueOf(wholeValue * 10000L, 4);
|
||||
if (includeDecimals && currency.getDefaultFractionDigits() > 0) {
|
||||
int orderOfMagnitude = (int) Math.pow(10, currency.getDefaultFractionDigits());
|
||||
int decimalValue = random.nextInt( orderOfMagnitude + 1);
|
||||
BigDecimal fractionalValue = BigDecimal.valueOf(decimalValue, currency.getDefaultFractionDigits());
|
||||
value = value.add(fractionalValue);
|
||||
}
|
||||
return value.setScale(4, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private String randomAccountNumber() {
|
||||
String alphabet = "0123456789";
|
||||
StringBuilder sb = new StringBuilder(16);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
sb.append(alphabet.charAt(random.nextInt(alphabet.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private <T> T randomChoice(T... items) {
|
||||
return items[random.nextInt(items.length)];
|
||||
}
|
||||
|
||||
private <T> T randomChoice(List<T> items) {
|
||||
return items.get(random.nextInt(items.size()));
|
||||
}
|
||||
|
||||
private boolean randomChance(double percentChance) {
|
||||
return random.nextDouble() <= percentChance;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface SavedQueryRepository extends Repository {
|
||||
List<String> getSavedQueries();
|
||||
String getSavedQueryContent(String name);
|
||||
void createSavedQuery(String name, String content);
|
||||
void deleteSavedQuery(String name);
|
||||
}
|
|
@ -13,6 +13,11 @@ public record TimestampRange(LocalDateTime start, LocalDateTime end) {
|
|||
return new TimestampRange(now.minusDays(days), now);
|
||||
}
|
||||
|
||||
public static TimestampRange lastNMonths(int months) {
|
||||
LocalDateTime now = DateUtil.nowAsUTC();
|
||||
return new TimestampRange(now.minusMonths(months), now);
|
||||
}
|
||||
|
||||
public static TimestampRange thisMonth() {
|
||||
LocalDateTime localStartOfMonth = LocalDate.now(ZoneId.systemDefault()).atStartOfDay().withDayOfMonth(1);
|
||||
LocalDateTime utcStart = localStartOfMonth.atZone(ZoneId.systemDefault())
|
||||
|
|
|
@ -31,10 +31,13 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
|||
Optional<Transaction> findById(long id);
|
||||
Page<Transaction> findAll(PageRequest pagination);
|
||||
List<Transaction> findRecentN(int n);
|
||||
List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency);
|
||||
long countAll();
|
||||
long countAllAfter(long transactionId);
|
||||
long countAllByAccounts(Set<Long> accountIds);
|
||||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||
Optional<Transaction> findEarliest();
|
||||
Optional<Transaction> findLatest();
|
||||
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
||||
List<Attachment> findAttachments(long transactionId);
|
||||
List<String> findTags(long transactionId);
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.SavedQueryRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public record FileSystemSavedQueryRepository(Path contentDir) implements SavedQueryRepository {
|
||||
private static final Logger log = LoggerFactory.getLogger(FileSystemSavedQueryRepository.class);
|
||||
|
||||
private Path queriesDir() {
|
||||
return contentDir.resolve("saved-queries");
|
||||
}
|
||||
|
||||
private Path queryFile(String name) {
|
||||
return queriesDir().resolve(name + ".sql");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getSavedQueries() {
|
||||
Path dir = queriesDir();
|
||||
if (Files.notExists(dir)) return Collections.emptyList();
|
||||
try (var stream = Files.list(dir)) {
|
||||
return stream.filter(p ->
|
||||
Files.isRegularFile(p) &&
|
||||
p.getFileName().toString().toLowerCase().endsWith(".sql")
|
||||
)
|
||||
.map(p -> {
|
||||
var s = p.getFileName().toString();
|
||||
int idx = s.lastIndexOf('.');
|
||||
return s.substring(0, idx);
|
||||
})
|
||||
.sorted()
|
||||
.toList();
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to list files", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSavedQueryContent(String name) {
|
||||
Path file = queryFile(name);
|
||||
if (Files.notExists(file)) return null;
|
||||
try {
|
||||
return Files.readString(file);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read saved query content", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void createSavedQuery(String name, String content) {
|
||||
try {
|
||||
if (Files.notExists(queriesDir())) {
|
||||
Files.createDirectory(queriesDir());
|
||||
}
|
||||
Path file = queryFile(name);
|
||||
Files.writeString(file, content);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to create saved query.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteSavedQuery(String name) {
|
||||
try {
|
||||
Files.deleteIfExists(queryFile(name));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to delete saved query.");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
@ -114,7 +117,49 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal deriveBalance(long accountId, Instant timestamp) {
|
||||
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.
|
||||
Account account = findById(accountId).orElse(null);
|
||||
if (account == null) throw new EntityNotFoundException(Account.class, accountId);
|
||||
|
@ -122,7 +167,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
|
||||
AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn);
|
||||
// Find the most recent balance record before timestamp.
|
||||
Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp);
|
||||
Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, BalanceRecordType.CASH, utcTimestamp);
|
||||
if (closestPastRecord.isPresent()) {
|
||||
// Then find any entries on the account since that balance record and the timestamp.
|
||||
List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
|
||||
|
@ -133,7 +178,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow);
|
||||
} else {
|
||||
// There is no balance record present before the given timestamp. Try and find the closest one after.
|
||||
Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp);
|
||||
Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, BalanceRecordType.CASH, utcTimestamp);
|
||||
if (closestFutureRecord.isPresent()) {
|
||||
// Now find any entries on the account from the timestamp until that balance record.
|
||||
List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
|
||||
|
@ -145,13 +190,22 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
} else {
|
||||
// No balance records exist for the account! Assume balance of 0 when the account was created.
|
||||
log.warn("No balance record exists for account {}! Assuming balance was 0 at account creation.", account.getShortName());
|
||||
BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BigDecimal.ZERO, account.getCurrency());
|
||||
BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BalanceRecordType.CASH, BigDecimal.ZERO, account.getCurrency());
|
||||
List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp);
|
||||
return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BigDecimal getNearestAssetValue(long accountId, Instant timestamp) {
|
||||
LocalDateTime utcTimestamp = timestamp.atZone(ZoneOffset.UTC).toLocalDateTime();
|
||||
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
|
||||
Optional<BalanceRecord> mostRecentRecord = balanceRecordRepo.findClosestBefore(accountId, BalanceRecordType.ASSETS, utcTimestamp);
|
||||
if (mostRecentRecord.isEmpty()) return BigDecimal.ZERO;
|
||||
return mostRecentRecord.get().getBalance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Currency> findAllUsedCurrencies() {
|
||||
return new HashSet<>(DbUtil.findAll(
|
||||
|
@ -179,7 +233,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
|||
SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id
|
||||
FROM balance_record
|
||||
)
|
||||
WHERE account_id = ? AND timestamp <= ?
|
||||
WHERE account_id = ? AND timestamp < ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT\s""" + maxResults;
|
||||
try (var stmt = conn.prepareStatement(query)) {
|
||||
|
@ -245,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
|
||||
|
@ -280,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();
|
||||
|
@ -301,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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.TimestampRange;
|
|||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.data.util.Pair;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.MoneyValue;
|
||||
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||
import javafx.scene.paint.Color;
|
||||
|
@ -72,6 +73,38 @@ public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepos
|
|||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MoneyValue> getVendorSpend(TimestampRange range, long vendorId) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"""
|
||||
SELECT
|
||||
SUM(transaction.amount) AS total,
|
||||
transaction.currency AS currency,
|
||||
FROM transaction
|
||||
WHERE
|
||||
transaction.vendor_id = ? AND
|
||||
transaction.timestamp >= ? AND
|
||||
transaction.timestamp <= ? AND
|
||||
'!exclude' NOT IN (
|
||||
SELECT tt.name
|
||||
FROM transaction_tag tt
|
||||
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
|
||||
WHERE ttj.transaction_id = transaction.id
|
||||
) AND
|
||||
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id) = 1 AND
|
||||
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id AND ae.type = 'CREDIT') = 1
|
||||
GROUP BY transaction.currency
|
||||
ORDER BY total DESC""",
|
||||
List.of(vendorId, range.start(), range.end()),
|
||||
rs -> {
|
||||
BigDecimal total = rs.getBigDecimal(1);
|
||||
String currencyCode = rs.getString(2);
|
||||
return new MoneyValue(total, Currency.getInstance(currencyCode));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
conn.close();
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
|||
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||
import com.andrewlalis.perfin.model.Attachment;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import com.andrewlalis.perfin.model.BalanceRecordType;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
|
@ -18,12 +19,12 @@ import java.util.Optional;
|
|||
|
||||
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
|
||||
@Override
|
||||
public long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments) {
|
||||
public long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments) {
|
||||
return DbUtil.doTransaction(conn, () -> {
|
||||
long recordId = DbUtil.insertOne(
|
||||
conn,
|
||||
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
|
||||
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, balance, currency.getCurrencyCode())
|
||||
"INSERT INTO balance_record (timestamp, account_id, type, balance, currency) VALUES (?, ?, ?, ?, ?)",
|
||||
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, type.name(), balance, currency.getCurrencyCode())
|
||||
);
|
||||
// Insert attachments.
|
||||
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||
|
@ -39,11 +40,11 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
|||
}
|
||||
|
||||
@Override
|
||||
public BalanceRecord findLatestByAccountId(long accountId) {
|
||||
public BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type) {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM balance_record WHERE account_id = ? ORDER BY timestamp DESC LIMIT 1",
|
||||
List.of(accountId),
|
||||
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? ORDER BY timestamp DESC LIMIT 1",
|
||||
List.of(accountId, type.name()),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
).orElse(null);
|
||||
}
|
||||
|
@ -59,21 +60,21 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
|||
}
|
||||
|
||||
@Override
|
||||
public Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp) {
|
||||
public Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
|
||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
|
||||
List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp) {
|
||||
public Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
|
||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
|
||||
List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||
JdbcBalanceRecordRepository::parse
|
||||
);
|
||||
}
|
||||
|
@ -108,6 +109,7 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
|||
rs.getLong("id"),
|
||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||
rs.getLong("account_id"),
|
||||
BalanceRecordType.valueOf(rs.getString("type").toUpperCase()),
|
||||
rs.getBigDecimal("balance"),
|
||||
Currency.getInstance(rs.getString("currency"))
|
||||
);
|
||||
|
|
|
@ -74,6 +74,11 @@ public class JdbcDataSource implements DataSource {
|
|||
return new JdbcHistoryRepository(getConnection());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SavedQueryRepository getSavedQueryRepository() {
|
||||
return new FileSystemSavedQueryRepository(contentDir);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AnalyticsRepository getAnalyticsRepository() {
|
||||
return new JdbcAnalyticsRepository(getConnection());
|
||||
|
|
|
@ -34,8 +34,11 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
|
|||
* loaded with an old schema version, then we'll migrate to the latest. If
|
||||
* the profile has a newer schema version, we'll exit and prompt the user
|
||||
* to update their app.
|
||||
* <p>
|
||||
* This value should be one higher than the
|
||||
* </p>
|
||||
*/
|
||||
public static final int SCHEMA_VERSION = 4;
|
||||
public static final int SCHEMA_VERSION = 6;
|
||||
|
||||
public DataSource getDataSource(String profileName) throws ProfileLoadException {
|
||||
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
|
||||
|
|
|
@ -153,6 +153,16 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
|||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency) {
|
||||
return DbUtil.findAll(
|
||||
conn,
|
||||
"SELECT * FROM transaction WHERE timestamp = ? AND amount = ? AND currency = ? ORDER BY timestamp DESC",
|
||||
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode()),
|
||||
JdbcTransactionRepository::parseTransaction
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countAll() {
|
||||
return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L);
|
||||
|
@ -192,6 +202,26 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
|||
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Transaction> findEarliest() {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM transaction ORDER BY timestamp ASC LIMIT 1",
|
||||
Collections.emptyList(),
|
||||
JdbcTransactionRepository::parseTransaction
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Transaction> findLatest() {
|
||||
return DbUtil.findOne(
|
||||
conn,
|
||||
"SELECT * FROM transaction ORDER BY timestamp DESC LIMIT 1",
|
||||
Collections.emptyList(),
|
||||
JdbcTransactionRepository::parseTransaction
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CreditAndDebitAccounts findLinkedAccounts(long transactionId) {
|
||||
Account creditAccount = DbUtil.findOne(
|
||||
|
|
|
@ -19,6 +19,8 @@ public class Migrations {
|
|||
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -114,4 +114,10 @@ public class FileUtil {
|
|||
in.transferTo(out);
|
||||
}
|
||||
}
|
||||
|
||||
public static String escapeCSVText(String raw) {
|
||||
if (raw == null) return "NULL";
|
||||
if (!raw.contains("\"") && !raw.contains(",") && !raw.contains(";")) return raw;
|
||||
return '"' + raw.replaceAll("\"", "\"\"") + '"';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,20 +5,20 @@ import java.time.LocalDateTime;
|
|||
import java.util.Currency;
|
||||
|
||||
/**
|
||||
* A recording of an account's real reported balance at a given point in time,
|
||||
* used as a sanity check for ensuring that an account's entries add up to the
|
||||
* correct balance.
|
||||
* A recording of an account's real reported balance at a given point in time.
|
||||
*/
|
||||
public class BalanceRecord extends IdEntity implements Timestamped {
|
||||
private final LocalDateTime timestamp;
|
||||
private final long accountId;
|
||||
private final BalanceRecordType type;
|
||||
private final BigDecimal balance;
|
||||
private final Currency currency;
|
||||
|
||||
public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BigDecimal balance, Currency currency) {
|
||||
public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency) {
|
||||
super(id);
|
||||
this.timestamp = timestamp;
|
||||
this.accountId = accountId;
|
||||
this.type = type;
|
||||
this.balance = balance;
|
||||
this.currency = currency;
|
||||
}
|
||||
|
@ -31,6 +31,10 @@ public class BalanceRecord extends IdEntity implements Timestamped {
|
|||
return accountId;
|
||||
}
|
||||
|
||||
public BalanceRecordType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public BigDecimal getBalance() {
|
||||
return balance;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package com.andrewlalis.perfin.model;
|
||||
|
||||
public enum BalanceRecordType {
|
||||
CASH("Cash"),
|
||||
ASSETS("Assets");
|
||||
|
||||
private final String name;
|
||||
|
||||
BalanceRecordType(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return name;
|
||||
}
|
||||
}
|
|
@ -2,6 +2,14 @@ package com.andrewlalis.perfin.model;
|
|||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* A simple pair of accounts representing the two possible linked accounts for a
|
||||
* {@link Transaction}.
|
||||
* @param creditAccount The account linked as the account to which the
|
||||
* transaction amount is credited.
|
||||
* @param debitAccount The account linked as the account from which the
|
||||
* transaction amount is debited.
|
||||
*/
|
||||
public record CreditAndDebitAccounts(Account creditAccount, Account debitAccount) {
|
||||
public boolean hasCredit() {
|
||||
return creditAccount != null;
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package com.andrewlalis.perfin.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record CreditCardProperties(
|
||||
long accountId,
|
||||
BigDecimal creditLimit
|
||||
) {}
|
|
@ -52,6 +52,7 @@ public class ProfileBackups {
|
|||
}
|
||||
|
||||
public static LocalDateTime getLastBackupTimestamp(String name) {
|
||||
if (Files.notExists(getBackupDir(name))) return null;
|
||||
try (var files = Files.list(getBackupDir(name))) {
|
||||
return files.map(ProfileBackups::getTimestampFromBackup)
|
||||
.max(LocalDateTime::compareTo)
|
||||
|
|
|
@ -3,12 +3,10 @@ package com.andrewlalis.perfin.view.component;
|
|||
import com.andrewlalis.perfin.control.TransactionsViewController;
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.DataSource;
|
||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||
import com.andrewlalis.perfin.model.AccountEntry;
|
||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.Timestamped;
|
||||
import com.andrewlalis.perfin.model.*;
|
||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||
import com.andrewlalis.perfin.view.BindingUtil;
|
||||
import javafx.application.Platform;
|
||||
|
@ -27,6 +25,8 @@ import javafx.scene.text.TextFlow;
|
|||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
||||
|
@ -60,7 +60,7 @@ public class AccountHistoryView extends ScrollPane {
|
|||
int maxItems = initialItemsToLoadProperty.get();
|
||||
DataSource ds = Profile.getCurrent().dataSource();
|
||||
ds.mapRepoAsync(AccountRepository.class, repo -> repo.findEventsBefore(accountId, lastTimestamp(), maxItems))
|
||||
.thenAccept(entities -> Platform.runLater(() -> addEntitiesToHistory(entities, maxItems)));
|
||||
.thenAccept(entities -> addEntitiesToHistory(entities, maxItems));
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
|
@ -91,48 +91,69 @@ public class AccountHistoryView extends ScrollPane {
|
|||
return lastTimestamp;
|
||||
}
|
||||
|
||||
private Node makeTile(Timestamped entity) {
|
||||
private CompletableFuture<Node> makeTile(Timestamped entity) {
|
||||
switch (entity) {
|
||||
case HistoryTextItem textItem -> {
|
||||
return new AccountHistoryTile(textItem.getTimestamp(), new TextFlow(new Text(textItem.getDescription())));
|
||||
return CompletableFuture.completedFuture(
|
||||
new AccountHistoryTile(textItem.getTimestamp(), new TextFlow(new Text(textItem.getDescription())))
|
||||
);
|
||||
}
|
||||
case AccountEntry ae -> {
|
||||
Hyperlink txLink = new Hyperlink("Transaction #" + ae.getTransactionId());
|
||||
txLink.setOnAction(event -> router.navigate("transactions", new TransactionsViewController.RouteContext(ae.getTransactionId())));
|
||||
String descriptionFormat = ae.getType() == AccountEntry.Type.CREDIT
|
||||
? "credited %s from this account."
|
||||
: "debited %s to this account.";
|
||||
String description = descriptionFormat.formatted(CurrencyUtil.formatMoney(ae.getMoneyValue()));
|
||||
TextFlow textFlow = new TextFlow(txLink, new Text(description));
|
||||
return new AccountHistoryTile(ae.getTimestamp(), textFlow);
|
||||
? "credited %s from this account"
|
||||
: "debited %s to this account";
|
||||
final String description = descriptionFormat.formatted(CurrencyUtil.formatMoney(ae.getMoneyValue()));
|
||||
|
||||
CompletableFuture<Node> future = new CompletableFuture<>();
|
||||
Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> {
|
||||
Optional<Transaction> optionalTransaction = repo.findById(ae.getTransactionId());
|
||||
String extraText = optionalTransaction.map(transaction -> ": " + transaction.getDescription())
|
||||
.orElse(". No transaction information found.");
|
||||
TextFlow textFlow = new TextFlow(txLink, new Text(description + extraText));
|
||||
future.complete(new AccountHistoryTile(ae.getTimestamp(), textFlow));
|
||||
});
|
||||
return future;
|
||||
|
||||
}
|
||||
case BalanceRecord br -> {
|
||||
Hyperlink brLink = new Hyperlink("Balance Record #" + br.id);
|
||||
brLink.setOnAction(event -> router.navigate("balance-record", br));
|
||||
return new AccountHistoryTile(br.getTimestamp(), new TextFlow(
|
||||
String phrase = switch(br.getType()) {
|
||||
case CASH -> "a cash value";
|
||||
case ASSETS -> "an asset value";
|
||||
};
|
||||
return CompletableFuture.completedFuture(new AccountHistoryTile(br.getTimestamp(), new TextFlow(
|
||||
brLink,
|
||||
new Text("added with a value of %s.".formatted(CurrencyUtil.formatMoney(br.getMoneyAmount())))
|
||||
));
|
||||
new Text("added with %s of %s.".formatted(phrase, CurrencyUtil.formatMoney(br.getMoneyAmount())))
|
||||
)));
|
||||
}
|
||||
default -> {
|
||||
return new AccountHistoryTile(entity.getTimestamp(), new TextFlow(new Text("Unsupported entity: " + entity.getClass().getName())));
|
||||
return CompletableFuture.completedFuture(
|
||||
new AccountHistoryTile(entity.getTimestamp(), new TextFlow(new Text("Unsupported entity: " + entity.getClass().getName())))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addEntitiesToHistory(List<Timestamped> entities, int requestedItems) {
|
||||
if (!itemsVBox.getChildren().isEmpty()) {
|
||||
itemsVBox.getChildren().add(new Separator(Orientation.HORIZONTAL));
|
||||
}
|
||||
itemsVBox.getChildren().addAll(entities.stream()
|
||||
.map(this::makeTile)
|
||||
var futures = entities.stream().map(this::makeTile).toList();
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
||||
.thenRun(() -> {
|
||||
List<AnchorPane> tiles = futures.stream().map(CompletableFuture::join)
|
||||
.map(tile -> {
|
||||
// Use this to scrunch content to the left.
|
||||
AnchorPane ap = new AnchorPane(tile);
|
||||
AnchorPane.setLeftAnchor(tile, 0.0);
|
||||
return ap;
|
||||
})
|
||||
.toList());
|
||||
.toList();
|
||||
Platform.runLater(() -> {
|
||||
if (!itemsVBox.getChildren().isEmpty()) {
|
||||
itemsVBox.getChildren().add(new Separator(Orientation.HORIZONTAL));
|
||||
}
|
||||
itemsVBox.getChildren().addAll(tiles);
|
||||
if (entities.size() < requestedItems) {
|
||||
canLoadMore.set(false);
|
||||
BorderPane endMarker = new BorderPane(new Label("This is the start of the history."));
|
||||
|
@ -142,5 +163,7 @@ public class AccountHistoryView extends ScrollPane {
|
|||
if (!entities.isEmpty()) {
|
||||
lastTimestamp = entities.getLast().getTimestamp();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -113,7 +113,7 @@ public class AccountSelectionBox extends ComboBox<Account> {
|
|||
nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
|
||||
if (showBalanceProp.get()) {
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal balance = repo.deriveCurrentBalance(item.id);
|
||||
BigDecimal balance = repo.deriveCurrentCashBalance(item.id);
|
||||
Platform.runLater(() -> {
|
||||
balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));
|
||||
balanceLabel.setVisible(true);
|
||||
|
|
|
@ -17,6 +17,7 @@ import javafx.scene.layout.HBox;
|
|||
import javafx.scene.layout.Priority;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||
|
@ -83,7 +84,7 @@ public class AccountTile extends BorderPane {
|
|||
balanceLabel.getStyleClass().addAll("mono-font");
|
||||
balanceLabel.setDisable(true);
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal balance = repo.deriveCurrentBalance(account.id);
|
||||
BigDecimal balance = repo.deriveCurrentCashBalance(account.id);
|
||||
String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()));
|
||||
Platform.runLater(() -> {
|
||||
balanceLabel.setText(text);
|
||||
|
@ -104,6 +105,32 @@ public class AccountTile extends BorderPane {
|
|||
newPropertyLabel("Current Balance"),
|
||||
balanceLabel
|
||||
);
|
||||
|
||||
if (account.getType() == AccountType.BROKERAGE) {
|
||||
Label assetValueLabel = new Label("Computing assets value...");
|
||||
assetValueLabel.getStyleClass().addAll("mono-font");
|
||||
assetValueLabel.setDisable(true);
|
||||
|
||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||
BigDecimal assetValue = repo.getNearestAssetValue(account.id);
|
||||
String text = CurrencyUtil.formatMoney(new MoneyValue(assetValue, account.getCurrency()));
|
||||
Platform.runLater(() -> {
|
||||
assetValueLabel.setText(text);
|
||||
if (account.getType().areDebitsPositive() && assetValue.compareTo(BigDecimal.ZERO) < 0) {
|
||||
assetValueLabel.getStyleClass().add("negative-color-text-fill");
|
||||
} else if (!account.getType().areDebitsPositive() && assetValue.compareTo(BigDecimal.ZERO) < 0) {
|
||||
assetValueLabel.getStyleClass().add("positive-color-text-fill");
|
||||
}
|
||||
assetValueLabel.setDisable(false);
|
||||
});
|
||||
});
|
||||
|
||||
propertiesPane.getChildren().addAll(
|
||||
newPropertyLabel("Latest Assets Value"),
|
||||
assetValueLabel
|
||||
);
|
||||
}
|
||||
|
||||
return propertiesPane;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.andrewlalis.perfin.view.component.module;
|
|||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||
import com.andrewlalis.perfin.model.Account;
|
||||
import com.andrewlalis.perfin.model.AccountType;
|
||||
import com.andrewlalis.perfin.model.MoneyValue;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.view.component.AccountTile;
|
||||
|
@ -91,13 +92,17 @@ public class AccountsModule extends DashboardModule {
|
|||
Label typeLabel = new Label(account.getType().toString());
|
||||
typeLabel.getStyleClass().add("bold-text");
|
||||
typeLabel.setStyle("-fx-text-fill: " + AccountTile.ACCOUNT_TYPE_COLORS.get(account.getType()));
|
||||
|
||||
VBox rightSideVBox = new VBox();
|
||||
rightSideVBox.getStyleClass().addAll("std-spacing");
|
||||
Label balanceLabel = new Label("Computing balance...");
|
||||
balanceLabel.getStyleClass().addAll("mono-font");
|
||||
balanceLabel.setDisable(true);
|
||||
rightSideVBox.getChildren().add(balanceLabel);
|
||||
|
||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
AccountRepository.class,
|
||||
repo -> repo.deriveCurrentBalance(account.id)
|
||||
repo -> repo.deriveCurrentCashBalance(account.id)
|
||||
).thenAccept(bal -> Platform.runLater(() -> {
|
||||
String text = CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(bal, account.getCurrency()));
|
||||
balanceLabel.setText(text);
|
||||
|
@ -109,9 +114,29 @@ public class AccountsModule extends DashboardModule {
|
|||
balanceLabel.setDisable(false);
|
||||
}));
|
||||
|
||||
if (account.getType() == AccountType.BROKERAGE) {
|
||||
Label assetValueLabel = new Label("Computing assets value...");
|
||||
assetValueLabel.getStyleClass().addAll("mono-font");
|
||||
assetValueLabel.setDisable(true);
|
||||
rightSideVBox.getChildren().add(assetValueLabel);
|
||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
AccountRepository.class,
|
||||
repo -> repo.getNearestAssetValue(account.id)
|
||||
).thenAccept(value -> Platform.runLater(() -> {
|
||||
String text = CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(value, account.getCurrency()));
|
||||
assetValueLabel.setText(text + " in assets");
|
||||
if (account.getType().areDebitsPositive() && value.compareTo(BigDecimal.ZERO) < 0) {
|
||||
assetValueLabel.getStyleClass().add("negative-color-text-fill");
|
||||
} else if (!account.getType().areDebitsPositive() && value.compareTo(BigDecimal.ZERO) < 0) {
|
||||
assetValueLabel.getStyleClass().add("positive-color-text-fill");
|
||||
}
|
||||
assetValueLabel.setDisable(false);
|
||||
}));
|
||||
}
|
||||
|
||||
VBox contentBox = new VBox(nameLabel, numberLabel, typeLabel);
|
||||
borderPane.setCenter(contentBox);
|
||||
borderPane.setRight(balanceLabel);
|
||||
borderPane.setRight(rightSideVBox);
|
||||
return borderPane;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,7 @@ public abstract class PieChartModule extends DashboardModule {
|
|||
|
||||
this.timeRangeChoiceBox.getItems().addAll(RANGE_CHOICES);
|
||||
this.timeRangeChoiceBox.getSelectionModel().select("All Time");
|
||||
this.currencyChoiceBox.managedProperty().bind(this.currencyChoiceBox.visibleProperty());
|
||||
|
||||
PieChart chart = new PieChart(chartData);
|
||||
chart.setLegendVisible(false);
|
||||
|
@ -68,9 +69,7 @@ public abstract class PieChartModule extends DashboardModule {
|
|||
chartData.clear();
|
||||
}
|
||||
});
|
||||
timeRangeChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
renderChart();
|
||||
});
|
||||
timeRangeChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> renderChart());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -136,6 +135,7 @@ public abstract class PieChartModule extends DashboardModule {
|
|||
} else {
|
||||
currencyChoiceBox.getSelectionModel().selectFirst();
|
||||
}
|
||||
currencyChoiceBox.setVisible(orderedCurrencies.size() > 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,188 @@
|
|||
package com.andrewlalis.perfin.view.component.module;
|
||||
|
||||
import com.andrewlalis.perfin.data.AccountRepository;
|
||||
import com.andrewlalis.perfin.data.TimestampRange;
|
||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
||||
import com.andrewlalis.perfin.model.Profile;
|
||||
import com.andrewlalis.perfin.model.Transaction;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.chart.*;
|
||||
import javafx.scene.control.ChoiceBox;
|
||||
import javafx.scene.layout.Pane;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* A module for visualizing the total asset value in the user's profile over
|
||||
* a configurable period of time.
|
||||
*/
|
||||
public class TotalAssetsGraphModule extends DashboardModule {
|
||||
private final ObservableList<XYChart.Data<String, Number>> totalAssetDataPoints = FXCollections.observableArrayList();
|
||||
private final ChoiceBox<Currency> currencyChoiceBox = new ChoiceBox<>();
|
||||
private final ChoiceBox<String> timeRangeChoiceBox = new ChoiceBox<>();
|
||||
|
||||
private static final String PREFERRED_CURRENCY_SETTING = "charts.total-assets.default-currency";
|
||||
private static final String PREFERRED_TIME_RANGE_SETTING = "charts.total-assets.default-time-range";
|
||||
|
||||
private record TimeRangeOption(String name, Supplier<TimestampRange> rangeSupplier) {}
|
||||
|
||||
private static final TimeRangeOption[] TIME_RANGE_OPTIONS = {
|
||||
new TimeRangeOption("Last 90 Days", () -> TimestampRange.lastNDays(90)),
|
||||
new TimeRangeOption("Last 6 Months", () -> TimestampRange.lastNMonths(6)),
|
||||
new TimeRangeOption("Last 12 Months", () -> TimestampRange.lastNMonths(12)),
|
||||
new TimeRangeOption("This Year", TimestampRange::thisYear),
|
||||
new TimeRangeOption("Last 5 Years", () -> TimestampRange.lastNMonths(60)),
|
||||
new TimeRangeOption("All Time", TimestampRange::unbounded)
|
||||
};
|
||||
|
||||
public TotalAssetsGraphModule(Pane parent) {
|
||||
super(parent);
|
||||
Axis<String> xAxis = new CategoryAxis();
|
||||
Axis<Number> yAxis = new NumberAxis();
|
||||
xAxis.setAnimated(false);
|
||||
yAxis.setAnimated(false);
|
||||
|
||||
LineChart<String, Number> chart = new LineChart<>(xAxis, yAxis, FXCollections.observableArrayList(
|
||||
new XYChart.Series<>("Total Assets", totalAssetDataPoints)
|
||||
));
|
||||
chart.setLegendVisible(false);
|
||||
chart.setAnimated(false);
|
||||
// Init currency choice box.
|
||||
currencyChoiceBox.managedProperty().bind(currencyChoiceBox.visibleProperty());
|
||||
currencyChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue != null) {
|
||||
renderChart();
|
||||
} else {
|
||||
totalAssetDataPoints.clear();
|
||||
}
|
||||
});
|
||||
// Init time range choice box.
|
||||
timeRangeChoiceBox.getItems().addAll(
|
||||
Arrays.stream(TIME_RANGE_OPTIONS).map(TimeRangeOption::name).toList()
|
||||
);
|
||||
timeRangeChoiceBox.getSelectionModel().select(TIME_RANGE_OPTIONS[0].name());
|
||||
timeRangeChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> renderChart());
|
||||
// Add all the elements to the module VBox.
|
||||
this.getChildren().add(new ModuleHeader(
|
||||
"Total Assets over Time",
|
||||
timeRangeChoiceBox,
|
||||
currencyChoiceBox
|
||||
));
|
||||
this.getChildren().add(chart);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void refreshContents() {
|
||||
refreshCurrencies();
|
||||
String savedTimeRangeLabel = Profile.getCurrent().getSetting(PREFERRED_TIME_RANGE_SETTING).orElse(null);
|
||||
if (savedTimeRangeLabel != null && Arrays.stream(TIME_RANGE_OPTIONS).anyMatch(o -> o.name().equals(savedTimeRangeLabel))) {
|
||||
timeRangeChoiceBox.getSelectionModel().select(savedTimeRangeLabel);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderChart() {
|
||||
totalAssetDataPoints.clear();
|
||||
final Currency currency = currencyChoiceBox.getValue();
|
||||
String timeRangeLabel = timeRangeChoiceBox.getValue();
|
||||
if (currency == null || timeRangeLabel == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
getSelectedTimestampRange().thenAccept(range -> {
|
||||
Duration rangeDuration = Duration.between(range.start(), range.end());
|
||||
boolean useMonths = rangeDuration.dividedBy(Duration.ofDays(1)) > 365;
|
||||
|
||||
|
||||
List<CompletableFuture<Number>> dataFutures = new ArrayList<>();
|
||||
List<String> labels = new ArrayList<>();
|
||||
LocalDateTime currentInterval = range.start();
|
||||
while (currentInterval.isBefore(range.end())) {
|
||||
Instant currentInstant = currentInterval.toInstant(ZoneOffset.UTC);
|
||||
final String label;
|
||||
if (useMonths) {
|
||||
label = currentInterval.format(DateTimeFormatter.ofPattern("MMM yyyy"));
|
||||
} else {
|
||||
label = currentInterval.format(DateTimeFormatter.ofPattern("dd MMM yyyy"));
|
||||
}
|
||||
labels.add(label);
|
||||
dataFutures.add(Profile.getCurrent().dataSource().getCombinedAccountBalances(currentInstant)
|
||||
.thenApply(moneyValues -> moneyValues.stream()
|
||||
.filter(m -> m.currency().equals(currency))
|
||||
.map(m -> m.amount().doubleValue())
|
||||
.findFirst().orElse(0.0)
|
||||
));
|
||||
if (useMonths) {
|
||||
currentInterval = currentInterval.plusMonths(1);
|
||||
} else {
|
||||
currentInterval = currentInterval.plusDays(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Once all futures are complete, we build the final list of data points
|
||||
// for the chart, and send them off.
|
||||
CompletableFuture.allOf(dataFutures.toArray(new CompletableFuture[0])).thenRun(() -> {
|
||||
List<XYChart.Data<String, Number>> dataPoints = new ArrayList<>(dataFutures.size());
|
||||
for (int i = 0; i < dataFutures.size(); i++) {
|
||||
dataPoints.add(new XYChart.Data<>(labels.get(i), dataFutures.get(i).join()));
|
||||
}
|
||||
Platform.runLater(() -> totalAssetDataPoints.setAll(dataPoints));
|
||||
});
|
||||
});
|
||||
|
||||
Profile.getCurrent().setSettingAndSave(PREFERRED_CURRENCY_SETTING, currency.getCurrencyCode());
|
||||
Profile.getCurrent().setSettingAndSave(PREFERRED_TIME_RANGE_SETTING, timeRangeLabel);
|
||||
}
|
||||
|
||||
private CompletableFuture<TimestampRange> getSelectedTimestampRange() {
|
||||
String selectedLabel = timeRangeChoiceBox.getValue();
|
||||
if (selectedLabel == null || Arrays.stream(TIME_RANGE_OPTIONS).noneMatch(o -> o.name().equals(selectedLabel))) {
|
||||
return CompletableFuture.completedFuture(TimestampRange.thisYear());
|
||||
}
|
||||
if (selectedLabel.equals("All Time")) {
|
||||
return Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
TransactionRepository.class,
|
||||
repo -> repo.findEarliest().map(Transaction::getTimestamp)
|
||||
.orElse(LocalDateTime.now(ZoneOffset.UTC).minusYears(1))
|
||||
).thenApply(ts -> new TimestampRange(ts, LocalDateTime.now(ZoneOffset.UTC)));
|
||||
}
|
||||
return CompletableFuture.completedFuture(Arrays.stream(TIME_RANGE_OPTIONS)
|
||||
.filter(o -> o.name().equals(selectedLabel))
|
||||
.findFirst()
|
||||
.map(o -> o.rangeSupplier().get())
|
||||
.orElseThrow());
|
||||
}
|
||||
|
||||
private void refreshCurrencies() {
|
||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||
AccountRepository.class,
|
||||
AccountRepository::findAllUsedCurrencies
|
||||
)
|
||||
.thenAccept(currencies -> {
|
||||
final List<Currency> orderedCurrencies = currencies.isEmpty()
|
||||
? List.of(Currency.getInstance("USD"))
|
||||
: currencies.stream()
|
||||
.sorted(Comparator.comparing(Currency::getCurrencyCode))
|
||||
.toList();
|
||||
final Currency preferredCurrency = Profile.getCurrent().getSetting(PREFERRED_CURRENCY_SETTING)
|
||||
.map(Currency::getInstance).orElse(null);
|
||||
Platform.runLater(() -> {
|
||||
currencyChoiceBox.getItems().setAll(orderedCurrencies);
|
||||
if (preferredCurrency != null && currencies.contains(preferredCurrency)) {
|
||||
currencyChoiceBox.getSelectionModel().select(preferredCurrency);
|
||||
} else {
|
||||
currencyChoiceBox.getSelectionModel().selectFirst();
|
||||
}
|
||||
currencyChoiceBox.setVisible(orderedCurrencies.size() > 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -14,10 +14,12 @@ module com.andrewlalis.perfin {
|
|||
exports com.andrewlalis.perfin to javafx.graphics;
|
||||
exports com.andrewlalis.perfin.view to javafx.graphics;
|
||||
exports com.andrewlalis.perfin.model to javafx.graphics;
|
||||
exports com.andrewlalis.perfin.data.util to javafx.graphics;
|
||||
|
||||
opens com.andrewlalis.perfin.control to javafx.fxml;
|
||||
opens com.andrewlalis.perfin.view to javafx.fxml;
|
||||
opens com.andrewlalis.perfin.view.component to javafx.fxml;
|
||||
opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
|
||||
exports com.andrewlalis.perfin.model.history to javafx.graphics;
|
||||
opens com.andrewlalis.perfin.view.component.module to javafx.fxml;
|
||||
}
|
|
@ -4,7 +4,8 @@
|
|||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.text.*?>
|
||||
<?import javafx.scene.text.Text?>
|
||||
<?import javafx.scene.text.TextFlow?>
|
||||
<BorderPane
|
||||
xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
|
@ -15,10 +16,8 @@
|
|||
</top>
|
||||
<center>
|
||||
<VBox>
|
||||
<!-- Main account properties and actions -->
|
||||
<FlowPane styleClass="std-padding,std-spacing">
|
||||
<!-- Main account properties. -->
|
||||
<PropertiesPane vgap="5" hgap="5">
|
||||
<PropertiesPane vgap="5" hgap="5" styleClass="std-padding,std-spacing">
|
||||
<Label text="Name" styleClass="bold-text"/>
|
||||
<Label fx:id="accountNameLabel"/>
|
||||
|
||||
|
@ -31,18 +30,31 @@
|
|||
<Label text="Created At" styleClass="bold-text"/>
|
||||
<Label fx:id="accountCreatedAtLabel" styleClass="mono-font"/>
|
||||
|
||||
<Label text="Current Balance" styleClass="bold-text"/>
|
||||
<VBox>
|
||||
<Label text="Current Balance" styleClass="bold-text" fx:id="balanceLabel"/>
|
||||
<Text
|
||||
styleClass="small-font,secondary-color-fill"
|
||||
wrappingWidth="${balanceLabel.width}"
|
||||
>Computed using the last recorded balance and all transactions since.</Text>
|
||||
</VBox>
|
||||
<Label fx:id="accountBalanceLabel" styleClass="mono-font"/>
|
||||
<Label
|
||||
styleClass="small-font,secondary-color-fill"
|
||||
>Derived using nearest recorded balance and transactions.</Label>
|
||||
</VBox>
|
||||
</PropertiesPane>
|
||||
</FlowPane>
|
||||
|
||||
<PropertiesPane vgap="5" hgap="5" fx:id="descriptionPane">
|
||||
<PropertiesPane vgap="5" hgap="5" fx:id="assetValuePane" styleClass="std-padding,std-spacing">
|
||||
<Label text="Latest Assets Value" styleClass="bold-text" labelFor="${latestAssetsValueLabel}"/>
|
||||
<VBox>
|
||||
<Label fx:id="latestAssetsValueLabel" styleClass="mono-font"/>
|
||||
<Label
|
||||
styleClass="small-font,secondary-color-fill"
|
||||
>Derived using nearest recorded asset value.</Label>
|
||||
</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>
|
||||
</PropertiesPane>
|
||||
|
@ -51,6 +63,7 @@
|
|||
<HBox fx:id="actionsBox" styleClass="std-padding,std-spacing,small-font">
|
||||
<Button text="Edit" onAction="#goToEditPage"/>
|
||||
<Button text="Record Balance" onAction="#goToCreateBalanceRecord"/>
|
||||
<Button text="Record Asset Value" onAction="#goToCreateAssetRecord"/>
|
||||
<Button text="Archive" onAction="#archiveAccount"/>
|
||||
<Button text="Delete" onAction="#deleteAccount"/>
|
||||
<Button text="Unarchive" onAction="#unarchiveAccount"/>
|
||||
|
|
|
@ -22,6 +22,9 @@
|
|||
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
|
||||
</columnConstraints>
|
||||
|
||||
<Label text="Type" styleClass="bold-text"/>
|
||||
<Label fx:id="typeLabel" styleClass="mono-font"/>
|
||||
|
||||
<Label text="Timestamp" styleClass="bold-text"/>
|
||||
<Label fx:id="timestampLabel" styleClass="mono-font"/>
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import com.andrewlalis.perfin.view.component.AccountSelectionBox?>
|
||||
<?import com.andrewlalis.perfin.view.component.FileSelectionArea?>
|
||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||
<?import com.andrewlalis.perfin.view.component.*?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import com.andrewlalis.perfin.view.component.CategorySelectionBox?>
|
||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
|
||||
|
|
|
@ -11,24 +11,45 @@
|
|||
<Label text="Edit Vendor" styleClass="bold-text,large-font,std-padding"/>
|
||||
</top>
|
||||
<center>
|
||||
<VBox>
|
||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
|
||||
<ScrollPane fitToWidth="true" fitToHeight="true">
|
||||
<VBox style="-fx-max-width: 500px;">
|
||||
<!-- Basic properties -->
|
||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||
</columnConstraints>
|
||||
|
||||
<Label text="Name" labelFor="${nameField}"/>
|
||||
<Label text="Name" labelFor="${nameField}" styleClass="bold-text"/>
|
||||
<TextField fx:id="nameField"/>
|
||||
|
||||
<Label text="Description" labelFor="${descriptionField}"/>
|
||||
<TextArea fx:id="descriptionField" wrapText="true"/>
|
||||
<Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
|
||||
<TextArea
|
||||
fx:id="descriptionField"
|
||||
wrapText="true"
|
||||
style="-fx-pref-height: 100px; -fx-min-height: 100px;"
|
||||
/>
|
||||
</PropertiesPane>
|
||||
|
||||
<!-- Some stats about the vendor -->
|
||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||
</columnConstraints>
|
||||
|
||||
<Label text="Total Spent" labelFor="${totalSpentField}" styleClass="bold-text"/>
|
||||
<Label fx:id="totalSpentField" styleClass="mono-font"/>
|
||||
</PropertiesPane>
|
||||
|
||||
|
||||
<!-- Buttons -->
|
||||
<Separator/>
|
||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
|
||||
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
||||
<Button text="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</ScrollPane>
|
||||
</center>
|
||||
</BorderPane>
|
||||
|
|
|
@ -33,5 +33,7 @@
|
|||
[Adding a Transaction](help:adding-a-transaction)
|
||||
--
|
||||
[Profiles](help:profiles)
|
||||
--
|
||||
[SQL Console](help:sql-console)
|
||||
</StyledText>
|
||||
</VBox>
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<VBox xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
>
|
||||
<StyledText>
|
||||
## SQL Console ##
|
||||
* This feature is for advanced users with experience in relational
|
||||
databases. *
|
||||
--
|
||||
The SQL console is a page where you can write and execute custom SQL
|
||||
queries on the underlying database that your Perfin profile is using.
|
||||
--
|
||||
Unless otherwise noted, Perfin uses the [H2 Database](https://www.h2database.com/html/main.html),
|
||||
but for most common purposes, the SQL query syntax is identical to other
|
||||
common dialects.
|
||||
--
|
||||
Simply write your queries in the upper text area, press **Execute**, and
|
||||
view the results in the output area below. When running multiple queries,
|
||||
be sure to end each with a semicolon (`;`). Results are shown as comma
|
||||
separated columns of text, starting with an initial row for the column
|
||||
names.
|
||||
|
||||
# Comments #
|
||||
Prepend your query with `#` or `//` to exclude it from execution. Each
|
||||
comment character disables all query syntax up until the next semicolon,
|
||||
(`;`).
|
||||
|
||||
# Saved Queries #
|
||||
Click on the **Save Query** button to save the contents of the editor
|
||||
into a *saved query*, which can then be loaded again at any time. You
|
||||
can find the saved query files under `/content/saved-queries`, in your
|
||||
profile's directory.
|
||||
</StyledText>
|
||||
</VBox>
|
|
@ -17,6 +17,7 @@
|
|||
<Button text="Forward" onAction="#goForward"/>
|
||||
<Button text="Dashboard" onAction="#goToDashboard"/>
|
||||
<Button text="Profiles" onAction="#viewProfiles"/>
|
||||
<Button text="SQL Console" onAction="#goToSqlConsole"/>
|
||||
|
||||
<Button text="View Help" fx:id="showManualButton" onAction="#showManual"/>
|
||||
<Button text="Hide Help" fx:id="hideManualButton" onAction="#hideManual"/>
|
||||
|
@ -27,7 +28,7 @@
|
|||
<!-- App footer -->
|
||||
<bottom>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<Label text="Perfin Version 1.11.0"/>
|
||||
<Label text="Perfin Version 1.19.0"/>
|
||||
<AnchorPane>
|
||||
<Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
|
||||
</AnchorPane>
|
||||
|
|
|
@ -37,6 +37,7 @@
|
|||
</ScrollPane>
|
||||
</center>
|
||||
<bottom>
|
||||
<VBox>
|
||||
<BorderPane>
|
||||
<left>
|
||||
<AnchorPane styleClass="std-padding">
|
||||
|
@ -54,5 +55,9 @@
|
|||
</VBox>
|
||||
</right>
|
||||
</BorderPane>
|
||||
<HBox styleClass="std-spacing,std-padding">
|
||||
<Button text="Create Sample Profile" styleClass="small-font" onAction="#createSampleProfile"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</bottom>
|
||||
</BorderPane>
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<HBox
|
||||
xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.SqlConsoleViewController"
|
||||
styleClass="std-spacing"
|
||||
>
|
||||
<VBox HBox.hgrow="ALWAYS">
|
||||
<ScrollPane fitToWidth="true" fitToHeight="true" VBox.vgrow="ALWAYS">
|
||||
<TextArea
|
||||
fx:id="sqlEditorTextArea"
|
||||
promptText="Write your SQL query here..."
|
||||
styleClass="mono-font,small-font"
|
||||
/>
|
||||
</ScrollPane>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<Button text="Execute Query" onAction="#executeQuery"/>
|
||||
<Button text="View Schema" onAction="#showSchema"/>
|
||||
<Button text="Save Query" onAction="#saveQuery"/>
|
||||
<Button text="Export to File" onAction="#exportToFile"/>
|
||||
</HBox>
|
||||
<ScrollPane fitToHeight="true" fitToWidth="true">
|
||||
<TextArea
|
||||
fx:id="outputTextArea"
|
||||
styleClass="mono-font,small-font"
|
||||
maxHeight="800"
|
||||
/>
|
||||
</ScrollPane>
|
||||
</VBox>
|
||||
|
||||
<VBox HBox.hgrow="SOMETIMES" minWidth="300" prefWidth="300">
|
||||
<Label text="Saved Queries"/>
|
||||
<ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
|
||||
<VBox fx:id="savedQueriesVBox" styleClass="tile-container"/>
|
||||
</ScrollPane>
|
||||
</VBox>
|
||||
</HBox>
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
This migration adds a new entity specifically for brokerage accounts: the asset
|
||||
value record. This records the approximate value of the brokerage account assets
|
||||
excluding cash (which is already recorded).
|
||||
|
||||
This allows users to include their brokerage/investment assets in their Perfin
|
||||
profile for analysis, and paves the way for adding integrations with brokerage
|
||||
APIs to automate asset value record fetching.
|
||||
|
||||
Note that at the moment, asset value records only make sense for brokerage
|
||||
accounts, but in the future more account types might be added for which this
|
||||
would make sense.
|
||||
*/
|
||||
|
||||
ALTER TABLE balance_record
|
||||
ADD COLUMN type ENUM('CASH', 'ASSETS') NOT NULL DEFAULT 'CASH' AFTER account_id;
|
|
@ -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
|
||||
);
|
|
@ -1,3 +1,11 @@
|
|||
/*
|
||||
+--------------------------+
|
||||
| PERFIN Database Schema |
|
||||
+--------------------------+
|
||||
This file defines the relational database schema for Perfin. Various sections
|
||||
are labeled below.
|
||||
*/
|
||||
|
||||
CREATE TABLE account (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
|
@ -9,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,
|
||||
|
@ -120,6 +136,7 @@ CREATE TABLE balance_record (
|
|||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
account_id BIGINT NOT NULL,
|
||||
type ENUM('CASH', 'ASSETS') NOT NULL DEFAULT 'CASH',
|
||||
balance NUMERIC(12, 4) NOT NULL,
|
||||
currency VARCHAR(3) NOT NULL,
|
||||
CONSTRAINT fk_balance_record_account
|
||||
|
@ -139,7 +156,8 @@ CREATE TABLE balance_record_attachment (
|
|||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
/* HISTORY */
|
||||
/* HISTORY ENTITIES */
|
||||
|
||||
CREATE TABLE history (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT
|
||||
);
|
||||
|
|
|
@ -2,11 +2,8 @@
|
|||
|
||||
<?import com.andrewlalis.perfin.view.component.AccountSelectionBox?>
|
||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
|
||||
|
@ -14,20 +11,23 @@
|
|||
<top>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<Button text="Add Transaction" onAction="#addTransaction"/>
|
||||
<Button text="Export Transactions" onAction="#exportTransactions"/>
|
||||
<Button text="Export Transactions" onAction="#exportTransactions" disable="true"/>
|
||||
</HBox>
|
||||
</top>
|
||||
<center>
|
||||
<!-- The main page content is an HBox with a list of transactions on the
|
||||
left, and a detail panel on the right (which is hidden if no
|
||||
transaction is selected). -->
|
||||
<HBox>
|
||||
<BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
|
||||
<top>
|
||||
<HBox styleClass="padding-extra,std-spacing">
|
||||
<TextField fx:id="searchField" promptText="Search"/>
|
||||
<VBox styleClass="padding-extra,std-spacing">
|
||||
<TextField fx:id="searchField" promptText="Search" maxWidth="300" prefWidth="200" minWidth="100"/>
|
||||
<PropertiesPane hgap="5" vgap="5">
|
||||
<Label text="Filter by Account"/>
|
||||
<AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
|
||||
</PropertiesPane>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</top>
|
||||
<center>
|
||||
<ScrollPane styleClass="tile-container-scroll">
|
||||
|
@ -35,6 +35,7 @@
|
|||
</ScrollPane>
|
||||
</center>
|
||||
</BorderPane>
|
||||
|
||||
<VBox fx:id="detailPanel"/>
|
||||
</HBox>
|
||||
</center>
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
package com.andrewlalis.perfin.data;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
public class TimestampRangeTest {
|
||||
@Test
|
||||
public void testLastNDays() {
|
||||
TimestampRange r = TimestampRange.lastNDays(1);
|
||||
assertEquals(r.start(), r.end().minusDays(1));
|
||||
r = TimestampRange.lastNDays(42);
|
||||
assertEquals(r.start(), r.end().minusDays(42));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLastNMonths() {
|
||||
TimestampRange r = TimestampRange.lastNMonths(1);
|
||||
assertEquals(r.start(), r.end().minusMonths(1));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
package com.andrewlalis.perfin.data.impl;
|
||||
|
||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tests the {@link FileSystemSavedQueryRepository}, which uses a specific
|
||||
* directory under the Perfin profile's content directory for storing SQL
|
||||
* queries.
|
||||
*/
|
||||
public class FileSystemSavedQueryRepositoryTest {
|
||||
private static final Path CONTENT_DIR = Path.of("target", "FileSystemSavedQueryRepositoryTest_content-dir");
|
||||
|
||||
@BeforeEach
|
||||
public void setupTestEnvironment() {
|
||||
try {
|
||||
if (Files.exists(CONTENT_DIR)) {
|
||||
FileUtil.deleteDirRecursive(CONTENT_DIR);
|
||||
}
|
||||
|
||||
Files.createDirectories(CONTENT_DIR);
|
||||
Path queriesDir = CONTENT_DIR.resolve("saved-queries");
|
||||
Files.createDirectory(queriesDir);
|
||||
Files.writeString(queriesDir.resolve("sample-1.sql"), """
|
||||
SELECT * FROM transaction ORDER BY timestamp DESC;
|
||||
""");
|
||||
Files.writeString(queriesDir.resolve("sample-2.sql"), """
|
||||
SELECT COUNT(id)
|
||||
FROM transaction
|
||||
WHERE amount > 1000
|
||||
""");
|
||||
Files.writeString(queriesDir.resolve("sample-3.sql"), """
|
||||
SELECT 'Test str';
|
||||
""");
|
||||
Files.writeString(queriesDir.resolve("not-a-query.txt"), "not an SQL query!");
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSavedQueries() {
|
||||
var repo = new FileSystemSavedQueryRepository(CONTENT_DIR);
|
||||
var queries = repo.getSavedQueries();
|
||||
assertEquals(List.of("sample-1", "sample-2", "sample-3"), queries);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSavedQueryContent() {
|
||||
var repo = new FileSystemSavedQueryRepository(CONTENT_DIR);
|
||||
String content = repo.getSavedQueryContent("sample-1");
|
||||
assertEquals("SELECT * FROM transaction ORDER BY timestamp DESC;\n", content);
|
||||
|
||||
assertNull(repo.getSavedQueryContent("non-existent-query"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateSavedQuery() {
|
||||
var repo = new FileSystemSavedQueryRepository(CONTENT_DIR);
|
||||
String name = "sample-4";
|
||||
String content = "SELECT AVG(amount) FROM transaction;";
|
||||
repo.createSavedQuery(name, content);
|
||||
assertTrue(repo.getSavedQueries().contains(name));
|
||||
assertEquals(content, repo.getSavedQueryContent(name));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDeleteSavedQuery() {
|
||||
var repo = new FileSystemSavedQueryRepository(CONTENT_DIR);
|
||||
assertTrue(repo.getSavedQueries().contains("sample-1"));
|
||||
repo.deleteSavedQuery("sample-1");
|
||||
assertFalse(repo.getSavedQueries().contains("sample-1"));
|
||||
repo.deleteSavedQuery("sample-1");
|
||||
assertFalse(repo.getSavedQueries().contains("sample-1"));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package com.andrewlalis.perfin.data.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
public class FileUtilTest {
|
||||
@Test
|
||||
public void testEscapeCSVText() {
|
||||
assertEquals("regular_value", FileUtil.escapeCSVText("regular_value"));
|
||||
assertEquals("\"\"\"\"", FileUtil.escapeCSVText("\""));
|
||||
assertEquals("\"\"\",\"\"\"", FileUtil.escapeCSVText("\",\""));
|
||||
assertEquals("\"and, this\"", FileUtil.escapeCSVText("and, this"));
|
||||
assertEquals("\"1,345.43\"", FileUtil.escapeCSVText("1,345.43"));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue