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
|
# 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
|
A personal accounting desktop app to track your finances using an approachable
|
||||||
interface and interoperable file formats for maximum compatibility.
|
interface and interoperable file formats for maximum compatibility.
|
||||||
|
|
||||||
|
|
2
pom.xml
2
pom.xml
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
<groupId>com.andrewlalis</groupId>
|
<groupId>com.andrewlalis</groupId>
|
||||||
<artifactId>perfin</artifactId>
|
<artifactId>perfin</artifactId>
|
||||||
<version>1.11.0</version>
|
<version>1.19.0</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
|
|
|
@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar"
|
||||||
|
|
||||||
jpackage \
|
jpackage \
|
||||||
--name "Perfin" \
|
--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." \
|
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
|
||||||
--icon design/perfin-logo_256.png \
|
--icon design/perfin-logo_256.png \
|
||||||
--vendor "Andrew Lalis" \
|
--vendor "Andrew Lalis" \
|
||||||
|
|
|
@ -12,7 +12,7 @@ $modulePath = "$modulePath;target\modules\h2-2.2.224.jar"
|
||||||
|
|
||||||
jpackage `
|
jpackage `
|
||||||
--name "Perfin" `
|
--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." `
|
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
|
||||||
--icon design\perfin-logo_256.ico `
|
--icon design\perfin-logo_256.ico `
|
||||||
--vendor "Andrew Lalis" `
|
--vendor "Andrew Lalis" `
|
||||||
|
|
|
@ -30,7 +30,9 @@ import java.util.function.Consumer;
|
||||||
public class PerfinApp extends Application {
|
public class PerfinApp extends Application {
|
||||||
private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
|
private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
|
||||||
public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
|
public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
|
||||||
|
/** The singleton instance of the application. */
|
||||||
public static PerfinApp instance;
|
public static PerfinApp instance;
|
||||||
|
/** The singleton profile loader for the application. */
|
||||||
public static ProfileLoader profileLoader;
|
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) {
|
private void initMainScreen(Stage stage, Consumer<String> msgConsumer) {
|
||||||
msgConsumer.accept("Initializing main screen.");
|
msgConsumer.accept("Initializing main screen.");
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
stage.hide();
|
stage.hide();
|
||||||
Scene mainViewScene = SceneUtil.load("/main-view.fxml");
|
Scene mainViewScene = SceneUtil.load("/main-view.fxml");
|
||||||
mainViewScene.getStylesheets().addAll(
|
SceneUtil.addStylesheets(mainViewScene, "/style/base.css");
|
||||||
PerfinApp.class.getResource("/style/base.css").toExternalForm()
|
|
||||||
);
|
|
||||||
stage.setScene(mainViewScene);
|
stage.setScene(mainViewScene);
|
||||||
stage.setTitle("Perfin");
|
stage.setTitle("Perfin");
|
||||||
stage.getIcons().add(ImageCache.getLogo256());
|
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) {
|
private static void defineRoutes(Consumer<String> msgConsumer) {
|
||||||
msgConsumer.accept("Initializing application views.");
|
msgConsumer.accept("Initializing application views.");
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
|
@ -97,6 +107,7 @@ public class PerfinApp extends Application {
|
||||||
router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
|
router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
|
||||||
router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
|
router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
|
||||||
router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
|
router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
|
||||||
|
router.map("sql-console", PerfinApp.class.getResource("/sql-console-view.fxml"));
|
||||||
|
|
||||||
// Help pages.
|
// Help pages.
|
||||||
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
|
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("adding-a-transaction", PerfinApp.class.getResource("/help-pages/adding-a-transaction.fxml"));
|
||||||
helpRouter.map("profiles", PerfinApp.class.getResource("/help-pages/profiles.fxml"));
|
helpRouter.map("profiles", PerfinApp.class.getResource("/help-pages/profiles.fxml"));
|
||||||
helpRouter.map("about", PerfinApp.class.getResource("/help-pages/about.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 {
|
private static void initAppDir(Consumer<String> msgConsumer) throws Exception {
|
||||||
msgConsumer.accept("Validating application files.");
|
msgConsumer.accept("Validating application files.");
|
||||||
if (Files.notExists(APP_DIR)) {
|
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 {
|
private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
|
||||||
String lastProfile = ProfileLoader.getLastProfile();
|
String lastProfile = ProfileLoader.getLastProfile();
|
||||||
msgConsumer.accept("Loading the most recent profile: \"" + lastProfile + "\".");
|
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() {
|
private static void loadFonts() {
|
||||||
List<String> fontResources = List.of(
|
List<String> fontResources = List.of(
|
||||||
"/font/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Regular.ttf",
|
"/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.AccountRepository;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.*;
|
||||||
import com.andrewlalis.perfin.model.MoneyValue;
|
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
|
||||||
import com.andrewlalis.perfin.view.BindingUtil;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.view.component.AccountHistoryView;
|
import com.andrewlalis.perfin.view.component.AccountHistoryView;
|
||||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
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 com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.BooleanExpression;
|
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.beans.value.ObservableValue;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
|
@ -23,7 +24,10 @@ import javafx.scene.control.Label;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.text.Text;
|
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;
|
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 ObjectProperty<Account> accountProperty = new SimpleObjectProperty<>(null);
|
||||||
private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived());
|
private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived());
|
||||||
private final StringProperty balanceTextProperty = new SimpleStringProperty(null);
|
private final StringProperty balanceTextProperty = new SimpleStringProperty(null);
|
||||||
|
private final StringProperty assetValueTextProperty = new SimpleStringProperty(null);
|
||||||
|
private final StringProperty creditLimitTextProperty = new SimpleStringProperty(null);
|
||||||
|
|
||||||
@FXML public Label titleLabel;
|
@FXML public Label titleLabel;
|
||||||
@FXML public Label accountNameLabel;
|
@FXML public Label accountNameLabel;
|
||||||
|
@ -38,6 +44,10 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
@FXML public Label accountCurrencyLabel;
|
@FXML public Label accountCurrencyLabel;
|
||||||
@FXML public Label accountCreatedAtLabel;
|
@FXML public Label accountCreatedAtLabel;
|
||||||
@FXML public Label accountBalanceLabel;
|
@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 PropertiesPane descriptionPane;
|
||||||
@FXML public Text accountDescriptionText;
|
@FXML public Text accountDescriptionText;
|
||||||
|
|
||||||
|
@ -59,13 +69,27 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription);
|
BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription);
|
||||||
accountBalanceLabel.textProperty().bind(balanceTextProperty);
|
accountBalanceLabel.textProperty().bind(balanceTextProperty);
|
||||||
|
|
||||||
|
var isBrokerageAccount = accountProperty.map(a -> a.getType() == AccountType.BROKERAGE);
|
||||||
|
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 -> {
|
actionsBox.getChildren().forEach(node -> {
|
||||||
Button button = (Button) node;
|
Button button = (Button) node;
|
||||||
ObservableValue<Boolean> buttonActive = accountArchived;
|
ObservableValue<Boolean> buttonDisabled = accountArchived;
|
||||||
if (button.getText().equalsIgnoreCase("Unarchive")) {
|
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.managedProperty().bind(button.visibleProperty());
|
||||||
button.visibleProperty().bind(button.disableProperty().not());
|
button.visibleProperty().bind(button.disableProperty().not());
|
||||||
});
|
});
|
||||||
|
@ -81,7 +105,7 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
.toInstant();
|
.toInstant();
|
||||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
AccountRepository.class,
|
AccountRepository.class,
|
||||||
repo -> repo.deriveBalance(getAccount().id, timestamp)
|
repo -> repo.deriveCashBalance(getAccount().id, timestamp)
|
||||||
).thenAccept(balance -> Platform.runLater(() -> {
|
).thenAccept(balance -> Platform.runLater(() -> {
|
||||||
String msg = String.format(
|
String msg = String.format(
|
||||||
"Your balance as of %s is %s, according to Perfin's data.",
|
"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);
|
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
|
@Override
|
||||||
public void onRouteSelected(Object context) {
|
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
|
@FXML
|
||||||
|
@ -116,7 +160,11 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void goToCreateBalanceRecord() {
|
@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
|
@FXML
|
||||||
|
|
|
@ -24,6 +24,7 @@ public class BalanceRecordViewController implements RouteSelectionListener {
|
||||||
|
|
||||||
@FXML public Label titleLabel;
|
@FXML public Label titleLabel;
|
||||||
|
|
||||||
|
@FXML public Label typeLabel;
|
||||||
@FXML public Label timestampLabel;
|
@FXML public Label timestampLabel;
|
||||||
@FXML public Label balanceLabel;
|
@FXML public Label balanceLabel;
|
||||||
@FXML public Label currencyLabel;
|
@FXML public Label currencyLabel;
|
||||||
|
@ -38,6 +39,7 @@ public class BalanceRecordViewController implements RouteSelectionListener {
|
||||||
this.balanceRecord = (BalanceRecord) context;
|
this.balanceRecord = (BalanceRecord) context;
|
||||||
if (balanceRecord == null) return;
|
if (balanceRecord == null) return;
|
||||||
titleLabel.setText("Balance Record #" + balanceRecord.id);
|
titleLabel.setText("Balance Record #" + balanceRecord.id);
|
||||||
|
typeLabel.setText(balanceRecord.getType().toString());
|
||||||
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
|
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
|
||||||
balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
|
balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
|
||||||
currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
|
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.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
|
import com.andrewlalis.perfin.model.BalanceRecordType;
|
||||||
import com.andrewlalis.perfin.model.MoneyValue;
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||||
|
@ -29,7 +30,13 @@ import java.time.format.DateTimeParseException;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
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 class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
|
public record RouteContext (Account account, BalanceRecordType type) {}
|
||||||
|
|
||||||
@FXML public TextField timestampField;
|
@FXML public TextField timestampField;
|
||||||
@FXML public TextField balanceField;
|
@FXML public TextField balanceField;
|
||||||
@FXML public Label balanceWarningLabel;
|
@FXML public Label balanceWarningLabel;
|
||||||
|
@ -39,6 +46,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
@FXML public Button saveButton;
|
@FXML public Button saveButton;
|
||||||
|
|
||||||
private Account account;
|
private Account account;
|
||||||
|
private BalanceRecordType type = BalanceRecordType.CASH;
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
var timestampValid = new ValidationApplier<>((ValidationFunction<String>) input -> {
|
var timestampValid = new ValidationApplier<>((ValidationFunction<String>) input -> {
|
||||||
|
@ -57,7 +65,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
balanceWarningLabel.managedProperty().bind(balanceWarningLabel.visibleProperty());
|
balanceWarningLabel.managedProperty().bind(balanceWarningLabel.visibleProperty());
|
||||||
balanceWarningLabel.visibleProperty().set(false);
|
balanceWarningLabel.visibleProperty().set(false);
|
||||||
balanceField.textProperty().addListener((observable, oldValue, newValue) -> {
|
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);
|
balanceWarningLabel.visibleProperty().set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -65,7 +73,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
||||||
LocalDateTime utcTimestamp = DateUtil.localToUTC(localTimestamp);
|
LocalDateTime utcTimestamp = DateUtil.localToUTC(localTimestamp);
|
||||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
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);
|
boolean balancesMatch = reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance);
|
||||||
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(!balancesMatch));
|
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(!balancesMatch));
|
||||||
});
|
});
|
||||||
|
@ -77,14 +85,19 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRouteSelected(Object context) {
|
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));
|
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||||
|
balanceField.setText(null);
|
||||||
|
if (ctx.type() == BalanceRecordType.CASH) {
|
||||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
BigDecimal value = repo.deriveCurrentBalance(account.id);
|
BigDecimal value = repo.deriveCurrentCashBalance(account.id);
|
||||||
Platform.runLater(() -> balanceField.setText(
|
Platform.runLater(() -> balanceField.setText(
|
||||||
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
|
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
attachmentSelectionArea.clear();
|
attachmentSelectionArea.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,16 +105,26 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
|
||||||
BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
|
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(),
|
account.getShortName(),
|
||||||
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
|
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
|
||||||
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
|
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 -> {
|
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
|
||||||
repo.insert(
|
repo.insert(
|
||||||
DateUtil.localToUTC(localTimestamp),
|
DateUtil.localToUTC(localTimestamp),
|
||||||
account.id,
|
account.id,
|
||||||
|
type,
|
||||||
reportedBalance,
|
reportedBalance,
|
||||||
account.getCurrency(),
|
account.getCurrency(),
|
||||||
attachmentSelectionArea.getSelectedPaths()
|
attachmentSelectionArea.getSelectedPaths()
|
||||||
|
@ -118,7 +141,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
|
||||||
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance, LocalDateTime utcTimestamp) {
|
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance, LocalDateTime utcTimestamp) {
|
||||||
BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
|
BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
|
||||||
AccountRepository.class,
|
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)) {
|
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(
|
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.javafx_scene_router.RouteSelectionListener;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.view.component.module.TotalAssetsGraphModule;
|
||||||
import com.andrewlalis.perfin.view.component.module.*;
|
import com.andrewlalis.perfin.view.component.module.*;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.geometry.Bounds;
|
import javafx.geometry.Bounds;
|
||||||
|
@ -30,7 +31,10 @@ public class DashboardController implements RouteSelectionListener {
|
||||||
var m4 = new VendorSpendChartModule(modulesFlowPane);
|
var m4 = new VendorSpendChartModule(modulesFlowPane);
|
||||||
m4.columnsProperty.set(2);
|
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
|
@Override
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
package com.andrewlalis.perfin.control;
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.*;
|
||||||
import com.andrewlalis.perfin.model.AccountType;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.model.MoneyValue;
|
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
|
||||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.BooleanExpression;
|
import javafx.beans.binding.BooleanExpression;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.BooleanProperty;
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
@ -38,6 +38,7 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
@FXML public TextField accountNumberField;
|
@FXML public TextField accountNumberField;
|
||||||
@FXML public ComboBox<Currency> accountCurrencyComboBox;
|
@FXML public ComboBox<Currency> accountCurrencyComboBox;
|
||||||
@FXML public ChoiceBox<AccountType> accountTypeChoiceBox;
|
@FXML public ChoiceBox<AccountType> accountTypeChoiceBox;
|
||||||
|
@FXML public TextField creditLimitField;
|
||||||
@FXML public TextArea descriptionField;
|
@FXML public TextArea descriptionField;
|
||||||
@FXML public PropertiesPane initialBalanceContent;
|
@FXML public PropertiesPane initialBalanceContent;
|
||||||
@FXML public TextField initialBalanceField;
|
@FXML public TextField initialBalanceField;
|
||||||
|
@ -60,12 +61,25 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false)
|
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false)
|
||||||
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty());
|
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty());
|
||||||
|
|
||||||
|
var isEditingCreditCardAccount = accountTypeChoiceBox.valueProperty().isEqualTo(AccountType.CREDIT_CARD);
|
||||||
|
BindingUtil.bindManagedAndVisible(creditLimitField, isEditingCreditCardAccount);
|
||||||
|
var creditLimitValid = new ValidationApplier<>(new CurrencyAmountValidator(
|
||||||
|
() -> accountCurrencyComboBox.getValue(),
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)).validatedInitially().attachToTextField(creditLimitField)
|
||||||
|
.or(isEditingCreditCardAccount.not());
|
||||||
|
|
||||||
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
.addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.")
|
.addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.")
|
||||||
).attach(descriptionField, descriptionField.textProperty());
|
).attach(descriptionField, descriptionField.textProperty());
|
||||||
|
|
||||||
// Combine validity of all fields for an expression that determines if the whole form is valid.
|
// Combine validity of all fields for an expression that determines if the whole form is valid.
|
||||||
BooleanExpression formValid = nameValid.and(numberValid).and(balanceValid.or(creatingNewAccount.not())).and(descriptionValid);
|
BooleanExpression formValid = nameValid
|
||||||
|
.and(numberValid)
|
||||||
|
.and(balanceValid.or(creatingNewAccount.not()))
|
||||||
|
.and(descriptionValid)
|
||||||
|
.and(creditLimitValid);
|
||||||
saveButton.disableProperty().bind(formValid.not());
|
saveButton.disableProperty().bind(formValid.not());
|
||||||
|
|
||||||
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
|
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
|
||||||
|
@ -114,6 +128,10 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
description = description.strip();
|
description = description.strip();
|
||||||
if (description.isBlank()) description = null;
|
if (description.isBlank()) description = null;
|
||||||
}
|
}
|
||||||
|
BigDecimal creditLimit = null;
|
||||||
|
if (type == AccountType.CREDIT_CARD && creditLimitField.getText() != null && !creditLimitField.getText().isBlank()) {
|
||||||
|
creditLimit = new BigDecimal(creditLimitField.getText());
|
||||||
|
}
|
||||||
try (
|
try (
|
||||||
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
|
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
|
||||||
var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
|
var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
|
||||||
|
@ -132,13 +150,19 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
boolean success = Popups.confirm(accountNameField, prompt);
|
boolean success = Popups.confirm(accountNameField, prompt);
|
||||||
if (success) {
|
if (success) {
|
||||||
long id = accountRepo.insert(type, number, name, currency, description);
|
long id = accountRepo.insert(type, number, name, currency, description);
|
||||||
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
|
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, BalanceRecordType.CASH, initialBalance, currency, attachments);
|
||||||
|
if (type == AccountType.CREDIT_CARD && creditLimit != null) {
|
||||||
|
accountRepo.saveCreditCardProperties(new CreditCardProperties(id, creditLimit));
|
||||||
|
}
|
||||||
// Once we create the new account, go to the account.
|
// Once we create the new account, go to the account.
|
||||||
Account newAccount = accountRepo.findById(id).orElseThrow();
|
Account newAccount = accountRepo.findById(id).orElseThrow();
|
||||||
router.replace("account", newAccount);
|
router.replace("account", newAccount);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
accountRepo.update(account.id, type, number, name, currency, description);
|
accountRepo.update(account.id, type, number, name, currency, description);
|
||||||
|
if (type == AccountType.CREDIT_CARD) {
|
||||||
|
accountRepo.saveCreditCardProperties(new CreditCardProperties(account.id, creditLimit));
|
||||||
|
}
|
||||||
Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
|
Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
|
||||||
router.replace("account", updatedAccount);
|
router.replace("account", updatedAccount);
|
||||||
}
|
}
|
||||||
|
@ -161,12 +185,28 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
|
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
|
||||||
initialBalanceField.setText(String.format("%.02f", 0f));
|
initialBalanceField.setText(String.format("%.02f", 0f));
|
||||||
descriptionField.setText(null);
|
descriptionField.setText(null);
|
||||||
|
|
||||||
|
creditLimitField.setText(null);
|
||||||
} else {
|
} else {
|
||||||
accountNameField.setText(account.getName());
|
accountNameField.setText(account.getName());
|
||||||
accountNumberField.setText(account.getAccountNumber());
|
accountNumberField.setText(account.getAccountNumber());
|
||||||
accountTypeChoiceBox.getSelectionModel().select(account.getType());
|
accountTypeChoiceBox.getSelectionModel().select(account.getType());
|
||||||
accountCurrencyComboBox.getSelectionModel().select(account.getCurrency());
|
accountCurrencyComboBox.getSelectionModel().select(account.getCurrency());
|
||||||
descriptionField.setText(account.getDescription());
|
descriptionField.setText(account.getDescription());
|
||||||
|
|
||||||
|
// Fetch the account's credit limit if it's a credit card account.
|
||||||
|
if (account.getType() == AccountType.CREDIT_CARD) {
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
AccountRepository.class,
|
||||||
|
repo -> repo.getCreditCardProperties(account.id)
|
||||||
|
).thenAccept(props -> Platform.runLater(() -> {
|
||||||
|
if (props != null && props.creditLimit() != null) {
|
||||||
|
creditLimitField.setText(CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(props.creditLimit(), account.getCurrency())));
|
||||||
|
} else {
|
||||||
|
creditLimitField.setText(null);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,12 +13,12 @@ import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
||||||
import com.andrewlalis.perfin.view.component.CategorySelectionBox;
|
import com.andrewlalis.perfin.view.component.CategorySelectionBox;
|
||||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
import com.andrewlalis.perfin.view.component.FileSelectionArea;
|
||||||
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
|
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.ValidationApplier;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
|
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.CurrencyAmountValidator;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.Bindings;
|
|
||||||
import javafx.beans.binding.BooleanExpression;
|
import javafx.beans.binding.BooleanExpression;
|
||||||
import javafx.beans.property.*;
|
import javafx.beans.property.*;
|
||||||
import javafx.beans.value.ObservableValue;
|
import javafx.beans.value.ObservableValue;
|
||||||
|
@ -41,6 +41,7 @@ import java.time.DateTimeException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
@ -57,6 +58,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
@FXML public TextField timestampField;
|
@FXML public TextField timestampField;
|
||||||
@FXML public TextField amountField;
|
@FXML public TextField amountField;
|
||||||
@FXML public ChoiceBox<Currency> currencyChoiceBox;
|
@FXML public ChoiceBox<Currency> currencyChoiceBox;
|
||||||
|
private final BooleanProperty basicTransactionInfoValid = new SimpleBooleanProperty(false);
|
||||||
@FXML public TextArea descriptionField;
|
@FXML public TextArea descriptionField;
|
||||||
|
|
||||||
@FXML public HBox linkedAccountsContainer;
|
@FXML public HBox linkedAccountsContainer;
|
||||||
|
@ -132,11 +134,13 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
||||||
initializeTagSelectionUi();
|
initializeTagSelectionUi();
|
||||||
initializeLineItemsUi();
|
initializeLineItemsUi();
|
||||||
|
initializeDuplicateTransactionUi();
|
||||||
|
|
||||||
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
|
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
|
||||||
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
|
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
|
||||||
tagsHyperlink.setOnAction(event -> router.navigate("tags"));
|
tagsHyperlink.setOnAction(event -> router.navigate("tags"));
|
||||||
|
|
||||||
|
basicTransactionInfoValid.bind(timestampValid.and(amountValid).and(currencyChoiceBox.valueProperty().isNotNull()));
|
||||||
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
|
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
|
||||||
saveButton.disableProperty().bind(formValid.not());
|
saveButton.disableProperty().bind(formValid.not());
|
||||||
}
|
}
|
||||||
|
@ -313,6 +317,49 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
.attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty());
|
.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() {
|
private void initializeTagSelectionUi() {
|
||||||
addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
|
addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
|
||||||
addTagButton.setOnAction(event -> {
|
addTagButton.setOnAction(event -> {
|
||||||
|
@ -403,12 +450,10 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false);
|
BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false);
|
||||||
lineItemsTotalValue.addListener((observable, oldValue, newValue) -> {
|
lineItemsTotalValue.addListener((observable, oldValue, newValue) ->
|
||||||
lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0);
|
lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0));
|
||||||
});
|
amountFieldValue.addListener((observable, oldValue, newValue) ->
|
||||||
amountFieldValue.addListener((observable, oldValue, newValue) -> {
|
lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0));
|
||||||
lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0);
|
|
||||||
});
|
|
||||||
BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not()));
|
BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not()));
|
||||||
|
|
||||||
// Logic for button that syncs line items total to the amount field.
|
// Logic for button that syncs line items total to the amount field.
|
||||||
|
|
|
@ -1,19 +1,24 @@
|
||||||
package com.andrewlalis.perfin.control;
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
|
||||||
|
import com.andrewlalis.perfin.data.AnalyticsRepository;
|
||||||
import com.andrewlalis.perfin.data.DataSource;
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
|
import com.andrewlalis.perfin.data.TimestampRange;
|
||||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
import com.andrewlalis.perfin.data.TransactionVendorRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.model.TransactionVendor;
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
|
||||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
|
||||||
|
import javafx.application.Platform;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.control.TextArea;
|
import javafx.scene.control.TextArea;
|
||||||
import javafx.scene.control.TextField;
|
import javafx.scene.control.TextField;
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
@ -24,6 +29,8 @@ public class EditVendorController implements RouteSelectionListener {
|
||||||
@FXML public TextArea descriptionField;
|
@FXML public TextArea descriptionField;
|
||||||
@FXML public Button saveButton;
|
@FXML public Button saveButton;
|
||||||
|
|
||||||
|
@FXML public Label totalSpentField;
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
|
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
|
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
|
||||||
|
@ -63,9 +70,19 @@ public class EditVendorController implements RouteSelectionListener {
|
||||||
this.vendor = tv;
|
this.vendor = tv;
|
||||||
nameField.setText(vendor.getName());
|
nameField.setText(vendor.getName());
|
||||||
descriptionField.setText(vendor.getDescription());
|
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 {
|
} else {
|
||||||
nameField.setText(null);
|
nameField.setText(null);
|
||||||
descriptionField.setText(null);
|
descriptionField.setText(null);
|
||||||
|
totalSpentField.setText(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -102,4 +102,8 @@ public class MainViewController {
|
||||||
@FXML public void goToDashboard() {
|
@FXML public void goToDashboard() {
|
||||||
router.replace("dashboard");
|
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.PerfinApp;
|
||||||
import com.andrewlalis.perfin.data.ProfileLoadException;
|
import com.andrewlalis.perfin.data.ProfileLoadException;
|
||||||
|
import com.andrewlalis.perfin.data.SampleProfileGenerator;
|
||||||
import com.andrewlalis.perfin.data.util.FileUtil;
|
import com.andrewlalis.perfin.data.util.FileUtil;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.model.ProfileBackups;
|
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() {
|
private void refreshAvailableProfiles() {
|
||||||
List<String> profileNames = ProfileLoader.getAvailableProfiles();
|
List<String> profileNames = ProfileLoader.getAvailableProfiles();
|
||||||
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name();
|
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.pagination.Sort;
|
||||||
import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
|
import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
|
||||||
import com.andrewlalis.perfin.data.search.SearchFilter;
|
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.data.util.Pair;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
@ -29,11 +28,7 @@ import javafx.scene.control.TextField;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.VBox;
|
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.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -47,13 +42,19 @@ import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
* to a specific page.
|
* to a specific page.
|
||||||
*/
|
*/
|
||||||
public class TransactionsViewController implements RouteSelectionListener {
|
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) {}
|
public record RouteContext(Long selectedTransactionId) {}
|
||||||
|
|
||||||
@FXML public BorderPane transactionsListBorderPane;
|
@FXML public BorderPane transactionsListBorderPane;
|
||||||
@FXML public TextField searchField;
|
@FXML public TextField searchField;
|
||||||
@FXML public AccountSelectionBox filterByAccountComboBox;
|
@FXML public AccountSelectionBox filterByAccountComboBox;
|
||||||
|
|
||||||
@FXML public VBox transactionsVBox;
|
@FXML public VBox transactionsVBox;
|
||||||
|
|
||||||
private DataSourcePaginationControls paginationControls;
|
private DataSourcePaginationControls paginationControls;
|
||||||
|
|
||||||
|
|
||||||
|
@ -66,6 +67,9 @@ public class TransactionsViewController implements RouteSelectionListener {
|
||||||
paginationControls.setPage(1);
|
paginationControls.setPage(1);
|
||||||
selectedTransaction.set(null);
|
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) -> {
|
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
paginationControls.setPage(1);
|
paginationControls.setPage(1);
|
||||||
selectedTransaction.set(null);
|
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 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) {
|
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(
|
Profile.getCurrent().dataSource().useRepoAsync(
|
||||||
TransactionRepository.class,
|
TransactionRepository.class,
|
||||||
repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
|
repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
|
||||||
|
@ -155,37 +160,34 @@ public class TransactionsViewController implements RouteSelectionListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void exportTransactions() {
|
@FXML public void exportTransactions() {
|
||||||
FileChooser fileChooser = new FileChooser();
|
Popups.message(transactionsListBorderPane, "Exporting transactions is not yet supported.");
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<SearchFilter> getCurrentSearchFilters() {
|
private List<SearchFilter> getCurrentSearchFilters() {
|
||||||
List<SearchFilter> filters = new ArrayList<>();
|
List<SearchFilter> filters = new ArrayList<>();
|
||||||
if (searchField.getText() != null && !searchField.getText().isBlank()) {
|
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+'%')
|
.map(t -> '%'+t+'%')
|
||||||
.toList();
|
.toList();
|
||||||
var builder = new SearchFilter.Builder();
|
var builder = new SearchFilter.Builder();
|
||||||
|
@ -213,13 +215,6 @@ public class TransactionsViewController implements RouteSelectionListener {
|
||||||
return filters;
|
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) {
|
private TransactionTile makeTile(Transaction transaction) {
|
||||||
var tile = new TransactionTile(transaction);
|
var tile = new TransactionTile(transaction);
|
||||||
tile.setOnMouseClicked(event -> {
|
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.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
import com.andrewlalis.perfin.model.AccountType;
|
import com.andrewlalis.perfin.model.AccountType;
|
||||||
|
import com.andrewlalis.perfin.model.CreditCardProperties;
|
||||||
import com.andrewlalis.perfin.model.Timestamped;
|
import com.andrewlalis.perfin.model.Timestamped;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
@ -23,15 +24,22 @@ public interface AccountRepository extends Repository, AutoCloseable {
|
||||||
List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive);
|
List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive);
|
||||||
List<Account> findAllByCurrency(Currency currency);
|
List<Account> findAllByCurrency(Currency currency);
|
||||||
Optional<Account> findById(long id);
|
Optional<Account> findById(long id);
|
||||||
|
CreditCardProperties getCreditCardProperties(long id);
|
||||||
|
void saveCreditCardProperties(CreditCardProperties properties);
|
||||||
void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description);
|
void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description);
|
||||||
void delete(Account account);
|
void delete(Account account);
|
||||||
void archive(long accountId);
|
void archive(long accountId);
|
||||||
void unarchive(long accountId);
|
void unarchive(long accountId);
|
||||||
|
|
||||||
BigDecimal deriveBalance(long accountId, Instant timestamp);
|
BigDecimal deriveCashBalance(long accountId, Instant timestamp);
|
||||||
default BigDecimal deriveCurrentBalance(long accountId) {
|
default BigDecimal deriveCurrentCashBalance(long accountId) {
|
||||||
return deriveBalance(accountId, Instant.now(Clock.systemUTC()));
|
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();
|
Set<Currency> findAllUsedCurrencies();
|
||||||
List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults);
|
List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package com.andrewlalis.perfin.data;
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.util.Pair;
|
import com.andrewlalis.perfin.data.util.Pair;
|
||||||
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
import com.andrewlalis.perfin.model.TransactionCategory;
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
import com.andrewlalis.perfin.model.TransactionVendor;
|
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>> getIncomeByCategory(TimestampRange range, Currency currency);
|
||||||
List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency);
|
List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency);
|
||||||
List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(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.Attachment;
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||||
|
import com.andrewlalis.perfin.model.BalanceRecordType;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
@ -11,11 +12,11 @@ import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface BalanceRecordRepository extends Repository, AutoCloseable {
|
public interface BalanceRecordRepository extends Repository, AutoCloseable {
|
||||||
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
|
long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments);
|
||||||
BalanceRecord findLatestByAccountId(long accountId);
|
BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type);
|
||||||
Optional<BalanceRecord> findById(long id);
|
Optional<BalanceRecord> findById(long id);
|
||||||
Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp);
|
Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
|
||||||
Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp);
|
Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
|
||||||
List<Attachment> findAttachments(long recordId);
|
List<Attachment> findAttachments(long recordId);
|
||||||
void deleteById(long id);
|
void deleteById(long id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import javafx.application.Platform;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
@ -35,6 +36,7 @@ public interface DataSource {
|
||||||
TransactionLineItemRepository getTransactionLineItemRepository();
|
TransactionLineItemRepository getTransactionLineItemRepository();
|
||||||
AttachmentRepository getAttachmentRepository();
|
AttachmentRepository getAttachmentRepository();
|
||||||
HistoryRepository getHistoryRepository();
|
HistoryRepository getHistoryRepository();
|
||||||
|
SavedQueryRepository getSavedQueryRepository();
|
||||||
|
|
||||||
AnalyticsRepository getAnalyticsRepository();
|
AnalyticsRepository getAnalyticsRepository();
|
||||||
|
|
||||||
|
@ -91,6 +93,7 @@ public interface DataSource {
|
||||||
TransactionLineItemRepository.class, this::getTransactionLineItemRepository,
|
TransactionLineItemRepository.class, this::getTransactionLineItemRepository,
|
||||||
AttachmentRepository.class, this::getAttachmentRepository,
|
AttachmentRepository.class, this::getAttachmentRepository,
|
||||||
HistoryRepository.class, this::getHistoryRepository,
|
HistoryRepository.class, this::getHistoryRepository,
|
||||||
|
SavedQueryRepository.class, this::getSavedQueryRepository,
|
||||||
AnalyticsRepository.class, this::getAnalyticsRepository
|
AnalyticsRepository.class, this::getAnalyticsRepository
|
||||||
);
|
);
|
||||||
return (Supplier<R>) repoSuppliers.get(type);
|
return (Supplier<R>) repoSuppliers.get(type);
|
||||||
|
@ -101,7 +104,7 @@ public interface DataSource {
|
||||||
default CompletableFuture<String> getAccountBalanceText(Account account) {
|
default CompletableFuture<String> getAccountBalanceText(Account account) {
|
||||||
CompletableFuture<String> cf = new CompletableFuture<>();
|
CompletableFuture<String> cf = new CompletableFuture<>();
|
||||||
mapRepoAsync(AccountRepository.class, repo -> {
|
mapRepoAsync(AccountRepository.class, repo -> {
|
||||||
BigDecimal balance = repo.deriveCurrentBalance(account.id);
|
BigDecimal balance = repo.deriveCurrentCashBalance(account.id);
|
||||||
MoneyValue money = new MoneyValue(balance, account.getCurrency());
|
MoneyValue money = new MoneyValue(balance, account.getCurrency());
|
||||||
return CurrencyUtil.formatMoney(money);
|
return CurrencyUtil.formatMoney(money);
|
||||||
}).thenAccept(s -> Platform.runLater(() -> cf.complete(s)));
|
}).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,
|
* Gets a list of combined total assets for each currency that's tracked,
|
||||||
* ordered with highest assets first.
|
* 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.
|
* @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 -> {
|
return mapRepoAsync(AccountRepository.class, repo -> {
|
||||||
List<Account> accounts = repo.findAll(PageRequest.unpaged()).items();
|
List<Account> accounts = repo.findAll(PageRequest.unpaged()).items();
|
||||||
Map<Currency, BigDecimal> totals = new HashMap<>();
|
Map<Currency, BigDecimal> totals = new HashMap<>();
|
||||||
for (var account : accounts) {
|
for (var account : accounts) {
|
||||||
BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO);
|
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();
|
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());
|
List<MoneyValue> values = new ArrayList<>(totals.size());
|
||||||
for (var entry : totals.entrySet()) {
|
for (var entry : totals.entrySet()) {
|
||||||
|
@ -131,4 +137,8 @@ public interface DataSource {
|
||||||
return values;
|
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);
|
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() {
|
public static TimestampRange thisMonth() {
|
||||||
LocalDateTime localStartOfMonth = LocalDate.now(ZoneId.systemDefault()).atStartOfDay().withDayOfMonth(1);
|
LocalDateTime localStartOfMonth = LocalDate.now(ZoneId.systemDefault()).atStartOfDay().withDayOfMonth(1);
|
||||||
LocalDateTime utcStart = localStartOfMonth.atZone(ZoneId.systemDefault())
|
LocalDateTime utcStart = localStartOfMonth.atZone(ZoneId.systemDefault())
|
||||||
|
|
|
@ -31,10 +31,13 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
Optional<Transaction> findById(long id);
|
Optional<Transaction> findById(long id);
|
||||||
Page<Transaction> findAll(PageRequest pagination);
|
Page<Transaction> findAll(PageRequest pagination);
|
||||||
List<Transaction> findRecentN(int n);
|
List<Transaction> findRecentN(int n);
|
||||||
|
List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency);
|
||||||
long countAll();
|
long countAll();
|
||||||
long countAllAfter(long transactionId);
|
long countAllAfter(long transactionId);
|
||||||
long countAllByAccounts(Set<Long> accountIds);
|
long countAllByAccounts(Set<Long> accountIds);
|
||||||
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
|
||||||
|
Optional<Transaction> findEarliest();
|
||||||
|
Optional<Transaction> findLatest();
|
||||||
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
|
||||||
List<Attachment> findAttachments(long transactionId);
|
List<Attachment> findAttachments(long transactionId);
|
||||||
List<String> findTags(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
|
@Override
|
||||||
public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) {
|
public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) {
|
||||||
return DbUtil.doTransaction(conn, () -> {
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
|
|
||||||
long accountId = DbUtil.insertOne(
|
long accountId = DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
"INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
@ -36,6 +35,10 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
currency.getCurrencyCode(),
|
currency.getCurrencyCode(),
|
||||||
description
|
description
|
||||||
);
|
);
|
||||||
|
// If it's a credit card account, preemptively create a credit card properties record.
|
||||||
|
if (type == AccountType.CREDIT_CARD) {
|
||||||
|
saveCreditCardProperties(new CreditCardProperties(accountId, null));
|
||||||
|
}
|
||||||
// Insert a history item indicating the creation of the account.
|
// Insert a history item indicating the creation of the account.
|
||||||
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
|
||||||
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||||
|
@ -114,7 +117,49 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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.
|
// First find the account itself, since its properties influence the balance.
|
||||||
Account account = findById(accountId).orElse(null);
|
Account account = findById(accountId).orElse(null);
|
||||||
if (account == null) throw new EntityNotFoundException(Account.class, accountId);
|
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);
|
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
|
||||||
AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn);
|
AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn);
|
||||||
// Find the most recent balance record before timestamp.
|
// 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()) {
|
if (closestPastRecord.isPresent()) {
|
||||||
// Then find any entries on the account since that balance record and the timestamp.
|
// Then find any entries on the account since that balance record and the timestamp.
|
||||||
List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
|
List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
|
||||||
|
@ -133,7 +178,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow);
|
return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow);
|
||||||
} else {
|
} else {
|
||||||
// There is no balance record present before the given timestamp. Try and find the closest one after.
|
// 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()) {
|
if (closestFutureRecord.isPresent()) {
|
||||||
// Now find any entries on the account from the timestamp until that balance record.
|
// Now find any entries on the account from the timestamp until that balance record.
|
||||||
List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
|
List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
|
||||||
|
@ -145,13 +190,22 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
} else {
|
} else {
|
||||||
// No balance records exist for the account! Assume balance of 0 when the account was created.
|
// 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());
|
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);
|
List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp);
|
||||||
return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated);
|
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
|
@Override
|
||||||
public Set<Currency> findAllUsedCurrencies() {
|
public Set<Currency> findAllUsedCurrencies() {
|
||||||
return new HashSet<>(DbUtil.findAll(
|
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
|
SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id
|
||||||
FROM balance_record
|
FROM balance_record
|
||||||
)
|
)
|
||||||
WHERE account_id = ? AND timestamp <= ?
|
WHERE account_id = ? AND timestamp < ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT\s""" + maxResults;
|
LIMIT\s""" + maxResults;
|
||||||
try (var stmt = conn.prepareStatement(query)) {
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
@ -245,7 +299,10 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(Account account) {
|
public void delete(Account account) {
|
||||||
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", List.of(account.id));
|
DbUtil.doTransaction(conn, () -> {
|
||||||
|
DbUtil.update(conn, "DELETE FROM credit_card_account_properties WHERE account_id = ?", account.id);
|
||||||
|
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", account.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -280,6 +337,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
return new Account(id, createdAt, archived, type, accountNumber, name, currency, description);
|
return new Account(id, createdAt, archived, type, accountNumber, name, currency, description);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static CreditCardProperties parseCreditCardProperties(ResultSet rs) throws SQLException {
|
||||||
|
long accountId = rs.getLong("account_id");
|
||||||
|
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
|
||||||
|
return new CreditCardProperties(accountId, creditLimit);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() throws Exception {
|
public void close() throws Exception {
|
||||||
conn.close();
|
conn.close();
|
||||||
|
@ -301,4 +364,11 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
}
|
}
|
||||||
return balance;
|
return balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AccountType getAccountType(long id) {
|
||||||
|
String accountTypeStr = DbUtil.findOne(conn, "SELECT account_type FROM account WHERE id = ?", List.of(id), rs -> rs.getString(1))
|
||||||
|
.orElse(null);
|
||||||
|
if (accountTypeStr == null) return null;
|
||||||
|
return AccountType.valueOf(accountTypeStr.toUpperCase());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.TimestampRange;
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
import com.andrewlalis.perfin.data.util.Pair;
|
import com.andrewlalis.perfin.data.util.Pair;
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
import com.andrewlalis.perfin.model.AccountEntry;
|
||||||
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
import com.andrewlalis.perfin.model.TransactionCategory;
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
import com.andrewlalis.perfin.model.TransactionVendor;
|
import com.andrewlalis.perfin.model.TransactionVendor;
|
||||||
import javafx.scene.paint.Color;
|
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
|
@Override
|
||||||
public void close() throws Exception {
|
public void close() throws Exception {
|
||||||
conn.close();
|
conn.close();
|
||||||
|
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.BalanceRecordRepository;
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
import com.andrewlalis.perfin.model.Attachment;
|
import com.andrewlalis.perfin.model.Attachment;
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
import com.andrewlalis.perfin.model.BalanceRecord;
|
||||||
|
import com.andrewlalis.perfin.model.BalanceRecordType;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
@ -18,12 +19,12 @@ import java.util.Optional;
|
||||||
|
|
||||||
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
|
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
|
||||||
@Override
|
@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, () -> {
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
long recordId = DbUtil.insertOne(
|
long recordId = DbUtil.insertOne(
|
||||||
conn,
|
conn,
|
||||||
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
|
"INSERT INTO balance_record (timestamp, account_id, type, balance, currency) VALUES (?, ?, ?, ?, ?)",
|
||||||
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, balance, currency.getCurrencyCode())
|
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, type.name(), balance, currency.getCurrencyCode())
|
||||||
);
|
);
|
||||||
// Insert attachments.
|
// Insert attachments.
|
||||||
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
|
||||||
|
@ -39,11 +40,11 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public BalanceRecord findLatestByAccountId(long accountId) {
|
public BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type) {
|
||||||
return DbUtil.findOne(
|
return DbUtil.findOne(
|
||||||
conn,
|
conn,
|
||||||
"SELECT * FROM balance_record WHERE account_id = ? ORDER BY timestamp DESC LIMIT 1",
|
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? ORDER BY timestamp DESC LIMIT 1",
|
||||||
List.of(accountId),
|
List.of(accountId, type.name()),
|
||||||
JdbcBalanceRecordRepository::parse
|
JdbcBalanceRecordRepository::parse
|
||||||
).orElse(null);
|
).orElse(null);
|
||||||
}
|
}
|
||||||
|
@ -59,21 +60,21 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<BalanceRecord> findClosestBefore(long accountId, LocalDateTime utcTimestamp) {
|
public Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
|
||||||
return DbUtil.findOne(
|
return DbUtil.findOne(
|
||||||
conn,
|
conn,
|
||||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
|
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
|
||||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||||
JdbcBalanceRecordRepository::parse
|
JdbcBalanceRecordRepository::parse
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<BalanceRecord> findClosestAfter(long accountId, LocalDateTime utcTimestamp) {
|
public Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
|
||||||
return DbUtil.findOne(
|
return DbUtil.findOne(
|
||||||
conn,
|
conn,
|
||||||
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
|
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
|
||||||
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
|
||||||
JdbcBalanceRecordRepository::parse
|
JdbcBalanceRecordRepository::parse
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -108,6 +109,7 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
|
||||||
rs.getLong("id"),
|
rs.getLong("id"),
|
||||||
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
|
||||||
rs.getLong("account_id"),
|
rs.getLong("account_id"),
|
||||||
|
BalanceRecordType.valueOf(rs.getString("type").toUpperCase()),
|
||||||
rs.getBigDecimal("balance"),
|
rs.getBigDecimal("balance"),
|
||||||
Currency.getInstance(rs.getString("currency"))
|
Currency.getInstance(rs.getString("currency"))
|
||||||
);
|
);
|
||||||
|
|
|
@ -74,6 +74,11 @@ public class JdbcDataSource implements DataSource {
|
||||||
return new JdbcHistoryRepository(getConnection());
|
return new JdbcHistoryRepository(getConnection());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SavedQueryRepository getSavedQueryRepository() {
|
||||||
|
return new FileSystemSavedQueryRepository(contentDir);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AnalyticsRepository getAnalyticsRepository() {
|
public AnalyticsRepository getAnalyticsRepository() {
|
||||||
return new JdbcAnalyticsRepository(getConnection());
|
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
|
* 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
|
* the profile has a newer schema version, we'll exit and prompt the user
|
||||||
* to update their app.
|
* 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 {
|
public DataSource getDataSource(String profileName) throws ProfileLoadException {
|
||||||
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
|
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
|
@Override
|
||||||
public long countAll() {
|
public long countAll() {
|
||||||
return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L);
|
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);
|
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
|
@Override
|
||||||
public CreditAndDebitAccounts findLinkedAccounts(long transactionId) {
|
public CreditAndDebitAccounts findLinkedAccounts(long transactionId) {
|
||||||
Account creditAccount = DbUtil.findOne(
|
Account creditAccount = DbUtil.findOne(
|
||||||
|
|
|
@ -19,6 +19,8 @@ public class Migrations {
|
||||||
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
|
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
|
||||||
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
|
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
|
||||||
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
|
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
|
||||||
|
migrations.put(4, new PlainSQLMigration("/sql/migration/M004_AddBrokerageValueRecords.sql"));
|
||||||
|
migrations.put(5, new PlainSQLMigration("/sql/migration/M005_AddCreditCardLimit.sql"));
|
||||||
return migrations;
|
return migrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -113,8 +113,13 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void updateOne(Connection conn, String query, List<Object> args) {
|
public static void updateOne(Connection conn, String query, List<Object> args) {
|
||||||
Object[] argsArray = args.toArray();
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
updateOne(conn, query, argsArray);
|
setArgs(stmt, args);
|
||||||
|
int updateCount = stmt.executeUpdate();
|
||||||
|
if (updateCount != 1) throw new UncheckedSqlException("Update count is " + updateCount + "; expected 1.");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void updateOne(Connection conn, String query, Object... args) {
|
public static void updateOne(Connection conn, String query, Object... args) {
|
||||||
|
|
|
@ -114,4 +114,10 @@ public class FileUtil {
|
||||||
in.transferTo(out);
|
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;
|
import java.util.Currency;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A recording of an account's real reported balance at a given point in time,
|
* 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.
|
|
||||||
*/
|
*/
|
||||||
public class BalanceRecord extends IdEntity implements Timestamped {
|
public class BalanceRecord extends IdEntity implements Timestamped {
|
||||||
private final LocalDateTime timestamp;
|
private final LocalDateTime timestamp;
|
||||||
private final long accountId;
|
private final long accountId;
|
||||||
|
private final BalanceRecordType type;
|
||||||
private final BigDecimal balance;
|
private final BigDecimal balance;
|
||||||
private final Currency currency;
|
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);
|
super(id);
|
||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
this.accountId = accountId;
|
this.accountId = accountId;
|
||||||
|
this.type = type;
|
||||||
this.balance = balance;
|
this.balance = balance;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
}
|
}
|
||||||
|
@ -31,6 +31,10 @@ public class BalanceRecord extends IdEntity implements Timestamped {
|
||||||
return accountId;
|
return accountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BalanceRecordType getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
public BigDecimal getBalance() {
|
public BigDecimal getBalance() {
|
||||||
return balance;
|
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;
|
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 record CreditAndDebitAccounts(Account creditAccount, Account debitAccount) {
|
||||||
public boolean hasCredit() {
|
public boolean hasCredit() {
|
||||||
return creditAccount != null;
|
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) {
|
public static LocalDateTime getLastBackupTimestamp(String name) {
|
||||||
|
if (Files.notExists(getBackupDir(name))) return null;
|
||||||
try (var files = Files.list(getBackupDir(name))) {
|
try (var files = Files.list(getBackupDir(name))) {
|
||||||
return files.map(ProfileBackups::getTimestampFromBackup)
|
return files.map(ProfileBackups::getTimestampFromBackup)
|
||||||
.max(LocalDateTime::compareTo)
|
.max(LocalDateTime::compareTo)
|
||||||
|
|
|
@ -3,12 +3,10 @@ package com.andrewlalis.perfin.view.component;
|
||||||
import com.andrewlalis.perfin.control.TransactionsViewController;
|
import com.andrewlalis.perfin.control.TransactionsViewController;
|
||||||
import com.andrewlalis.perfin.data.AccountRepository;
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
import com.andrewlalis.perfin.data.DataSource;
|
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.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.data.util.DateUtil;
|
import com.andrewlalis.perfin.data.util.DateUtil;
|
||||||
import com.andrewlalis.perfin.model.AccountEntry;
|
import com.andrewlalis.perfin.model.*;
|
||||||
import com.andrewlalis.perfin.model.BalanceRecord;
|
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
|
||||||
import com.andrewlalis.perfin.model.Timestamped;
|
|
||||||
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
import com.andrewlalis.perfin.model.history.HistoryTextItem;
|
||||||
import com.andrewlalis.perfin.view.BindingUtil;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
@ -27,6 +25,8 @@ import javafx.scene.text.TextFlow;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ public class AccountHistoryView extends ScrollPane {
|
||||||
int maxItems = initialItemsToLoadProperty.get();
|
int maxItems = initialItemsToLoadProperty.get();
|
||||||
DataSource ds = Profile.getCurrent().dataSource();
|
DataSource ds = Profile.getCurrent().dataSource();
|
||||||
ds.mapRepoAsync(AccountRepository.class, repo -> repo.findEventsBefore(accountId, lastTimestamp(), maxItems))
|
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() {
|
public void clear() {
|
||||||
|
@ -91,48 +91,69 @@ public class AccountHistoryView extends ScrollPane {
|
||||||
return lastTimestamp;
|
return lastTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Node makeTile(Timestamped entity) {
|
private CompletableFuture<Node> makeTile(Timestamped entity) {
|
||||||
switch (entity) {
|
switch (entity) {
|
||||||
case HistoryTextItem textItem -> {
|
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 -> {
|
case AccountEntry ae -> {
|
||||||
Hyperlink txLink = new Hyperlink("Transaction #" + ae.getTransactionId());
|
Hyperlink txLink = new Hyperlink("Transaction #" + ae.getTransactionId());
|
||||||
txLink.setOnAction(event -> router.navigate("transactions", new TransactionsViewController.RouteContext(ae.getTransactionId())));
|
txLink.setOnAction(event -> router.navigate("transactions", new TransactionsViewController.RouteContext(ae.getTransactionId())));
|
||||||
String descriptionFormat = ae.getType() == AccountEntry.Type.CREDIT
|
String descriptionFormat = ae.getType() == AccountEntry.Type.CREDIT
|
||||||
? "credited %s from this account."
|
? "credited %s from this account"
|
||||||
: "debited %s to this account.";
|
: "debited %s to this account";
|
||||||
String description = descriptionFormat.formatted(CurrencyUtil.formatMoney(ae.getMoneyValue()));
|
final String description = descriptionFormat.formatted(CurrencyUtil.formatMoney(ae.getMoneyValue()));
|
||||||
TextFlow textFlow = new TextFlow(txLink, new Text(description));
|
|
||||||
return new AccountHistoryTile(ae.getTimestamp(), textFlow);
|
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 -> {
|
case BalanceRecord br -> {
|
||||||
Hyperlink brLink = new Hyperlink("Balance Record #" + br.id);
|
Hyperlink brLink = new Hyperlink("Balance Record #" + br.id);
|
||||||
brLink.setOnAction(event -> router.navigate("balance-record", br));
|
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,
|
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 -> {
|
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) {
|
private void addEntitiesToHistory(List<Timestamped> entities, int requestedItems) {
|
||||||
if (!itemsVBox.getChildren().isEmpty()) {
|
var futures = entities.stream().map(this::makeTile).toList();
|
||||||
itemsVBox.getChildren().add(new Separator(Orientation.HORIZONTAL));
|
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
||||||
}
|
.thenRun(() -> {
|
||||||
itemsVBox.getChildren().addAll(entities.stream()
|
List<AnchorPane> tiles = futures.stream().map(CompletableFuture::join)
|
||||||
.map(this::makeTile)
|
|
||||||
.map(tile -> {
|
.map(tile -> {
|
||||||
// Use this to scrunch content to the left.
|
// Use this to scrunch content to the left.
|
||||||
AnchorPane ap = new AnchorPane(tile);
|
AnchorPane ap = new AnchorPane(tile);
|
||||||
AnchorPane.setLeftAnchor(tile, 0.0);
|
AnchorPane.setLeftAnchor(tile, 0.0);
|
||||||
return ap;
|
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) {
|
if (entities.size() < requestedItems) {
|
||||||
canLoadMore.set(false);
|
canLoadMore.set(false);
|
||||||
BorderPane endMarker = new BorderPane(new Label("This is the start of the history."));
|
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()) {
|
if (!entities.isEmpty()) {
|
||||||
lastTimestamp = entities.getLast().getTimestamp();
|
lastTimestamp = entities.getLast().getTimestamp();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,7 +113,7 @@ public class AccountSelectionBox extends ComboBox<Account> {
|
||||||
nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
|
nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
|
||||||
if (showBalanceProp.get()) {
|
if (showBalanceProp.get()) {
|
||||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
BigDecimal balance = repo.deriveCurrentBalance(item.id);
|
BigDecimal balance = repo.deriveCurrentCashBalance(item.id);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));
|
balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));
|
||||||
balanceLabel.setVisible(true);
|
balanceLabel.setVisible(true);
|
||||||
|
|
|
@ -17,6 +17,7 @@ import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
@ -83,7 +84,7 @@ public class AccountTile extends BorderPane {
|
||||||
balanceLabel.getStyleClass().addAll("mono-font");
|
balanceLabel.getStyleClass().addAll("mono-font");
|
||||||
balanceLabel.setDisable(true);
|
balanceLabel.setDisable(true);
|
||||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
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()));
|
String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()));
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
balanceLabel.setText(text);
|
balanceLabel.setText(text);
|
||||||
|
@ -104,6 +105,32 @@ public class AccountTile extends BorderPane {
|
||||||
newPropertyLabel("Current Balance"),
|
newPropertyLabel("Current Balance"),
|
||||||
balanceLabel
|
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;
|
return propertiesPane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ package com.andrewlalis.perfin.view.component.module;
|
||||||
import com.andrewlalis.perfin.data.AccountRepository;
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
import com.andrewlalis.perfin.model.Account;
|
import com.andrewlalis.perfin.model.Account;
|
||||||
|
import com.andrewlalis.perfin.model.AccountType;
|
||||||
import com.andrewlalis.perfin.model.MoneyValue;
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.view.component.AccountTile;
|
import com.andrewlalis.perfin.view.component.AccountTile;
|
||||||
|
@ -91,13 +92,17 @@ public class AccountsModule extends DashboardModule {
|
||||||
Label typeLabel = new Label(account.getType().toString());
|
Label typeLabel = new Label(account.getType().toString());
|
||||||
typeLabel.getStyleClass().add("bold-text");
|
typeLabel.getStyleClass().add("bold-text");
|
||||||
typeLabel.setStyle("-fx-text-fill: " + AccountTile.ACCOUNT_TYPE_COLORS.get(account.getType()));
|
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...");
|
Label balanceLabel = new Label("Computing balance...");
|
||||||
balanceLabel.getStyleClass().addAll("mono-font");
|
balanceLabel.getStyleClass().addAll("mono-font");
|
||||||
balanceLabel.setDisable(true);
|
balanceLabel.setDisable(true);
|
||||||
|
rightSideVBox.getChildren().add(balanceLabel);
|
||||||
|
|
||||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
AccountRepository.class,
|
AccountRepository.class,
|
||||||
repo -> repo.deriveCurrentBalance(account.id)
|
repo -> repo.deriveCurrentCashBalance(account.id)
|
||||||
).thenAccept(bal -> Platform.runLater(() -> {
|
).thenAccept(bal -> Platform.runLater(() -> {
|
||||||
String text = CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(bal, account.getCurrency()));
|
String text = CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(bal, account.getCurrency()));
|
||||||
balanceLabel.setText(text);
|
balanceLabel.setText(text);
|
||||||
|
@ -109,9 +114,29 @@ public class AccountsModule extends DashboardModule {
|
||||||
balanceLabel.setDisable(false);
|
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);
|
VBox contentBox = new VBox(nameLabel, numberLabel, typeLabel);
|
||||||
borderPane.setCenter(contentBox);
|
borderPane.setCenter(contentBox);
|
||||||
borderPane.setRight(balanceLabel);
|
borderPane.setRight(rightSideVBox);
|
||||||
return borderPane;
|
return borderPane;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,6 +52,7 @@ public abstract class PieChartModule extends DashboardModule {
|
||||||
|
|
||||||
this.timeRangeChoiceBox.getItems().addAll(RANGE_CHOICES);
|
this.timeRangeChoiceBox.getItems().addAll(RANGE_CHOICES);
|
||||||
this.timeRangeChoiceBox.getSelectionModel().select("All Time");
|
this.timeRangeChoiceBox.getSelectionModel().select("All Time");
|
||||||
|
this.currencyChoiceBox.managedProperty().bind(this.currencyChoiceBox.visibleProperty());
|
||||||
|
|
||||||
PieChart chart = new PieChart(chartData);
|
PieChart chart = new PieChart(chartData);
|
||||||
chart.setLegendVisible(false);
|
chart.setLegendVisible(false);
|
||||||
|
@ -68,9 +69,7 @@ public abstract class PieChartModule extends DashboardModule {
|
||||||
chartData.clear();
|
chartData.clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
timeRangeChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> {
|
timeRangeChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> renderChart());
|
||||||
renderChart();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -136,6 +135,7 @@ public abstract class PieChartModule extends DashboardModule {
|
||||||
} else {
|
} else {
|
||||||
currencyChoiceBox.getSelectionModel().selectFirst();
|
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 to javafx.graphics;
|
||||||
exports com.andrewlalis.perfin.view to javafx.graphics;
|
exports com.andrewlalis.perfin.view to javafx.graphics;
|
||||||
exports com.andrewlalis.perfin.model 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.control to javafx.fxml;
|
||||||
opens com.andrewlalis.perfin.view 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 to javafx.fxml;
|
||||||
opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
|
opens com.andrewlalis.perfin.view.component.validation to javafx.fxml;
|
||||||
exports com.andrewlalis.perfin.model.history to javafx.graphics;
|
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 com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||||
<?import javafx.scene.control.*?>
|
<?import javafx.scene.control.*?>
|
||||||
<?import javafx.scene.layout.*?>
|
<?import javafx.scene.layout.*?>
|
||||||
<?import javafx.scene.text.*?>
|
<?import javafx.scene.text.Text?>
|
||||||
|
<?import javafx.scene.text.TextFlow?>
|
||||||
<BorderPane
|
<BorderPane
|
||||||
xmlns="http://javafx.com/javafx"
|
xmlns="http://javafx.com/javafx"
|
||||||
xmlns:fx="http://javafx.com/fxml"
|
xmlns:fx="http://javafx.com/fxml"
|
||||||
|
@ -15,10 +16,8 @@
|
||||||
</top>
|
</top>
|
||||||
<center>
|
<center>
|
||||||
<VBox>
|
<VBox>
|
||||||
<!-- Main account properties and actions -->
|
|
||||||
<FlowPane styleClass="std-padding,std-spacing">
|
|
||||||
<!-- Main account properties. -->
|
<!-- 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 text="Name" styleClass="bold-text"/>
|
||||||
<Label fx:id="accountNameLabel"/>
|
<Label fx:id="accountNameLabel"/>
|
||||||
|
|
||||||
|
@ -31,18 +30,31 @@
|
||||||
<Label text="Created At" styleClass="bold-text"/>
|
<Label text="Created At" styleClass="bold-text"/>
|
||||||
<Label fx:id="accountCreatedAtLabel" styleClass="mono-font"/>
|
<Label fx:id="accountCreatedAtLabel" styleClass="mono-font"/>
|
||||||
|
|
||||||
|
<Label text="Current Balance" styleClass="bold-text"/>
|
||||||
<VBox>
|
<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 fx:id="accountBalanceLabel" styleClass="mono-font"/>
|
||||||
|
<Label
|
||||||
|
styleClass="small-font,secondary-color-fill"
|
||||||
|
>Derived using nearest recorded balance and transactions.</Label>
|
||||||
|
</VBox>
|
||||||
</PropertiesPane>
|
</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}"/>
|
<Label text="Description" styleClass="bold-text" labelFor="${accountDescriptionText}"/>
|
||||||
<TextFlow maxWidth="500"><Text fx:id="accountDescriptionText"/></TextFlow>
|
<TextFlow maxWidth="500"><Text fx:id="accountDescriptionText"/></TextFlow>
|
||||||
</PropertiesPane>
|
</PropertiesPane>
|
||||||
|
@ -51,6 +63,7 @@
|
||||||
<HBox fx:id="actionsBox" styleClass="std-padding,std-spacing,small-font">
|
<HBox fx:id="actionsBox" styleClass="std-padding,std-spacing,small-font">
|
||||||
<Button text="Edit" onAction="#goToEditPage"/>
|
<Button text="Edit" onAction="#goToEditPage"/>
|
||||||
<Button text="Record Balance" onAction="#goToCreateBalanceRecord"/>
|
<Button text="Record Balance" onAction="#goToCreateBalanceRecord"/>
|
||||||
|
<Button text="Record Asset Value" onAction="#goToCreateAssetRecord"/>
|
||||||
<Button text="Archive" onAction="#archiveAccount"/>
|
<Button text="Archive" onAction="#archiveAccount"/>
|
||||||
<Button text="Delete" onAction="#deleteAccount"/>
|
<Button text="Delete" onAction="#deleteAccount"/>
|
||||||
<Button text="Unarchive" onAction="#unarchiveAccount"/>
|
<Button text="Unarchive" onAction="#unarchiveAccount"/>
|
||||||
|
|
|
@ -22,6 +22,9 @@
|
||||||
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
|
<ColumnConstraints hgrow="ALWAYS" halignment="LEFT"/>
|
||||||
</columnConstraints>
|
</columnConstraints>
|
||||||
|
|
||||||
|
<Label text="Type" styleClass="bold-text"/>
|
||||||
|
<Label fx:id="typeLabel" styleClass="mono-font"/>
|
||||||
|
|
||||||
<Label text="Timestamp" styleClass="bold-text"/>
|
<Label text="Timestamp" styleClass="bold-text"/>
|
||||||
<Label fx:id="timestampLabel" styleClass="mono-font"/>
|
<Label fx:id="timestampLabel" styleClass="mono-font"/>
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,9 @@
|
||||||
<Label text="Account Type" styleClass="bold-text"/>
|
<Label text="Account Type" styleClass="bold-text"/>
|
||||||
<ChoiceBox fx:id="accountTypeChoiceBox"/>
|
<ChoiceBox fx:id="accountTypeChoiceBox"/>
|
||||||
|
|
||||||
|
<Label text="Credit Limit" styleClass="bold-text" managed="${creditLimitField.managed}" visible="${creditLimitField.visible}"/>
|
||||||
|
<TextField fx:id="creditLimitField" styleClass="mono-font"/>
|
||||||
|
|
||||||
<Label text="Description" styleClass="bold-text"/>
|
<Label text="Description" styleClass="bold-text"/>
|
||||||
<TextArea
|
<TextArea
|
||||||
fx:id="descriptionField"
|
fx:id="descriptionField"
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
<?import com.andrewlalis.perfin.view.component.AccountSelectionBox?>
|
<?import com.andrewlalis.perfin.view.component.*?>
|
||||||
<?import com.andrewlalis.perfin.view.component.FileSelectionArea?>
|
|
||||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
|
||||||
<?import javafx.scene.control.*?>
|
<?import javafx.scene.control.*?>
|
||||||
<?import javafx.scene.layout.*?>
|
<?import javafx.scene.layout.*?>
|
||||||
<?import com.andrewlalis.perfin.view.component.CategorySelectionBox?>
|
|
||||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
|
|
||||||
<BorderPane xmlns="http://javafx.com/javafx"
|
<BorderPane xmlns="http://javafx.com/javafx"
|
||||||
xmlns:fx="http://javafx.com/fxml"
|
xmlns:fx="http://javafx.com/fxml"
|
||||||
fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
|
fx:controller="com.andrewlalis.perfin.control.EditTransactionController"
|
||||||
|
|
|
@ -11,24 +11,45 @@
|
||||||
<Label text="Edit Vendor" styleClass="bold-text,large-font,std-padding"/>
|
<Label text="Edit Vendor" styleClass="bold-text,large-font,std-padding"/>
|
||||||
</top>
|
</top>
|
||||||
<center>
|
<center>
|
||||||
<VBox>
|
<ScrollPane fitToWidth="true" fitToHeight="true">
|
||||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
|
<VBox style="-fx-max-width: 500px;">
|
||||||
|
<!-- Basic properties -->
|
||||||
|
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||||
<columnConstraints>
|
<columnConstraints>
|
||||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||||
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||||
</columnConstraints>
|
</columnConstraints>
|
||||||
|
|
||||||
<Label text="Name" labelFor="${nameField}"/>
|
<Label text="Name" labelFor="${nameField}" styleClass="bold-text"/>
|
||||||
<TextField fx:id="nameField"/>
|
<TextField fx:id="nameField"/>
|
||||||
|
|
||||||
<Label text="Description" labelFor="${descriptionField}"/>
|
<Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
|
||||||
<TextArea fx:id="descriptionField" wrapText="true"/>
|
<TextArea
|
||||||
|
fx:id="descriptionField"
|
||||||
|
wrapText="true"
|
||||||
|
style="-fx-pref-height: 100px; -fx-min-height: 100px;"
|
||||||
|
/>
|
||||||
</PropertiesPane>
|
</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/>
|
<Separator/>
|
||||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
|
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
|
||||||
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
||||||
<Button text="Cancel" onAction="#cancel"/>
|
<Button text="Cancel" onAction="#cancel"/>
|
||||||
</HBox>
|
</HBox>
|
||||||
</VBox>
|
</VBox>
|
||||||
|
</ScrollPane>
|
||||||
</center>
|
</center>
|
||||||
</BorderPane>
|
</BorderPane>
|
||||||
|
|
|
@ -33,5 +33,7 @@
|
||||||
[Adding a Transaction](help:adding-a-transaction)
|
[Adding a Transaction](help:adding-a-transaction)
|
||||||
--
|
--
|
||||||
[Profiles](help:profiles)
|
[Profiles](help:profiles)
|
||||||
|
--
|
||||||
|
[SQL Console](help:sql-console)
|
||||||
</StyledText>
|
</StyledText>
|
||||||
</VBox>
|
</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="Forward" onAction="#goForward"/>
|
||||||
<Button text="Dashboard" onAction="#goToDashboard"/>
|
<Button text="Dashboard" onAction="#goToDashboard"/>
|
||||||
<Button text="Profiles" onAction="#viewProfiles"/>
|
<Button text="Profiles" onAction="#viewProfiles"/>
|
||||||
|
<Button text="SQL Console" onAction="#goToSqlConsole"/>
|
||||||
|
|
||||||
<Button text="View Help" fx:id="showManualButton" onAction="#showManual"/>
|
<Button text="View Help" fx:id="showManualButton" onAction="#showManual"/>
|
||||||
<Button text="Hide Help" fx:id="hideManualButton" onAction="#hideManual"/>
|
<Button text="Hide Help" fx:id="hideManualButton" onAction="#hideManual"/>
|
||||||
|
@ -27,7 +28,7 @@
|
||||||
<!-- App footer -->
|
<!-- App footer -->
|
||||||
<bottom>
|
<bottom>
|
||||||
<HBox styleClass="std-padding,std-spacing">
|
<HBox styleClass="std-padding,std-spacing">
|
||||||
<Label text="Perfin Version 1.11.0"/>
|
<Label text="Perfin Version 1.19.0"/>
|
||||||
<AnchorPane>
|
<AnchorPane>
|
||||||
<Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
|
<Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
|
||||||
</AnchorPane>
|
</AnchorPane>
|
||||||
|
|
|
@ -37,6 +37,7 @@
|
||||||
</ScrollPane>
|
</ScrollPane>
|
||||||
</center>
|
</center>
|
||||||
<bottom>
|
<bottom>
|
||||||
|
<VBox>
|
||||||
<BorderPane>
|
<BorderPane>
|
||||||
<left>
|
<left>
|
||||||
<AnchorPane styleClass="std-padding">
|
<AnchorPane styleClass="std-padding">
|
||||||
|
@ -54,5 +55,9 @@
|
||||||
</VBox>
|
</VBox>
|
||||||
</right>
|
</right>
|
||||||
</BorderPane>
|
</BorderPane>
|
||||||
|
<HBox styleClass="std-spacing,std-padding">
|
||||||
|
<Button text="Create Sample Profile" styleClass="small-font" onAction="#createSampleProfile"/>
|
||||||
|
</HBox>
|
||||||
|
</VBox>
|
||||||
</bottom>
|
</bottom>
|
||||||
</BorderPane>
|
</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 (
|
CREATE TABLE account (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
created_at TIMESTAMP NOT NULL,
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
@ -9,6 +17,14 @@ CREATE TABLE account (
|
||||||
description VARCHAR(255) DEFAULT NULL
|
description VARCHAR(255) DEFAULT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE credit_card_account_properties (
|
||||||
|
account_id BIGINT PRIMARY KEY,
|
||||||
|
credit_limit NUMERIC(12, 4) NULL,
|
||||||
|
CONSTRAINT fk_credit_card_account_properties_account
|
||||||
|
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE attachment (
|
CREATE TABLE attachment (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
uploaded_at TIMESTAMP NOT NULL,
|
uploaded_at TIMESTAMP NOT NULL,
|
||||||
|
@ -120,6 +136,7 @@ CREATE TABLE balance_record (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
timestamp TIMESTAMP NOT NULL,
|
timestamp TIMESTAMP NOT NULL,
|
||||||
account_id BIGINT NOT NULL,
|
account_id BIGINT NOT NULL,
|
||||||
|
type ENUM('CASH', 'ASSETS') NOT NULL DEFAULT 'CASH',
|
||||||
balance NUMERIC(12, 4) NOT NULL,
|
balance NUMERIC(12, 4) NOT NULL,
|
||||||
currency VARCHAR(3) NOT NULL,
|
currency VARCHAR(3) NOT NULL,
|
||||||
CONSTRAINT fk_balance_record_account
|
CONSTRAINT fk_balance_record_account
|
||||||
|
@ -139,7 +156,8 @@ CREATE TABLE balance_record_attachment (
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
/* HISTORY */
|
/* HISTORY ENTITIES */
|
||||||
|
|
||||||
CREATE TABLE history (
|
CREATE TABLE history (
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT
|
id BIGINT PRIMARY KEY AUTO_INCREMENT
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,11 +2,8 @@
|
||||||
|
|
||||||
<?import com.andrewlalis.perfin.view.component.AccountSelectionBox?>
|
<?import com.andrewlalis.perfin.view.component.AccountSelectionBox?>
|
||||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
|
||||||
<?import javafx.scene.control.Button?>
|
<?import javafx.scene.control.*?>
|
||||||
<?import javafx.scene.control.Label?>
|
|
||||||
<?import javafx.scene.control.ScrollPane?>
|
|
||||||
<?import javafx.scene.layout.*?>
|
<?import javafx.scene.layout.*?>
|
||||||
<?import javafx.scene.control.TextField?>
|
|
||||||
<BorderPane xmlns="http://javafx.com/javafx"
|
<BorderPane xmlns="http://javafx.com/javafx"
|
||||||
xmlns:fx="http://javafx.com/fxml"
|
xmlns:fx="http://javafx.com/fxml"
|
||||||
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
|
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
|
||||||
|
@ -14,20 +11,23 @@
|
||||||
<top>
|
<top>
|
||||||
<HBox styleClass="std-padding,std-spacing">
|
<HBox styleClass="std-padding,std-spacing">
|
||||||
<Button text="Add Transaction" onAction="#addTransaction"/>
|
<Button text="Add Transaction" onAction="#addTransaction"/>
|
||||||
<Button text="Export Transactions" onAction="#exportTransactions"/>
|
<Button text="Export Transactions" onAction="#exportTransactions" disable="true"/>
|
||||||
</HBox>
|
</HBox>
|
||||||
</top>
|
</top>
|
||||||
<center>
|
<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>
|
<HBox>
|
||||||
<BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
|
<BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
|
||||||
<top>
|
<top>
|
||||||
<HBox styleClass="padding-extra,std-spacing">
|
<VBox styleClass="padding-extra,std-spacing">
|
||||||
<TextField fx:id="searchField" promptText="Search"/>
|
<TextField fx:id="searchField" promptText="Search" maxWidth="300" prefWidth="200" minWidth="100"/>
|
||||||
<PropertiesPane hgap="5" vgap="5">
|
<PropertiesPane hgap="5" vgap="5">
|
||||||
<Label text="Filter by Account"/>
|
<Label text="Filter by Account"/>
|
||||||
<AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
|
<AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
|
||||||
</PropertiesPane>
|
</PropertiesPane>
|
||||||
</HBox>
|
</VBox>
|
||||||
</top>
|
</top>
|
||||||
<center>
|
<center>
|
||||||
<ScrollPane styleClass="tile-container-scroll">
|
<ScrollPane styleClass="tile-container-scroll">
|
||||||
|
@ -35,6 +35,7 @@
|
||||||
</ScrollPane>
|
</ScrollPane>
|
||||||
</center>
|
</center>
|
||||||
</BorderPane>
|
</BorderPane>
|
||||||
|
|
||||||
<VBox fx:id="detailPanel"/>
|
<VBox fx:id="detailPanel"/>
|
||||||
</HBox>
|
</HBox>
|
||||||
</center>
|
</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