Compare commits
44 Commits
Author | SHA1 | Date |
---|---|---|
|
86ee9f8187 | |
|
36c29e0d06 | |
|
b91b0a8263 | |
|
6b63b777cf | |
|
71cc5b1612 | |
|
6d720b9645 | |
|
408d5e415d | |
|
3908515ca4 | |
|
b74119a233 | |
|
2abbd6ca43 | |
|
f23d2c85a9 | |
|
ec6bc83353 | |
|
feda2e1897 | |
|
d4bd5cc6ec | |
|
83e9043057 | |
|
ea94f09702 | |
|
411f384775 | |
|
72d624afdc | |
|
2dbb3d944d | |
|
a88ebc8e13 | |
|
d360de5d6f | |
|
6e862a2709 | |
|
b6fef8d42f | |
|
e08c528b71 | |
|
28002fd32d | |
|
a3558b33e6 | |
|
5ce2360f05 | |
|
4cf95dba85 | |
|
e6d5b280aa | |
|
1898783c56 | |
|
77f2966291 | |
|
20eed2108f | |
|
e4783e5a47 | |
|
a13c9c22df | |
|
8f5ff09891 | |
|
06d9aa016d | |
|
a9cdc6c41e | |
|
9222b8f990 | |
|
d85ff6676e | |
|
7f65466d6d | |
|
b52148fd3b | |
|
9bc4d1e494 | |
|
012b60d1f8 | |
|
41530d5276 |
|
@ -1,8 +1,5 @@
|
||||||
# Perfin
|
# Perfin
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
4
pom.xml
4
pom.xml
|
@ -6,13 +6,13 @@
|
||||||
|
|
||||||
<groupId>com.andrewlalis</groupId>
|
<groupId>com.andrewlalis</groupId>
|
||||||
<artifactId>perfin</artifactId>
|
<artifactId>perfin</artifactId>
|
||||||
<version>1.8.0</version>
|
<version>1.19.0</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<javafx.version>21.0.1</javafx.version>
|
<javafx.version>21.0.2</javafx.version>
|
||||||
<project.main-class>com.andrewlalis.perfin.PerfinApp</project.main-class>
|
<project.main-class>com.andrewlalis.perfin.PerfinApp</project.main-class>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar"
|
||||||
|
|
||||||
jpackage \
|
jpackage \
|
||||||
--name "Perfin" \
|
--name "Perfin" \
|
||||||
--app-version "1.8.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.8.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,37 +4,52 @@ 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.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.model.Profile;
|
|
||||||
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.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.application.Platform;
|
||||||
import javafx.beans.binding.BooleanExpression;
|
import javafx.beans.binding.BooleanExpression;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.beans.property.StringProperty;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.DatePicker;
|
import javafx.scene.control.DatePicker;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.text.Text;
|
||||||
|
|
||||||
import java.time.*;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
public class AccountViewController implements RouteSelectionListener {
|
public class AccountViewController implements RouteSelectionListener {
|
||||||
private Account account;
|
private final ObjectProperty<Account> accountProperty = new SimpleObjectProperty<>(null);
|
||||||
|
private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived());
|
||||||
|
private final StringProperty balanceTextProperty = new SimpleStringProperty(null);
|
||||||
|
private final StringProperty assetValueTextProperty = new SimpleStringProperty(null);
|
||||||
|
private final StringProperty creditLimitTextProperty = new SimpleStringProperty(null);
|
||||||
|
|
||||||
@FXML public Label titleLabel;
|
@FXML public Label titleLabel;
|
||||||
|
|
||||||
@FXML public Label accountNameLabel;
|
@FXML public Label accountNameLabel;
|
||||||
@FXML public Label accountNumberLabel;
|
@FXML public Label accountNumberLabel;
|
||||||
@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 BooleanProperty accountArchivedProperty = new SimpleBooleanProperty(false);
|
@FXML public PropertiesPane assetValuePane;
|
||||||
|
@FXML public Label latestAssetsValueLabel;
|
||||||
|
@FXML public PropertiesPane creditCardPropertiesPane;
|
||||||
|
@FXML public Label creditLimitLabel;
|
||||||
|
@FXML public PropertiesPane descriptionPane;
|
||||||
|
@FXML public Text accountDescriptionText;
|
||||||
|
|
||||||
@FXML public AccountHistoryView accountHistory;
|
@FXML public AccountHistoryView accountHistory;
|
||||||
|
|
||||||
|
@ -44,13 +59,37 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
@FXML public Button balanceCheckerButton;
|
@FXML public Button balanceCheckerButton;
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
|
titleLabel.textProperty().bind(accountProperty.map(a -> "Account #" + a.id));
|
||||||
|
accountNameLabel.textProperty().bind(accountProperty.map(Account::getName));
|
||||||
|
accountNumberLabel.textProperty().bind(accountProperty.map(Account::getAccountNumber));
|
||||||
|
accountCurrencyLabel.textProperty().bind(accountProperty.map(a -> a.getCurrency().getDisplayName()));
|
||||||
|
accountCreatedAtLabel.textProperty().bind(accountProperty.map(a -> DateUtil.formatUTCAsLocalWithZone(a.getCreatedAt())));
|
||||||
|
accountDescriptionText.textProperty().bind(accountProperty.map(Account::getDescription));
|
||||||
|
var hasDescription = accountProperty.map(a -> a.getDescription() != null);
|
||||||
|
BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription);
|
||||||
|
accountBalanceLabel.textProperty().bind(balanceTextProperty);
|
||||||
|
|
||||||
|
var isBrokerageAccount = accountProperty.map(a -> a.getType() == AccountType.BROKERAGE);
|
||||||
|
BindingUtil.bindManagedAndVisible(assetValuePane, isBrokerageAccount);
|
||||||
|
latestAssetsValueLabel.textProperty().bind(assetValueTextProperty);
|
||||||
|
|
||||||
|
var isCreditCardAccount = accountProperty.map(a -> a.getType() == AccountType.CREDIT_CARD);
|
||||||
|
BindingUtil.bindManagedAndVisible(creditCardPropertiesPane, isCreditCardAccount);
|
||||||
|
creditLimitLabel.textProperty().bind(creditLimitTextProperty);
|
||||||
|
|
||||||
actionsBox.getChildren().forEach(node -> {
|
actionsBox.getChildren().forEach(node -> {
|
||||||
Button button = (Button) node;
|
Button button = (Button) node;
|
||||||
BooleanExpression buttonActive = accountArchivedProperty;
|
ObservableValue<Boolean> buttonDisabled = accountArchived;
|
||||||
if (button.getText().equalsIgnoreCase("Unarchive")) {
|
if (button.getText().equalsIgnoreCase("Unarchive")) {
|
||||||
buttonActive = 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());
|
||||||
});
|
});
|
||||||
|
@ -66,12 +105,12 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
.toInstant();
|
.toInstant();
|
||||||
Profile.getCurrent().dataSource().mapRepoAsync(
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
AccountRepository.class,
|
AccountRepository.class,
|
||||||
repo -> repo.deriveBalance(account.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.",
|
||||||
date,
|
date,
|
||||||
CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()))
|
CurrencyUtil.formatMoney(new MoneyValue(balance, getAccount().getCurrency()))
|
||||||
);
|
);
|
||||||
Popups.message(balanceCheckerButton, msg);
|
Popups.message(balanceCheckerButton, msg);
|
||||||
}));
|
}));
|
||||||
|
@ -80,27 +119,52 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRouteSelected(Object context) {
|
public void onRouteSelected(Object context) {
|
||||||
account = (Account) context;
|
|
||||||
accountArchivedProperty.set(account.isArchived());
|
|
||||||
titleLabel.setText("Account #" + account.id);
|
|
||||||
accountNameLabel.setText(account.getName());
|
|
||||||
accountNumberLabel.setText(account.getAccountNumber());
|
|
||||||
accountCurrencyLabel.setText(account.getCurrency().getDisplayName());
|
|
||||||
accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
|
|
||||||
Profile.getCurrent().dataSource().getAccountBalanceText(account)
|
|
||||||
.thenAccept(accountBalanceLabel::setText);
|
|
||||||
accountHistory.clear();
|
accountHistory.clear();
|
||||||
accountHistory.setAccountId(account.id);
|
balanceTextProperty.set(null);
|
||||||
accountHistory.loadMoreHistory();
|
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
|
||||||
public void goToEditPage() {
|
public void goToEditPage() {
|
||||||
router.navigate("edit-account", account);
|
router.navigate("edit-account", getAccount());
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML public void goToCreateBalanceRecord() {
|
@FXML public void goToCreateBalanceRecord() {
|
||||||
router.navigate("create-balance-record", account);
|
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
|
||||||
|
@ -114,7 +178,7 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
"later if you need to."
|
"later if you need to."
|
||||||
);
|
);
|
||||||
if (confirmResult) {
|
if (confirmResult) {
|
||||||
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id));
|
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(getAccount().id));
|
||||||
router.replace("accounts");
|
router.replace("accounts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -126,7 +190,7 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
"status?"
|
"status?"
|
||||||
);
|
);
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id));
|
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(getAccount().id));
|
||||||
router.replace("accounts");
|
router.replace("accounts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -142,8 +206,12 @@ public class AccountViewController implements RouteSelectionListener {
|
||||||
"want to hide it."
|
"want to hide it."
|
||||||
);
|
);
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(account));
|
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(getAccount()));
|
||||||
router.replace("accounts");
|
router.replace("accounts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Account getAccount() {
|
||||||
|
return accountProperty.get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
balanceField.setText(null);
|
||||||
BigDecimal value = repo.deriveCurrentBalance(account.id);
|
if (ctx.type() == BalanceRecordType.CASH) {
|
||||||
Platform.runLater(() -> balanceField.setText(
|
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
|
||||||
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
|
BigDecimal value = repo.deriveCurrentCashBalance(account.id);
|
||||||
));
|
Platform.runLater(() -> balanceField.setText(
|
||||||
});
|
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(
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
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.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;
|
||||||
|
@ -29,14 +31,19 @@ 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
|
||||||
public void onRouteSelected(Object context) {
|
public void onRouteSelected(Object context) {
|
||||||
for (var child : modulesFlowPane.getChildren()) {
|
Profile.whenLoaded(profile -> {
|
||||||
DashboardModule module = (DashboardModule) child;
|
for (var child : modulesFlowPane.getChildren()) {
|
||||||
module.refreshContents();
|
DashboardModule module = (DashboardModule) child;
|
||||||
}
|
module.refreshContents();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -33,20 +33,15 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
private Account account;
|
private Account account;
|
||||||
private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false);
|
private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false);
|
||||||
|
|
||||||
@FXML
|
@FXML public Label titleLabel;
|
||||||
public Label titleLabel;
|
@FXML public TextField accountNameField;
|
||||||
@FXML
|
@FXML public TextField accountNumberField;
|
||||||
public TextField accountNameField;
|
@FXML public ComboBox<Currency> accountCurrencyComboBox;
|
||||||
@FXML
|
@FXML public ChoiceBox<AccountType> accountTypeChoiceBox;
|
||||||
public TextField accountNumberField;
|
@FXML public TextField creditLimitField;
|
||||||
@FXML
|
@FXML public TextArea descriptionField;
|
||||||
public ComboBox<Currency> accountCurrencyComboBox;
|
@FXML public PropertiesPane initialBalanceContent;
|
||||||
@FXML
|
@FXML public TextField initialBalanceField;
|
||||||
public ChoiceBox<AccountType> accountTypeChoiceBox;
|
|
||||||
@FXML
|
|
||||||
public PropertiesPane initialBalanceContent;
|
|
||||||
@FXML
|
|
||||||
public TextField initialBalanceField;
|
|
||||||
|
|
||||||
@FXML public Button saveButton;
|
@FXML public Button saveButton;
|
||||||
|
|
||||||
|
@ -66,8 +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>()
|
||||||
|
.addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.")
|
||||||
|
).attach(descriptionField, descriptionField.textProperty());
|
||||||
|
|
||||||
// Combine validity of all fields for an expression that determines if the whole form is valid.
|
// 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()));
|
BooleanExpression formValid = nameValid
|
||||||
|
.and(numberValid)
|
||||||
|
.and(balanceValid.or(creatingNewAccount.not()))
|
||||||
|
.and(descriptionValid)
|
||||||
|
.and(creditLimitValid);
|
||||||
saveButton.disableProperty().bind(formValid.not());
|
saveButton.disableProperty().bind(formValid.not());
|
||||||
|
|
||||||
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
|
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
|
||||||
|
@ -111,6 +123,15 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
String number = accountNumberField.getText().strip();
|
String number = accountNumberField.getText().strip();
|
||||||
AccountType type = accountTypeChoiceBox.getValue();
|
AccountType type = accountTypeChoiceBox.getValue();
|
||||||
Currency currency = accountCurrencyComboBox.getValue();
|
Currency currency = accountCurrencyComboBox.getValue();
|
||||||
|
String description = descriptionField.getText();
|
||||||
|
if (description != null) {
|
||||||
|
description = description.strip();
|
||||||
|
if (description.isBlank()) description = null;
|
||||||
|
}
|
||||||
|
BigDecimal creditLimit = null;
|
||||||
|
if (type == AccountType.CREDIT_CARD && creditLimitField.getText() != null && !creditLimitField.getText().isBlank()) {
|
||||||
|
creditLimit = new BigDecimal(creditLimitField.getText());
|
||||||
|
}
|
||||||
try (
|
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()
|
||||||
|
@ -128,14 +149,20 @@ 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);
|
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);
|
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);
|
||||||
}
|
}
|
||||||
|
@ -157,11 +184,29 @@ public class EditAccountController implements RouteSelectionListener {
|
||||||
accountTypeChoiceBox.getSelectionModel().selectFirst();
|
accountTypeChoiceBox.getSelectionModel().selectFirst();
|
||||||
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);
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,15 +12,16 @@ import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
|
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.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.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.BooleanExpression;
|
import javafx.beans.binding.BooleanExpression;
|
||||||
import javafx.beans.property.BooleanProperty;
|
import javafx.beans.property.*;
|
||||||
import javafx.beans.property.Property;
|
import javafx.beans.value.ObservableValue;
|
||||||
import javafx.beans.property.SimpleBooleanProperty;
|
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
|
@ -40,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;
|
||||||
|
|
||||||
|
@ -56,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;
|
||||||
|
@ -75,11 +78,17 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
@FXML public Spinner<Integer> lineItemQuantitySpinner;
|
@FXML public Spinner<Integer> lineItemQuantitySpinner;
|
||||||
@FXML public TextField lineItemValueField;
|
@FXML public TextField lineItemValueField;
|
||||||
@FXML public TextField lineItemDescriptionField;
|
@FXML public TextField lineItemDescriptionField;
|
||||||
|
@FXML public CategorySelectionBox lineItemCategoryComboBox;
|
||||||
@FXML public Button addLineItemButton;
|
@FXML public Button addLineItemButton;
|
||||||
@FXML public VBox addLineItemForm;
|
@FXML public VBox addLineItemForm;
|
||||||
@FXML public Button addLineItemAddButton;
|
@FXML public Button addLineItemAddButton;
|
||||||
@FXML public Button addLineItemCancelButton;
|
@FXML public Button addLineItemCancelButton;
|
||||||
|
@FXML public VBox lineItemsVBox;
|
||||||
|
@FXML public Label lineItemsValueMatchLabel;
|
||||||
|
@FXML public Button lineItemsAmountSyncButton;
|
||||||
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
|
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
|
||||||
|
private final ObservableList<TransactionLineItem> lineItems = FXCollections.observableArrayList();
|
||||||
|
private static long tmpLineItemId = -1L;
|
||||||
|
|
||||||
@FXML public FileSelectionArea attachmentsSelectionArea;
|
@FXML public FileSelectionArea attachmentsSelectionArea;
|
||||||
|
|
||||||
|
@ -96,39 +105,42 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
return ts != null && ts.isBefore(LocalDateTime.now());
|
return ts != null && ts.isBefore(LocalDateTime.now());
|
||||||
}, "Timestamp cannot be in the future.")
|
}, "Timestamp cannot be in the future.")
|
||||||
).validatedInitially().attachToTextField(timestampField);
|
).validatedInitially().attachToTextField(timestampField);
|
||||||
var amountValid = new ValidationApplier<>(
|
var amountValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false) {
|
||||||
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
|
@Override
|
||||||
).validatedInitially().attachToTextField(amountField, currencyChoiceBox.valueProperty());
|
public ValidationResult validate(String input) {
|
||||||
|
var r = super.validate(input);
|
||||||
|
if (!r.isValid()) return r;
|
||||||
|
// Check that this amount is enough to cover the total of any line items.
|
||||||
|
BigDecimal lineItemsTotal = lineItems.stream().map(TransactionLineItem::getTotalValue)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal transactionAmount = new BigDecimal(input);
|
||||||
|
if (transactionAmount.compareTo(lineItemsTotal) < 0) {
|
||||||
|
String msg = String.format(
|
||||||
|
"Amount must be at least %s to account for line items.",
|
||||||
|
CurrencyUtil.formatMoney(new MoneyValue(lineItemsTotal, currencyChoiceBox.getValue()))
|
||||||
|
);
|
||||||
|
return ValidationResult.of(msg);
|
||||||
|
}
|
||||||
|
return ValidationResult.valid();
|
||||||
|
}
|
||||||
|
}).validatedInitially().attachToTextField(
|
||||||
|
amountField,
|
||||||
|
currencyChoiceBox.valueProperty(),
|
||||||
|
new SimpleListProperty<>(lineItems)
|
||||||
|
);
|
||||||
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
|
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
|
||||||
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
||||||
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
|
||||||
initializeTagSelectionUi();
|
initializeTagSelectionUi();
|
||||||
|
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"));
|
||||||
|
|
||||||
// Initialize line item stuff.
|
basicTransactionInfoValid.bind(timestampValid.and(amountValid).and(currencyChoiceBox.valueProperty().isNotNull()));
|
||||||
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
|
|
||||||
addLineItemCancelButton.setOnAction(event -> {
|
|
||||||
lineItemQuantitySpinner.getValueFactory().setValue(1);
|
|
||||||
lineItemValueField.setText(null);
|
|
||||||
lineItemDescriptionField.setText(null);
|
|
||||||
addingLineItemProperty.set(false);
|
|
||||||
});
|
|
||||||
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
|
|
||||||
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
|
|
||||||
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
|
|
||||||
var lineItemValueValid = new ValidationApplier<>(
|
|
||||||
new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false)
|
|
||||||
).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
|
|
||||||
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
|
||||||
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
|
|
||||||
).attachToTextField(lineItemDescriptionField);
|
|
||||||
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
|
|
||||||
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
|
|
||||||
|
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
@ -157,6 +169,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
vendor,
|
vendor,
|
||||||
category,
|
category,
|
||||||
tags,
|
tags,
|
||||||
|
lineItems,
|
||||||
newAttachmentPaths
|
newAttachmentPaths
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -173,6 +186,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
vendor,
|
vendor,
|
||||||
category,
|
category,
|
||||||
tags,
|
tags,
|
||||||
|
lineItems,
|
||||||
existingAttachments,
|
existingAttachments,
|
||||||
newAttachmentPaths
|
newAttachmentPaths
|
||||||
)
|
)
|
||||||
|
@ -195,6 +209,8 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
vendorComboBox.setValue(null);
|
vendorComboBox.setValue(null);
|
||||||
categoryComboBox.select(null);
|
categoryComboBox.select(null);
|
||||||
|
|
||||||
|
addingLineItemProperty.set(false);
|
||||||
|
|
||||||
if (transaction == null) {
|
if (transaction == null) {
|
||||||
titleLabel.setText("Create New Transaction");
|
titleLabel.setText("Create New Transaction");
|
||||||
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
|
||||||
|
@ -215,7 +231,8 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
var accountRepo = ds.getAccountRepository();
|
var accountRepo = ds.getAccountRepository();
|
||||||
var transactionRepo = ds.getTransactionRepository();
|
var transactionRepo = ds.getTransactionRepository();
|
||||||
var vendorRepo = ds.getTransactionVendorRepository();
|
var vendorRepo = ds.getTransactionVendorRepository();
|
||||||
var categoryRepo = ds.getTransactionCategoryRepository()
|
var categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
|
var lineItemRepo = ds.getTransactionLineItemRepository()
|
||||||
) {
|
) {
|
||||||
// First fetch all the data.
|
// First fetch all the data.
|
||||||
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
|
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
|
||||||
|
@ -229,12 +246,14 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
final CreditAndDebitAccounts linkedAccounts;
|
final CreditAndDebitAccounts linkedAccounts;
|
||||||
final String vendorName;
|
final String vendorName;
|
||||||
final TransactionCategory category;
|
final TransactionCategory category;
|
||||||
|
final List<TransactionLineItem> existingLineItems;
|
||||||
if (transaction == null) {
|
if (transaction == null) {
|
||||||
attachments = Collections.emptyList();
|
attachments = Collections.emptyList();
|
||||||
tags = Collections.emptyList();
|
tags = Collections.emptyList();
|
||||||
linkedAccounts = new CreditAndDebitAccounts(null, null);
|
linkedAccounts = new CreditAndDebitAccounts(null, null);
|
||||||
vendorName = null;
|
vendorName = null;
|
||||||
category = null;
|
category = null;
|
||||||
|
existingLineItems = Collections.emptyList();
|
||||||
} else {
|
} else {
|
||||||
attachments = transactionRepo.findAttachments(transaction.id);
|
attachments = transactionRepo.findAttachments(transaction.id);
|
||||||
tags = transactionRepo.findTags(transaction.id);
|
tags = transactionRepo.findTags(transaction.id);
|
||||||
|
@ -250,6 +269,7 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
} else {
|
} else {
|
||||||
category = null;
|
category = null;
|
||||||
}
|
}
|
||||||
|
existingLineItems = lineItemRepo.findItems(transaction.id);
|
||||||
}
|
}
|
||||||
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
|
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
|
||||||
// Then make updates to the view.
|
// Then make updates to the view.
|
||||||
|
@ -275,6 +295,9 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
creditAccountSelector.select(linkedAccounts.creditAccount());
|
creditAccountSelector.select(linkedAccounts.creditAccount());
|
||||||
debitAccountSelector.select(linkedAccounts.debitAccount());
|
debitAccountSelector.select(linkedAccounts.debitAccount());
|
||||||
}
|
}
|
||||||
|
lineItemCategoryComboBox.loadCategories(categoryTreeNodes);
|
||||||
|
lineItemCategoryComboBox.select(null);
|
||||||
|
lineItems.setAll(existingLineItems);
|
||||||
container.setDisable(false);
|
container.setDisable(false);
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
@ -291,7 +314,50 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
|
||||||
return new ValidationApplier<>(getLinkedAccountsValidator())
|
return new ValidationApplier<>(getLinkedAccountsValidator())
|
||||||
.validatedInitially()
|
.validatedInitially()
|
||||||
.attach(linkedAccountsContainer, linkedAccountsProperty);
|
.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() {
|
||||||
|
@ -326,6 +392,107 @@ public class EditTransactionController implements RouteSelectionListener {
|
||||||
return tile;
|
return tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void initializeLineItemsUi() {
|
||||||
|
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
|
||||||
|
addLineItemCancelButton.setOnAction(event -> addingLineItemProperty.set(false));
|
||||||
|
addingLineItemProperty.addListener((observable, oldValue, newValue) -> {
|
||||||
|
if (!newValue) { // The form has been closed.
|
||||||
|
lineItemQuantitySpinner.getValueFactory().setValue(1);
|
||||||
|
lineItemValueField.setText(null);
|
||||||
|
lineItemDescriptionField.setText(null);
|
||||||
|
lineItemCategoryComboBox.setValue(categoryComboBox.getValue());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
|
||||||
|
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
|
||||||
|
BindingUtil.mapContent(lineItemsVBox.getChildren(), lineItems, this::createLineItemTile);
|
||||||
|
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
|
||||||
|
var lineItemValueValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false))
|
||||||
|
.validatedInitially().attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
|
||||||
|
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
|
||||||
|
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
|
||||||
|
.addPredicate(s -> s.strip().length() <= TransactionLineItem.DESCRIPTION_MAX_LENGTH, "Description is too long.")
|
||||||
|
.addPredicate(
|
||||||
|
s -> lineItems.stream().map(TransactionLineItem::getDescription).noneMatch(d -> d.equalsIgnoreCase(s)),
|
||||||
|
"Description must be unique."
|
||||||
|
)
|
||||||
|
).validatedInitially().attachToTextField(lineItemDescriptionField);
|
||||||
|
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
|
||||||
|
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
|
||||||
|
addLineItemAddButton.setOnAction(event -> {
|
||||||
|
int quantity = lineItemQuantitySpinner.getValue();
|
||||||
|
BigDecimal valuePerItem = new BigDecimal(lineItemValueField.getText());
|
||||||
|
String description = lineItemDescriptionField.getText().strip();
|
||||||
|
TransactionCategory category = lineItemCategoryComboBox.getValue();
|
||||||
|
Long categoryId = category == null ? null : category.id;
|
||||||
|
long tmpId = tmpLineItemId--;
|
||||||
|
TransactionLineItem tmpItem = new TransactionLineItem(tmpId, -1L, valuePerItem, quantity, -1, description, categoryId);
|
||||||
|
lineItems.add(tmpItem);
|
||||||
|
addingLineItemProperty.set(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logic for showing an indicator when the line items total exactly matches the entered amount.
|
||||||
|
ListProperty<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItems);
|
||||||
|
ObservableValue<BigDecimal> lineItemsTotalValue = lineItemsProperty.map(items -> items.stream()
|
||||||
|
.map(TransactionLineItem::getTotalValue)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||||
|
ObjectProperty<BigDecimal> amountFieldValue = new SimpleObjectProperty<>(BigDecimal.ZERO);
|
||||||
|
amountField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
if (newValue == null) {
|
||||||
|
amountFieldValue.set(BigDecimal.ZERO);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
BigDecimal amount = new BigDecimal(newValue);
|
||||||
|
amountFieldValue.set(amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
amountFieldValue.set(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false);
|
||||||
|
lineItemsTotalValue.addListener((observable, oldValue, newValue) ->
|
||||||
|
lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0));
|
||||||
|
amountFieldValue.addListener((observable, oldValue, newValue) ->
|
||||||
|
lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0));
|
||||||
|
BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not()));
|
||||||
|
|
||||||
|
// Logic for button that syncs line items total to the amount field.
|
||||||
|
BindingUtil.bindManagedAndVisible(lineItemsAmountSyncButton, lineItemsTotalMatchesAmount.not().and(lineItemsProperty.emptyProperty().not()));
|
||||||
|
lineItemsAmountSyncButton.setOnAction(event -> amountField.setText(
|
||||||
|
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(
|
||||||
|
lineItemsTotalValue.getValue(),
|
||||||
|
currencyChoiceBox.getValue()
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node createLineItemTile(TransactionLineItem item) {
|
||||||
|
TransactionLineItemTile tile = TransactionLineItemTile.build(item, currencyChoiceBox.valueProperty(), categoryComboBox.getItems()).join();
|
||||||
|
Button removeButton = new Button("Remove");
|
||||||
|
removeButton.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
removeButton.setOnAction(event -> lineItems.remove(item));
|
||||||
|
Button moveUpButton = new Button("Move Up");
|
||||||
|
moveUpButton.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
moveUpButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getFirst().equals(item)));
|
||||||
|
moveUpButton.setOnAction(event -> {
|
||||||
|
int currentIdx = lineItems.indexOf(item);
|
||||||
|
lineItems.remove(currentIdx);
|
||||||
|
lineItems.add(currentIdx - 1, item);
|
||||||
|
});
|
||||||
|
Button moveDownButton = new Button("Move Down");
|
||||||
|
moveDownButton.setMaxWidth(Double.POSITIVE_INFINITY);
|
||||||
|
moveDownButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getLast().equals(item)));
|
||||||
|
moveDownButton.setOnAction(event -> {
|
||||||
|
int currentIdx = lineItems.indexOf(item);
|
||||||
|
lineItems.remove(currentIdx);
|
||||||
|
lineItems.add(currentIdx + 1, item);
|
||||||
|
});
|
||||||
|
VBox buttonsBox = new VBox(removeButton, moveUpButton, moveDownButton);
|
||||||
|
buttonsBox.getStyleClass().addAll("std-spacing");
|
||||||
|
tile.setRight(buttonsBox);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
private CreditAndDebitAccounts getSelectedAccounts() {
|
private CreditAndDebitAccounts getSelectedAccounts() {
|
||||||
return new CreditAndDebitAccounts(
|
return new CreditAndDebitAccounts(
|
||||||
creditAccountSelector.getValue(),
|
creditAccountSelector.getValue(),
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,32 +7,42 @@ import com.andrewlalis.perfin.model.*;
|
||||||
import com.andrewlalis.perfin.view.BindingUtil;
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
|
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
|
||||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
import com.andrewlalis.perfin.view.component.PropertiesPane;
|
||||||
|
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.property.ListProperty;
|
import javafx.beans.property.ListProperty;
|
||||||
import javafx.beans.property.ObjectProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
import javafx.beans.property.SimpleListProperty;
|
import javafx.beans.property.SimpleListProperty;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
import javafx.scene.control.Hyperlink;
|
import javafx.scene.control.Hyperlink;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
import javafx.scene.shape.Circle;
|
import javafx.scene.shape.Circle;
|
||||||
import javafx.scene.text.TextFlow;
|
import javafx.scene.text.TextFlow;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Currency;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
import static com.andrewlalis.perfin.PerfinApp.router;
|
import static com.andrewlalis.perfin.PerfinApp.router;
|
||||||
|
|
||||||
public class TransactionViewController {
|
public class TransactionViewController {
|
||||||
private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
|
private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
|
||||||
|
|
||||||
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
|
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
|
||||||
|
private final ObservableValue<Currency> observableCurrency = transactionProperty.map(Transaction::getCurrency);
|
||||||
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
|
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
|
||||||
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
|
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
|
||||||
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
|
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
|
||||||
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
|
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
|
||||||
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
|
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
|
||||||
|
private final ObservableList<TransactionLineItem> lineItemsList = FXCollections.observableArrayList();
|
||||||
|
private final ListProperty<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItemsList);
|
||||||
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
|
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
|
||||||
|
|
||||||
@FXML public Label titleLabel;
|
@FXML public Label titleLabel;
|
||||||
|
@ -49,6 +59,8 @@ public class TransactionViewController {
|
||||||
@FXML public Hyperlink debitAccountLink;
|
@FXML public Hyperlink debitAccountLink;
|
||||||
@FXML public Hyperlink creditAccountLink;
|
@FXML public Hyperlink creditAccountLink;
|
||||||
|
|
||||||
|
@FXML public VBox lineItemsVBox;
|
||||||
|
|
||||||
@FXML public AttachmentsViewPane attachmentsViewPane;
|
@FXML public AttachmentsViewPane attachmentsViewPane;
|
||||||
|
|
||||||
@FXML public void initialize() {
|
@FXML public void initialize() {
|
||||||
|
@ -89,6 +101,26 @@ public class TransactionViewController {
|
||||||
return event -> {};
|
return event -> {};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
VBox lineItemsContainer = (VBox) lineItemsVBox.getParent();
|
||||||
|
BindingUtil.bindManagedAndVisible(lineItemsContainer, lineItemsProperty.emptyProperty().not());
|
||||||
|
lineItemsProperty.addListener((observable, oldValue, newValue) -> {
|
||||||
|
lineItemsVBox.getChildren().clear();
|
||||||
|
Label loadingLabel = new Label("Loading line items...");
|
||||||
|
loadingLabel.getStyleClass().addAll("secondary-color-text-fill");
|
||||||
|
lineItemsVBox.getChildren().add(loadingLabel);
|
||||||
|
List<CompletableFuture<TransactionLineItemTile>> tileFutures = lineItemsList.stream()
|
||||||
|
.map(item -> TransactionLineItemTile.build(item, observableCurrency, null))
|
||||||
|
.toList();
|
||||||
|
Thread.ofVirtual().start(() -> {
|
||||||
|
List<TransactionLineItemTile> tiles = tileFutures.stream()
|
||||||
|
.map(CompletableFuture::join).toList();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
lineItemsVBox.getChildren().remove(loadingLabel);
|
||||||
|
lineItemsVBox.getChildren().addAll(tiles);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
attachmentsViewPane.hideIfEmpty();
|
attachmentsViewPane.hideIfEmpty();
|
||||||
attachmentsViewPane.listProperty().bindContent(attachmentsList);
|
attachmentsViewPane.listProperty().bindContent(attachmentsList);
|
||||||
|
|
||||||
|
@ -98,6 +130,7 @@ public class TransactionViewController {
|
||||||
vendorProperty.set(null);
|
vendorProperty.set(null);
|
||||||
categoryProperty.set(null);
|
categoryProperty.set(null);
|
||||||
tagsList.clear();
|
tagsList.clear();
|
||||||
|
lineItemsList.clear();
|
||||||
attachmentsList.clear();
|
attachmentsList.clear();
|
||||||
} else {
|
} else {
|
||||||
updateLinkedData(newValue);
|
updateLinkedData(newValue);
|
||||||
|
@ -115,19 +148,22 @@ public class TransactionViewController {
|
||||||
try (
|
try (
|
||||||
var transactionRepo = ds.getTransactionRepository();
|
var transactionRepo = ds.getTransactionRepository();
|
||||||
var vendorRepo = ds.getTransactionVendorRepository();
|
var vendorRepo = ds.getTransactionVendorRepository();
|
||||||
var categoryRepo = ds.getTransactionCategoryRepository()
|
var categoryRepo = ds.getTransactionCategoryRepository();
|
||||||
|
var lineItemsRepo = ds.getTransactionLineItemRepository()
|
||||||
) {
|
) {
|
||||||
final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
|
final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
|
||||||
final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
|
final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
|
||||||
final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
|
final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
|
||||||
final var attachments = transactionRepo.findAttachments(tx.id);
|
final var attachments = transactionRepo.findAttachments(tx.id);
|
||||||
final var tags = transactionRepo.findTags(tx.id);
|
final var tags = transactionRepo.findTags(tx.id);
|
||||||
|
final var lineItems = lineItemsRepo.findItems(tx.id);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
linkedAccountsProperty.set(linkedAccounts);
|
linkedAccountsProperty.set(linkedAccounts);
|
||||||
vendorProperty.set(vendor);
|
vendorProperty.set(vendor);
|
||||||
categoryProperty.set(category);
|
categoryProperty.set(category);
|
||||||
attachmentsList.setAll(attachments);
|
attachmentsList.setAll(attachments);
|
||||||
tagsList.setAll(tags);
|
tagsList.setAll(tags);
|
||||||
|
lineItemsList.setAll(lineItems);
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to fetch additional transaction data.", e);
|
log.error("Failed to fetch additional transaction data.", e);
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -16,22 +17,29 @@ import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface AccountRepository extends Repository, AutoCloseable {
|
public interface AccountRepository extends Repository, AutoCloseable {
|
||||||
long insert(AccountType type, String accountNumber, String name, Currency currency);
|
long insert(AccountType type, String accountNumber, String name, Currency currency, String description);
|
||||||
Page<Account> findAll(PageRequest pagination);
|
Page<Account> findAll(PageRequest pagination);
|
||||||
List<Account> findAllOrderedByRecentHistory();
|
List<Account> findAllOrderedByRecentHistory();
|
||||||
List<Account> findTopNOrderedByRecentHistory(int n);
|
List<Account> findTopNOrderedByRecentHistory(int n);
|
||||||
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);
|
||||||
void update(long accountId, AccountType type, String accountNumber, String name, Currency currency);
|
CreditCardProperties getCreditCardProperties(long id);
|
||||||
|
void saveCreditCardProperties(CreditCardProperties properties);
|
||||||
|
void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description);
|
||||||
void delete(Account account);
|
void 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;
|
||||||
|
@ -32,8 +33,10 @@ public interface DataSource {
|
||||||
TransactionRepository getTransactionRepository();
|
TransactionRepository getTransactionRepository();
|
||||||
TransactionVendorRepository getTransactionVendorRepository();
|
TransactionVendorRepository getTransactionVendorRepository();
|
||||||
TransactionCategoryRepository getTransactionCategoryRepository();
|
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||||
|
TransactionLineItemRepository getTransactionLineItemRepository();
|
||||||
AttachmentRepository getAttachmentRepository();
|
AttachmentRepository getAttachmentRepository();
|
||||||
HistoryRepository getHistoryRepository();
|
HistoryRepository getHistoryRepository();
|
||||||
|
SavedQueryRepository getSavedQueryRepository();
|
||||||
|
|
||||||
AnalyticsRepository getAnalyticsRepository();
|
AnalyticsRepository getAnalyticsRepository();
|
||||||
|
|
||||||
|
@ -87,8 +90,10 @@ public interface DataSource {
|
||||||
TransactionRepository.class, this::getTransactionRepository,
|
TransactionRepository.class, this::getTransactionRepository,
|
||||||
TransactionVendorRepository.class, this::getTransactionVendorRepository,
|
TransactionVendorRepository.class, this::getTransactionVendorRepository,
|
||||||
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
|
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
|
||||||
|
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);
|
||||||
|
@ -99,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)));
|
||||||
|
@ -109,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()) {
|
||||||
|
@ -129,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())
|
||||||
|
|
|
@ -3,8 +3,10 @@ package com.andrewlalis.perfin.data;
|
||||||
import com.andrewlalis.perfin.model.TransactionCategory;
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
import javafx.scene.paint.Color;
|
import javafx.scene.paint.Color;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public interface TransactionCategoryRepository extends Repository, AutoCloseable {
|
public interface TransactionCategoryRepository extends Repository, AutoCloseable {
|
||||||
Optional<TransactionCategory> findById(long id);
|
Optional<TransactionCategory> findById(long id);
|
||||||
|
@ -17,6 +19,17 @@ public interface TransactionCategoryRepository extends Repository, AutoCloseable
|
||||||
void update(long id, String name, Color color);
|
void update(long id, String name, Color color);
|
||||||
void deleteById(long id);
|
void deleteById(long id);
|
||||||
|
|
||||||
record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children){}
|
record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children) {
|
||||||
|
public Set<Long> allIds() {
|
||||||
|
Set<Long> ids = new HashSet<>();
|
||||||
|
ids.add(category.id);
|
||||||
|
for (var child : children) {
|
||||||
|
ids.addAll(child.allIds());
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<CategoryTreeNode> findTree();
|
List<CategoryTreeNode> findTree();
|
||||||
|
CategoryTreeNode findTree(TransactionCategory root);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface TransactionLineItemRepository extends Repository, AutoCloseable {
|
||||||
|
List<TransactionLineItem> findItems(long transactionId);
|
||||||
|
List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items);
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
|
||||||
import com.andrewlalis.perfin.model.Attachment;
|
import com.andrewlalis.perfin.model.Attachment;
|
||||||
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.Transaction;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
@ -24,15 +25,19 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
String vendor,
|
String vendor,
|
||||||
String category,
|
String category,
|
||||||
Set<String> tags,
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Path> attachments
|
List<Path> attachments
|
||||||
);
|
);
|
||||||
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);
|
||||||
|
@ -50,6 +55,7 @@ public interface TransactionRepository extends Repository, AutoCloseable {
|
||||||
String vendor,
|
String vendor,
|
||||||
String category,
|
String category,
|
||||||
Set<String> tags,
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Attachment> existingAttachments,
|
List<Attachment> existingAttachments,
|
||||||
List<Path> newAttachmentPaths
|
List<Path> newAttachmentPaths
|
||||||
);
|
);
|
||||||
|
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,19 +23,22 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class);
|
private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long insert(AccountType type, String accountNumber, String name, Currency currency) {
|
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) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
List.of(
|
DbUtil.timestampFromUtcNow(),
|
||||||
DbUtil.timestampFromUtcNow(),
|
type.name(),
|
||||||
type.name(),
|
accountNumber,
|
||||||
accountNumber,
|
name,
|
||||||
name,
|
currency.getCurrencyCode(),
|
||||||
currency.getCurrencyCode()
|
description
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
// If it's a credit card account, preemptively create a credit card properties record.
|
||||||
|
if (type == AccountType.CREDIT_CARD) {
|
||||||
|
saveCreditCardProperties(new CreditCardProperties(accountId, null));
|
||||||
|
}
|
||||||
// Insert a history item indicating the creation of the account.
|
// 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)) {
|
||||||
|
@ -210,7 +264,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void update(long accountId, AccountType type, String accountNumber, String name, Currency currency) {
|
public void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description) {
|
||||||
DbUtil.doTransaction(conn, () -> {
|
DbUtil.doTransaction(conn, () -> {
|
||||||
Account account = findById(accountId).orElse(null);
|
Account account = findById(accountId).orElse(null);
|
||||||
if (account == null) return;
|
if (account == null) return;
|
||||||
|
@ -231,6 +285,10 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
DbUtil.updateOne(conn, "UPDATE account SET currency = ? WHERE id = ?", currency.getCurrencyCode(), accountId);
|
DbUtil.updateOne(conn, "UPDATE account SET currency = ? WHERE id = ?", currency.getCurrencyCode(), accountId);
|
||||||
updateMessages.add(String.format("Updated account currency from %s to %s.", account.getCurrency(), currency));
|
updateMessages.add(String.format("Updated account currency from %s to %s.", account.getCurrency(), currency));
|
||||||
}
|
}
|
||||||
|
if (!Objects.equals(account.getDescription(), description)) {
|
||||||
|
DbUtil.updateOne(conn, "UPDATE account SET description = ? WHERE id = ?", description, accountId);
|
||||||
|
updateMessages.add("Updated account's description.");
|
||||||
|
}
|
||||||
if (!updateMessages.isEmpty()) {
|
if (!updateMessages.isEmpty()) {
|
||||||
var historyRepo = new JdbcHistoryRepository(conn);
|
var historyRepo = new JdbcHistoryRepository(conn);
|
||||||
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
|
||||||
|
@ -241,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
|
||||||
|
@ -272,7 +333,14 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
|
||||||
String accountNumber = rs.getString("account_number");
|
String accountNumber = rs.getString("account_number");
|
||||||
String name = rs.getString("name");
|
String name = rs.getString("name");
|
||||||
Currency currency = Currency.getInstance(rs.getString("currency"));
|
Currency currency = Currency.getInstance(rs.getString("currency"));
|
||||||
return new Account(id, createdAt, archived, type, accountNumber, name, currency);
|
String description = rs.getString("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
|
||||||
|
@ -296,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,12 +5,15 @@ 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;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepository {
|
public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepository {
|
||||||
|
@ -70,13 +73,46 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Pair<TransactionCategory, BigDecimal>> getTransactionAmountByCategoryAndType(TimestampRange range, Currency currency, AccountEntry.Type type) {
|
private List<Pair<TransactionCategory, BigDecimal>> getTransactionAmountByCategoryAndType(TimestampRange range, Currency currency, AccountEntry.Type type) {
|
||||||
return DbUtil.findAll(
|
// First find totals for each category, using only transactions without any line items (should be most).
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> totalsBeforeLineItems = DbUtil.findAll(
|
||||||
conn,
|
conn,
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
|
@ -95,24 +131,81 @@ public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepos
|
||||||
FROM transaction_tag tt
|
FROM transaction_tag tt
|
||||||
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
|
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
|
||||||
WHERE ttj.transaction_id = transaction.id
|
WHERE ttj.transaction_id = transaction.id
|
||||||
|
) AND
|
||||||
|
(
|
||||||
|
SELECT COUNT(tli.id) = 0
|
||||||
|
FROM transaction_line_item tli
|
||||||
|
WHERE tli.transaction_id = transaction.id
|
||||||
)
|
)
|
||||||
GROUP BY tc.id
|
GROUP BY tc.id
|
||||||
ORDER BY total DESC;""",
|
ORDER BY total DESC;""",
|
||||||
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
|
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
|
||||||
rs -> {
|
this::parseAmountAndCategory
|
||||||
BigDecimal total = rs.getBigDecimal(1);
|
|
||||||
TransactionCategory category = null;
|
|
||||||
long categoryId = rs.getLong(2);
|
|
||||||
if (!rs.wasNull()) {
|
|
||||||
Long parentId = rs.getLong(3);
|
|
||||||
if (rs.wasNull()) parentId = null;
|
|
||||||
String name = rs.getString(4);
|
|
||||||
Color color = Color.valueOf("#" + rs.getString(5));
|
|
||||||
category = new TransactionCategory(categoryId, parentId, name, color);
|
|
||||||
}
|
|
||||||
return new Pair<>(category, total);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
// Then augment the data for any transactions which do have line items.
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> totalsFromLineItemsOnly = DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT SUM(tli.value_per_item * tli.quantity) AS s, tc.*
|
||||||
|
FROM transaction_line_item tli
|
||||||
|
LEFT JOIN transaction_category tc ON tc.id = tli.category_id
|
||||||
|
LEFT JOIN transaction t ON t.id = tli.transaction_id
|
||||||
|
LEFT JOIN account_entry ae ON ae.transaction_id = t.id
|
||||||
|
WHERE
|
||||||
|
t.currency = ? AND
|
||||||
|
ae.type = ? AND
|
||||||
|
t.timestamp >= ? AND
|
||||||
|
t.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 = t.id
|
||||||
|
)
|
||||||
|
GROUP BY tli.category_id
|
||||||
|
ORDER BY s DESC""",
|
||||||
|
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
|
||||||
|
this::parseAmountAndCategory
|
||||||
|
);
|
||||||
|
// Finally add data for any remaining value in transactions with line items, which wasn't accounted for in line items.
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> totalsFromLeftoverTransactions = DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT SUM(s), c_id, c_parent_id, c_name, c_color
|
||||||
|
FROM (
|
||||||
|
SELECT transaction.amount - SUM(tli.value_per_item * tli.quantity) AS s,
|
||||||
|
tc.id AS c_id, tc.parent_id AS c_parent_id, tc.name AS c_name, tc.color AS c_color
|
||||||
|
FROM transaction
|
||||||
|
LEFT JOIN transaction_line_item tli ON tli.transaction_id = transaction.id
|
||||||
|
LEFT JOIN transaction_category tc ON tc.id = transaction.category_id
|
||||||
|
LEFT JOIN account_entry ae ON ae.transaction_id = transaction.id
|
||||||
|
WHERE
|
||||||
|
transaction.currency = ? AND
|
||||||
|
ae.type = ? 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(tli.id) > 0
|
||||||
|
FROM transaction_line_item tli
|
||||||
|
WHERE tli.transaction_id = transaction.id
|
||||||
|
)
|
||||||
|
GROUP BY transaction.id
|
||||||
|
)
|
||||||
|
GROUP BY c_id""",
|
||||||
|
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
|
||||||
|
this::parseAmountAndCategory
|
||||||
|
);
|
||||||
|
return combineCategorizedAmounts(List.of(
|
||||||
|
totalsBeforeLineItems,
|
||||||
|
totalsFromLineItemsOnly,
|
||||||
|
totalsFromLeftoverTransactions
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Pair<TransactionCategory, BigDecimal>> groupByRootCategory(List<Pair<TransactionCategory, BigDecimal>> spendByCategory) {
|
private List<Pair<TransactionCategory, BigDecimal>> groupByRootCategory(List<Pair<TransactionCategory, BigDecimal>> spendByCategory) {
|
||||||
|
@ -140,4 +233,39 @@ public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepos
|
||||||
result.sort((p1, p2) -> p2.second().compareTo(p1.second()));
|
result.sort((p1, p2) -> p2.second().compareTo(p1.second()));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Pair<TransactionCategory, BigDecimal> parseAmountAndCategory(ResultSet rs) throws SQLException {
|
||||||
|
BigDecimal amount = rs.getBigDecimal(1);
|
||||||
|
long categoryId = rs.getLong(2);
|
||||||
|
if (rs.wasNull()) {
|
||||||
|
return new Pair<>(null, amount);
|
||||||
|
}
|
||||||
|
Long parentId = rs.getLong(3);
|
||||||
|
if (rs.wasNull()) parentId = null;
|
||||||
|
String name = rs.getString(4);
|
||||||
|
Color color = Color.valueOf("#" + rs.getString(5));
|
||||||
|
return new Pair<>(new TransactionCategory(categoryId, parentId, name, color), amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Pair<TransactionCategory, BigDecimal>> combineCategorizedAmounts(List<List<Pair<TransactionCategory, BigDecimal>>> lists) {
|
||||||
|
BigDecimal uncategorizedAmount = BigDecimal.ZERO;
|
||||||
|
Map<TransactionCategory, BigDecimal> categorizedAmounts = new HashMap<>();
|
||||||
|
for (var list : lists) {
|
||||||
|
for (var p : list) {
|
||||||
|
if (p.first() == null) {
|
||||||
|
uncategorizedAmount = uncategorizedAmount.add(p.second());
|
||||||
|
} else {
|
||||||
|
BigDecimal value = categorizedAmounts.computeIfAbsent(p.first(), category -> BigDecimal.ZERO);
|
||||||
|
categorizedAmounts.put(p.first(), value.add(p.second()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Pair<TransactionCategory, BigDecimal>> amountsByCategory = new ArrayList<>();
|
||||||
|
amountsByCategory.add(new Pair<>(null, uncategorizedAmount));
|
||||||
|
for (var entry : categorizedAmounts.entrySet()) {
|
||||||
|
amountsByCategory.add(new Pair<>(entry.getKey(), entry.getValue()));
|
||||||
|
}
|
||||||
|
amountsByCategory.sort((p1, p2) -> p2.second().compareTo(p1.second()));
|
||||||
|
return amountsByCategory;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"))
|
||||||
);
|
);
|
||||||
|
|
|
@ -59,6 +59,11 @@ public class JdbcDataSource implements DataSource {
|
||||||
return new JdbcTransactionCategoryRepository(getConnection());
|
return new JdbcTransactionCategoryRepository(getConnection());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransactionLineItemRepository getTransactionLineItemRepository() {
|
||||||
|
return new JdbcTransactionLineItemRepository(getConnection());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AttachmentRepository getAttachmentRepository() {
|
public AttachmentRepository getAttachmentRepository() {
|
||||||
return new JdbcAttachmentRepository(getConnection(), contentDir);
|
return new JdbcAttachmentRepository(getConnection(), contentDir);
|
||||||
|
@ -69,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 = 3;
|
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));
|
||||||
|
|
|
@ -119,6 +119,11 @@ public record JdbcTransactionCategoryRepository(Connection conn) implements Tran
|
||||||
return rootNodes;
|
return rootNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CategoryTreeNode findTree(TransactionCategory root) {
|
||||||
|
return findTreeRecursive(root);
|
||||||
|
}
|
||||||
|
|
||||||
private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
|
private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
|
||||||
CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
|
CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
|
||||||
List<TransactionCategory> childCategories = DbUtil.findAll(
|
List<TransactionCategory> childCategories = DbUtil.findAll(
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionLineItemRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record JdbcTransactionLineItemRepository(Connection conn) implements TransactionLineItemRepository {
|
||||||
|
@Override
|
||||||
|
public List<TransactionLineItem> findItems(long transactionId) {
|
||||||
|
return DbUtil.findAll(
|
||||||
|
conn,
|
||||||
|
"SELECT * FROM transaction_line_item WHERE transaction_id = ? ORDER BY idx ASC",
|
||||||
|
List.of(transactionId),
|
||||||
|
JdbcTransactionLineItemRepository::parseItem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items) {
|
||||||
|
// First delete all existing line items since it's just easier that way.
|
||||||
|
DbUtil.update(conn, "DELETE FROM transaction_line_item WHERE transaction_id = ?", transactionId);
|
||||||
|
if (items.isEmpty()) return Collections.emptyList(); // Skip insertion logic if no items are present.
|
||||||
|
String query = """
|
||||||
|
INSERT INTO transaction_line_item (
|
||||||
|
transaction_id,
|
||||||
|
value_per_item,
|
||||||
|
quantity,
|
||||||
|
idx,
|
||||||
|
description,
|
||||||
|
category_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)""";
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
for (int i = 0; i < items.size(); i++) {
|
||||||
|
TransactionLineItem item = items.get(i);
|
||||||
|
stmt.setLong(1, transactionId);
|
||||||
|
stmt.setBigDecimal(2, item.getValuePerItem());
|
||||||
|
stmt.setInt(3, item.getQuantity());
|
||||||
|
stmt.setInt(4, i);
|
||||||
|
stmt.setString(5, item.getDescription());
|
||||||
|
if (item.getCategoryId() == null) {
|
||||||
|
stmt.setNull(6, Types.BIGINT);
|
||||||
|
} else {
|
||||||
|
stmt.setLong(6, item.getCategoryId());
|
||||||
|
}
|
||||||
|
int rowCount = stmt.executeUpdate();
|
||||||
|
if (rowCount != 1) throw new SQLException("Failed to insert line item.");
|
||||||
|
}
|
||||||
|
return findItems(transactionId); // Simply re-fetch items afterward. Their properties may have changed.
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws Exception {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransactionLineItem parseItem(ResultSet rs) throws SQLException {
|
||||||
|
long id = rs.getLong("id");
|
||||||
|
long transactionId = rs.getLong("transaction_id");
|
||||||
|
BigDecimal valuePerItem = rs.getBigDecimal("value_per_item");
|
||||||
|
int quantity = rs.getInt("quantity");
|
||||||
|
int idx = rs.getInt("idx");
|
||||||
|
String description = rs.getString("description");
|
||||||
|
Long categoryId = rs.getLong("category_id");
|
||||||
|
if (rs.wasNull()) categoryId = null;
|
||||||
|
return new TransactionLineItem(id, transactionId, valuePerItem, quantity, idx, description, categoryId);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,6 @@
|
||||||
package com.andrewlalis.perfin.data.impl;
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
import com.andrewlalis.perfin.data.AccountEntryRepository;
|
import com.andrewlalis.perfin.data.*;
|
||||||
import com.andrewlalis.perfin.data.AttachmentRepository;
|
|
||||||
import com.andrewlalis.perfin.data.HistoryRepository;
|
|
||||||
import com.andrewlalis.perfin.data.TransactionRepository;
|
|
||||||
import com.andrewlalis.perfin.data.pagination.Page;
|
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.data.util.CurrencyUtil;
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
|
@ -32,6 +29,7 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
String vendor,
|
String vendor,
|
||||||
String category,
|
String category,
|
||||||
Set<String> tags,
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Path> attachments
|
List<Path> attachments
|
||||||
) {
|
) {
|
||||||
return DbUtil.doTransaction(conn, () -> {
|
return DbUtil.doTransaction(conn, () -> {
|
||||||
|
@ -93,6 +91,10 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
// Add Line Items.
|
||||||
|
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
|
||||||
|
lineItemRepo.saveItems(txId, lineItems);
|
||||||
|
|
||||||
return txId;
|
return txId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -151,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);
|
||||||
|
@ -190,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(
|
||||||
|
@ -297,6 +329,7 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
String vendor,
|
String vendor,
|
||||||
String category,
|
String category,
|
||||||
Set<String> tags,
|
Set<String> tags,
|
||||||
|
List<TransactionLineItem> lineItems,
|
||||||
List<Attachment> existingAttachments,
|
List<Attachment> existingAttachments,
|
||||||
List<Path> newAttachmentPaths
|
List<Path> newAttachmentPaths
|
||||||
) {
|
) {
|
||||||
|
@ -393,6 +426,13 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
|
||||||
insertAttachmentLink(tx.id, attachment.id);
|
insertAttachmentLink(tx.id, attachment.id);
|
||||||
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
|
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
|
||||||
}
|
}
|
||||||
|
// Manage line item changes.
|
||||||
|
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
|
||||||
|
List<TransactionLineItem> existingLineItems = lineItemRepo.findItems(tx.id);
|
||||||
|
if (!existingLineItems.equals(lineItems)) {
|
||||||
|
lineItemRepo.saveItems(tx.id, lineItems);
|
||||||
|
updateMessages.add("Updated line items.");
|
||||||
|
}
|
||||||
|
|
||||||
// Add a text history item to any linked accounts detailing the changes.
|
// Add a text history item to any linked accounts detailing the changes.
|
||||||
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
|
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
|
||||||
|
|
|
@ -18,6 +18,9 @@ public class Migrations {
|
||||||
final Map<Integer, Migration> migrations = new HashMap<>();
|
final Map<Integer, Migration> migrations = new HashMap<>();
|
||||||
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(4, new PlainSQLMigration("/sql/migration/M004_AddBrokerageValueRecords.sql"));
|
||||||
|
migrations.put(5, new PlainSQLMigration("/sql/migration/M005_AddCreditCardLimit.sql"));
|
||||||
return migrations;
|
return migrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
package com.andrewlalis.perfin.data.search;
|
package com.andrewlalis.perfin.data.search;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
import com.andrewlalis.perfin.data.util.DbUtil;
|
import com.andrewlalis.perfin.data.util.DbUtil;
|
||||||
import com.andrewlalis.perfin.model.Transaction;
|
import com.andrewlalis.perfin.model.*;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
|
public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
|
||||||
public JdbcTransactionSearcher(Connection conn) {
|
public JdbcTransactionSearcher(Connection conn) {
|
||||||
|
@ -32,4 +35,184 @@ public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
|
||||||
if (rs.wasNull()) categoryId = null;
|
if (rs.wasNull()) categoryId = null;
|
||||||
return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
|
return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class FilterBuilder {
|
||||||
|
private final List<SearchFilter> filters = new ArrayList<>();
|
||||||
|
private final Set<String> joinTables = new HashSet<>();
|
||||||
|
|
||||||
|
public List<SearchFilter> build() {
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAccounts(Collection<Account> accounts, boolean exclude) {
|
||||||
|
if (accounts.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
addAccountEntryJoin(builder);
|
||||||
|
String idsString = accounts.stream()
|
||||||
|
.map(a -> Long.toString(a.id)).distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "account_entry.account_id", idsString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAccountTypes(Collection<AccountType> types, boolean exclude) {
|
||||||
|
if (types.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
addAccountJoin(builder);
|
||||||
|
String typesString = types.stream()
|
||||||
|
.map(t -> "'" + t.name() + "'").distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "account.account_type", typesString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byCategories(Collection<TransactionCategory> categories, boolean exclude) {
|
||||||
|
if (categories.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
Set<Long> ids = Profile.getCurrent().dataSource().mapRepo(TransactionCategoryRepository.class, repo -> {
|
||||||
|
Set<Long> categoryIds = new HashSet<>();
|
||||||
|
for (var category : categories) {
|
||||||
|
var treeNode = repo.findTree(category);
|
||||||
|
categoryIds.addAll(treeNode.allIds());
|
||||||
|
}
|
||||||
|
return categoryIds;
|
||||||
|
});
|
||||||
|
String idsString = ids.stream()
|
||||||
|
.map(id -> Long.toString(id)).distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "transaction.category_id", idsString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byVendors(Collection<TransactionVendor> vendors, boolean exclude) {
|
||||||
|
if (vendors.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
String idsString = vendors.stream()
|
||||||
|
.map(v -> Long.toString(v.id)).distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "transaction.vendor_id", idsString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byTags(Collection<TransactionTag> tags, boolean exclude) {
|
||||||
|
if (tags.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
addTagJoin(builder);
|
||||||
|
var tagIdsString = tags.stream()
|
||||||
|
.map(t -> Long.toString(t.id)).distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "transaction_tag_join.tag_id", tagIdsString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAmountGreaterThan(BigDecimal amount) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
builder.where("transaction.amount > ?");
|
||||||
|
builder.withArg(Types.NUMERIC, amount);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAmountLessThan(BigDecimal amount) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
builder.where("transaction.amount < ?");
|
||||||
|
builder.withArg(Types.NUMERIC, amount);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byAmountEqualTo(BigDecimal amount) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
builder.where("transaction.amount = ?");
|
||||||
|
builder.withArg(Types.NUMERIC, amount);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byEntryType(AccountEntry.Type type) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
addAccountEntryJoin(builder);
|
||||||
|
builder.where("account_entry.type = ?");
|
||||||
|
builder.withArg(Types.VARCHAR, type.name());
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byHasAttachments(boolean hasAttachments) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
String subQuery = "(SELECT COUNT(attachment_id) FROM transaction_attachment WHERE transaction_id = transaction.id)";
|
||||||
|
if (hasAttachments) {
|
||||||
|
builder.where(subQuery + " > 0");
|
||||||
|
} else {
|
||||||
|
builder.where(subQuery + " = 0");
|
||||||
|
}
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byHasLineItems(boolean hasLineItems) {
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
String subQuery = "(SELECT COUNT(id) FROM transaction_line_item WHERE transaction_id = transaction.id)";
|
||||||
|
if (hasLineItems) {
|
||||||
|
builder.where(subQuery + " > 0");
|
||||||
|
} else {
|
||||||
|
builder.where(subQuery + " = 0");
|
||||||
|
}
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterBuilder byCurrencies(Collection<Currency> currencies, boolean exclude) {
|
||||||
|
if (currencies.isEmpty()) return this;
|
||||||
|
var builder = new SearchFilter.Builder();
|
||||||
|
String currenciesString = currencies.stream()
|
||||||
|
.map(c -> "'" + c.getCurrencyCode() + "'").distinct()
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
addInClause(builder, "transaction.currency", currenciesString, exclude);
|
||||||
|
filters.add(builder.build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addAccountEntryJoin(SearchFilter.Builder builder) {
|
||||||
|
if (!joinTables.contains("account_entry")) {
|
||||||
|
builder.withJoin("LEFT JOIN account_entry ON account_entry.transaction_id = transaction.id");
|
||||||
|
joinTables.add("account_entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addAccountJoin(SearchFilter.Builder builder) {
|
||||||
|
addAccountEntryJoin(builder);
|
||||||
|
if (!joinTables.contains("account")) {
|
||||||
|
builder.withJoin("LEFT JOIN account ON account.id = account_entry.account_id");
|
||||||
|
joinTables.add("account");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCategoryJoin(SearchFilter.Builder builder) {
|
||||||
|
if (!joinTables.contains("transaction_category")) {
|
||||||
|
builder.withJoin("LEFT JOIN transaction_category ON transaction_category.id = transaction.category_id");
|
||||||
|
joinTables.add("transaction_category");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addTagJoin(SearchFilter.Builder builder) {
|
||||||
|
if (!joinTables.contains("transaction_tag_join")) {
|
||||||
|
builder.withJoin("LEFT JOIN transaction_tag_join ON transaction_tag_join.transaction_id = transaction.id");
|
||||||
|
joinTables.add("transaction_tag_join");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addInClause(SearchFilter.Builder builder, String valueExpr, String inExpr, boolean exclude) {
|
||||||
|
if (exclude) {
|
||||||
|
builder.where(valueExpr + " NOT IN (" + inExpr + ")");
|
||||||
|
} else {
|
||||||
|
builder.where(valueExpr + " IN (" + inExpr + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
|
|
||||||
package com.andrewlalis.perfin.data.ulid;
|
package com.andrewlalis.perfin.data.ulid;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
@ -50,6 +51,7 @@ import java.util.concurrent.ThreadLocalRandom;
|
||||||
*/
|
*/
|
||||||
public final class Ulid implements Serializable, Comparable<Ulid> {
|
public final class Ulid implements Serializable, Comparable<Ulid> {
|
||||||
|
|
||||||
|
@Serial
|
||||||
private static final long serialVersionUID = 2625269413446854731L;
|
private static final long serialVersionUID = 2625269413446854731L;
|
||||||
|
|
||||||
private final long msb; // most significant bits
|
private final long msb; // most significant bits
|
||||||
|
@ -209,7 +211,7 @@ public final class Ulid implements Serializable, Comparable<Ulid> {
|
||||||
* pseudo-random generator should use {@link UlidCreator#getUlid()}.
|
* pseudo-random generator should use {@link UlidCreator#getUlid()}.
|
||||||
*
|
*
|
||||||
* @return a ULID
|
* @return a ULID
|
||||||
* @see {@link ThreadLocalRandom}
|
* @see ThreadLocalRandom
|
||||||
* @since 5.1.0
|
* @since 5.1.0
|
||||||
*/
|
*/
|
||||||
public static Ulid fast() {
|
public static Ulid fast() {
|
||||||
|
@ -236,7 +238,7 @@ public final class Ulid implements Serializable, Comparable<Ulid> {
|
||||||
* @since 5.2.0
|
* @since 5.2.0
|
||||||
*/
|
*/
|
||||||
public static Ulid min(long time) {
|
public static Ulid min(long time) {
|
||||||
return new Ulid((time << 16) | 0x0000L, 0x0000000000000000L);
|
return new Ulid((time << 16), 0x0000000000000000L);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -28,7 +28,13 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void setArgs(PreparedStatement stmt, Object... args) {
|
public static void setArgs(PreparedStatement stmt, Object... args) {
|
||||||
setArgs(stmt, List.of(args));
|
for (int i = 0; i < args.length; i++) {
|
||||||
|
try {
|
||||||
|
stmt.setObject(i + 1, args[i]);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException("Failed to set parameter " + (i + 1) + " to " + args[i], e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static long getGeneratedId(PreparedStatement stmt) {
|
public static long getGeneratedId(PreparedStatement stmt) {
|
||||||
|
@ -117,10 +123,21 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void updateOne(Connection conn, String query, Object... args) {
|
public static void updateOne(Connection conn, String query, Object... args) {
|
||||||
updateOne(conn, query, List.of(args));
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
setArgs(stmt, args);
|
||||||
|
int updateCount = stmt.executeUpdate();
|
||||||
|
if (updateCount != 1) throw new UncheckedSqlException("Update count is " + updateCount + "; expected 1.");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static long insertOne(Connection conn, String query, List<Object> args) {
|
public static long insertOne(Connection conn, String query, List<Object> args) {
|
||||||
|
Object[] argsArray = args.toArray();
|
||||||
|
return insertOne(conn, query, argsArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long insertOne(Connection conn, String query, Object... args) {
|
||||||
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
|
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
|
||||||
setArgs(stmt, args);
|
setArgs(stmt, args);
|
||||||
int result = stmt.executeUpdate();
|
int result = stmt.executeUpdate();
|
||||||
|
@ -131,10 +148,6 @@ public final class DbUtil {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static long insertOne(Connection conn, String query, Object... args) {
|
|
||||||
return insertOne(conn, query, List.of(args));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
|
public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
|
||||||
return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
|
return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
|
||||||
}
|
}
|
||||||
|
|
|
@ -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("\"", "\"\"") + '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@ import java.util.Currency;
|
||||||
* credit-card, etc.).
|
* credit-card, etc.).
|
||||||
*/
|
*/
|
||||||
public class Account extends IdEntity {
|
public class Account extends IdEntity {
|
||||||
|
public static final int DESCRIPTION_MAX_LENGTH = 255;
|
||||||
|
|
||||||
private final LocalDateTime createdAt;
|
private final LocalDateTime createdAt;
|
||||||
private final boolean archived;
|
private final boolean archived;
|
||||||
|
|
||||||
|
@ -15,8 +17,9 @@ public class Account extends IdEntity {
|
||||||
private final String accountNumber;
|
private final String accountNumber;
|
||||||
private final String name;
|
private final String name;
|
||||||
private final Currency currency;
|
private final Currency currency;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency) {
|
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency, String description) {
|
||||||
super(id);
|
super(id);
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
this.archived = archived;
|
this.archived = archived;
|
||||||
|
@ -24,6 +27,7 @@ public class Account extends IdEntity {
|
||||||
this.accountNumber = accountNumber;
|
this.accountNumber = accountNumber;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccountType getType() {
|
public AccountType getType() {
|
||||||
|
@ -62,6 +66,10 @@ public class Account extends IdEntity {
|
||||||
return currency;
|
return currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -16,14 +16,16 @@ public class TransactionLineItem extends IdEntity {
|
||||||
private final int quantity;
|
private final int quantity;
|
||||||
private final int idx;
|
private final int idx;
|
||||||
private final String description;
|
private final String description;
|
||||||
|
private final Long categoryId;
|
||||||
|
|
||||||
public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description) {
|
public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description, Long categoryId) {
|
||||||
super(id);
|
super(id);
|
||||||
this.transactionId = transactionId;
|
this.transactionId = transactionId;
|
||||||
this.valuePerItem = valuePerItem;
|
this.valuePerItem = valuePerItem;
|
||||||
this.quantity = quantity;
|
this.quantity = quantity;
|
||||||
this.idx = idx;
|
this.idx = idx;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
|
this.categoryId = categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getTransactionId() {
|
public long getTransactionId() {
|
||||||
|
@ -46,6 +48,10 @@ public class TransactionLineItem extends IdEntity {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
public BigDecimal getTotalValue() {
|
public BigDecimal getTotalValue() {
|
||||||
return valuePerItem.multiply(new BigDecimal(quantity));
|
return valuePerItem.multiply(new BigDecimal(quantity));
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,56 +91,79 @@ 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 (entities.size() < requestedItems) {
|
if (!itemsVBox.getChildren().isEmpty()) {
|
||||||
canLoadMore.set(false);
|
itemsVBox.getChildren().add(new Separator(Orientation.HORIZONTAL));
|
||||||
BorderPane endMarker = new BorderPane(new Label("This is the start of the history."));
|
}
|
||||||
endMarker.getStyleClass().addAll("large-font", "italic-text");
|
itemsVBox.getChildren().addAll(tiles);
|
||||||
itemsVBox.getChildren().add(endMarker);
|
if (entities.size() < requestedItems) {
|
||||||
}
|
canLoadMore.set(false);
|
||||||
if (!entities.isEmpty()) {
|
BorderPane endMarker = new BorderPane(new Label("This is the start of the history."));
|
||||||
lastTimestamp = entities.getLast().getTimestamp();
|
endMarker.getStyleClass().addAll("large-font", "italic-text");
|
||||||
}
|
itemsVBox.getChildren().add(endMarker);
|
||||||
|
}
|
||||||
|
if (!entities.isEmpty()) {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,10 @@ import javafx.scene.shape.Circle;
|
||||||
|
|
||||||
public class CategoryLabel extends HBox {
|
public class CategoryLabel extends HBox {
|
||||||
public CategoryLabel(TransactionCategory category) {
|
public CategoryLabel(TransactionCategory category) {
|
||||||
|
this(category, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CategoryLabel(TransactionCategory category, double indicatorSize) {
|
||||||
Circle colorIndicator = new Circle(8, category.getColor());
|
Circle colorIndicator = new Circle(8, category.getColor());
|
||||||
Label label = new Label(category.getName());
|
Label label = new Label(category.getName());
|
||||||
this.getChildren().addAll(colorIndicator, label);
|
this.getChildren().addAll(colorIndicator, label);
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.andrewlalis.perfin.view.component;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
|
||||||
|
import com.andrewlalis.perfin.data.util.CurrencyUtil;
|
||||||
|
import com.andrewlalis.perfin.model.MoneyValue;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionCategory;
|
||||||
|
import com.andrewlalis.perfin.model.TransactionLineItem;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Currency;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
public class TransactionLineItemTile extends BorderPane {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TransactionLineItemTile.class);
|
||||||
|
|
||||||
|
private TransactionLineItemTile() {}
|
||||||
|
|
||||||
|
public static CompletableFuture<TransactionLineItemTile> build(TransactionLineItem item, ObservableValue<Currency> currencyValue, List<TransactionCategory> categoriesCache) {
|
||||||
|
TransactionLineItemTile tile = new TransactionLineItemTile();
|
||||||
|
tile.getStyleClass().addAll("std-spacing", "std-padding", "small-font");
|
||||||
|
tile.setStyle("-fx-background-color: -fx-theme-background-2;");
|
||||||
|
Function<String, Label> boldLabelMaker = s -> {
|
||||||
|
Label lbl = new Label(s);
|
||||||
|
lbl.getStyleClass().addAll("bold-text");
|
||||||
|
return lbl;
|
||||||
|
};
|
||||||
|
Label descriptionLabel = new Label(item.getDescription());
|
||||||
|
Label valuePerItemLabel = new Label();
|
||||||
|
valuePerItemLabel.getStyleClass().add("mono-font");
|
||||||
|
valuePerItemLabel.textProperty().bind(currencyValue
|
||||||
|
.map(currency -> CurrencyUtil.formatMoney(new MoneyValue(item.getValuePerItem(), currency)))
|
||||||
|
);
|
||||||
|
Label totalValueLabel = new Label();
|
||||||
|
totalValueLabel.getStyleClass().add("mono-font");
|
||||||
|
totalValueLabel.textProperty().bind(currencyValue
|
||||||
|
.map(currency -> CurrencyUtil.formatMoney(new MoneyValue(item.getTotalValue(), currency)))
|
||||||
|
);
|
||||||
|
Label quantityLabel = new Label(Integer.toString(item.getQuantity()));
|
||||||
|
quantityLabel.getStyleClass().add("mono-font");
|
||||||
|
PropertiesPane propertiesPane = new PropertiesPane(80);
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
boldLabelMaker.apply("Description"), descriptionLabel,
|
||||||
|
boldLabelMaker.apply("Quantity"), quantityLabel,
|
||||||
|
boldLabelMaker.apply("Item Value"), valuePerItemLabel,
|
||||||
|
boldLabelMaker.apply("Total"), totalValueLabel
|
||||||
|
);
|
||||||
|
tile.setCenter(propertiesPane);
|
||||||
|
if (item.getCategoryId() != null) {
|
||||||
|
if (categoriesCache != null) {
|
||||||
|
TransactionCategory category = categoriesCache.stream()
|
||||||
|
.filter(c -> c.id == item.getCategoryId())
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
if (category == null) {
|
||||||
|
log.warn("Failed to find cached category for line item.");
|
||||||
|
} else {
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
boldLabelMaker.apply("Category"), new CategoryLabel(category, 5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(tile);
|
||||||
|
} else {
|
||||||
|
CompletableFuture<TransactionLineItemTile> cf = new CompletableFuture<>();
|
||||||
|
Profile.getCurrent().dataSource().mapRepoAsync(
|
||||||
|
TransactionCategoryRepository.class,
|
||||||
|
repo -> repo.findById(item.getCategoryId()).orElse(null)
|
||||||
|
).thenAccept(category -> Platform.runLater(() -> {
|
||||||
|
propertiesPane.getChildren().addAll(
|
||||||
|
boldLabelMaker.apply("Category"), new CategoryLabel(category, 5)
|
||||||
|
);
|
||||||
|
cf.complete(tile);
|
||||||
|
}));
|
||||||
|
return cf;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return CompletableFuture.completedFuture(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
|
@ -5,7 +5,6 @@
|
||||||
<?import javafx.scene.control.*?>
|
<?import javafx.scene.control.*?>
|
||||||
<?import javafx.scene.layout.*?>
|
<?import javafx.scene.layout.*?>
|
||||||
<?import javafx.scene.text.Text?>
|
<?import javafx.scene.text.Text?>
|
||||||
<?import com.andrewlalis.perfin.view.component.StyledText?>
|
|
||||||
<?import javafx.scene.text.TextFlow?>
|
<?import javafx.scene.text.TextFlow?>
|
||||||
<BorderPane
|
<BorderPane
|
||||||
xmlns="http://javafx.com/javafx"
|
xmlns="http://javafx.com/javafx"
|
||||||
|
@ -17,37 +16,54 @@
|
||||||
</top>
|
</top>
|
||||||
<center>
|
<center>
|
||||||
<VBox>
|
<VBox>
|
||||||
<!-- Main account properties and actions -->
|
<!-- Main account properties. -->
|
||||||
<FlowPane styleClass="std-padding,std-spacing">
|
<PropertiesPane vgap="5" hgap="5" styleClass="std-padding,std-spacing">
|
||||||
<!-- Main account properties. -->
|
<Label text="Name" styleClass="bold-text"/>
|
||||||
<PropertiesPane vgap="5" hgap="5">
|
<Label fx:id="accountNameLabel"/>
|
||||||
<Label text="Name" styleClass="bold-text"/>
|
|
||||||
<Label fx:id="accountNameLabel"/>
|
|
||||||
|
|
||||||
<Label text="Number" styleClass="bold-text"/>
|
<Label text="Number" styleClass="bold-text"/>
|
||||||
<Label fx:id="accountNumberLabel" styleClass="mono-font"/>
|
<Label fx:id="accountNumberLabel" styleClass="mono-font"/>
|
||||||
|
|
||||||
<Label text="Currency" styleClass="bold-text"/>
|
<Label text="Currency" styleClass="bold-text"/>
|
||||||
<Label fx:id="accountCurrencyLabel"/>
|
<Label fx:id="accountCurrencyLabel"/>
|
||||||
|
|
||||||
<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"/>
|
||||||
|
|
||||||
<VBox>
|
<Label text="Current Balance" styleClass="bold-text"/>
|
||||||
<Label text="Current Balance" styleClass="bold-text" fx:id="balanceLabel"/>
|
<VBox>
|
||||||
<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"/>
|
||||||
</PropertiesPane>
|
<Label
|
||||||
</FlowPane>
|
styleClass="small-font,secondary-color-fill"
|
||||||
|
>Derived using nearest recorded balance and transactions.</Label>
|
||||||
|
</VBox>
|
||||||
|
</PropertiesPane>
|
||||||
|
|
||||||
|
<PropertiesPane vgap="5" hgap="5" fx:id="assetValuePane" styleClass="std-padding,std-spacing">
|
||||||
|
<Label text="Latest Assets Value" styleClass="bold-text" labelFor="${latestAssetsValueLabel}"/>
|
||||||
|
<VBox>
|
||||||
|
<Label fx:id="latestAssetsValueLabel" styleClass="mono-font"/>
|
||||||
|
<Label
|
||||||
|
styleClass="small-font,secondary-color-fill"
|
||||||
|
>Derived using nearest recorded asset value.</Label>
|
||||||
|
</VBox>
|
||||||
|
</PropertiesPane>
|
||||||
|
|
||||||
|
<PropertiesPane vgap="5" hgap="5" fx:id="creditCardPropertiesPane" styleClass="std-padding,std-spacing">
|
||||||
|
<Label text="Credit Limit" styleClass="bold-text" labelFor="${creditLimitLabel}"/>
|
||||||
|
<Label fx:id="creditLimitLabel" styleClass="mono-font"/>
|
||||||
|
</PropertiesPane>
|
||||||
|
|
||||||
|
<PropertiesPane vgap="5" hgap="5" fx:id="descriptionPane" styleClass="std-padding,std-spacing">
|
||||||
|
<Label text="Description" styleClass="bold-text" labelFor="${accountDescriptionText}"/>
|
||||||
|
<TextFlow maxWidth="500"><Text fx:id="accountDescriptionText"/></TextFlow>
|
||||||
|
</PropertiesPane>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<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"/>
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,16 @@
|
||||||
|
|
||||||
<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"/>
|
||||||
|
<TextArea
|
||||||
|
fx:id="descriptionField"
|
||||||
|
wrapText="true"
|
||||||
|
style="-fx-pref-height: 100px;-fx-min-height: 100px;"
|
||||||
|
/>
|
||||||
</PropertiesPane>
|
</PropertiesPane>
|
||||||
|
|
||||||
<!-- Initial balance content that's only visible when creating a new account. -->
|
<!-- Initial balance content that's only visible when creating a new account. -->
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -85,38 +81,47 @@
|
||||||
</HBox>
|
</HBox>
|
||||||
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
|
<VBox fx:id="tagsVBox" styleClass="std-spacing,std-padding"/>
|
||||||
</VBox>
|
</VBox>
|
||||||
|
|
||||||
<Label text="Line Items" styleClass="bold-text"/>
|
|
||||||
<VBox maxWidth="Infinity">
|
|
||||||
<Button text="Add Line Item" fx:id="addLineItemButton" disable="true"/>
|
|
||||||
<StyledText styleClass="small-font">
|
|
||||||
Line items aren't yet supported. I'm working on it!
|
|
||||||
</StyledText>
|
|
||||||
<VBox styleClass="std-spacing" fx:id="addLineItemForm">
|
|
||||||
<HBox styleClass="std-spacing">
|
|
||||||
<VBox>
|
|
||||||
<Label text="Quantity" styleClass="bold-text,small-font"/>
|
|
||||||
<Spinner fx:id="lineItemQuantitySpinner" minWidth="60" maxWidth="60"/>
|
|
||||||
</VBox>
|
|
||||||
<VBox HBox.hgrow="ALWAYS">
|
|
||||||
<Label text="Value per Item" styleClass="bold-text,small-font"/>
|
|
||||||
<TextField fx:id="lineItemValueField"/>
|
|
||||||
</VBox>
|
|
||||||
</HBox>
|
|
||||||
<VBox>
|
|
||||||
<Label text="Description" styleClass="bold-text,small-font"/>
|
|
||||||
<TextField fx:id="lineItemDescriptionField"/>
|
|
||||||
</VBox>
|
|
||||||
<HBox styleClass="std-spacing" alignment="CENTER_RIGHT">
|
|
||||||
<Button text="Add" fx:id="addLineItemAddButton"/>
|
|
||||||
<Button text="Cancel" fx:id="addLineItemCancelButton"/>
|
|
||||||
</HBox>
|
|
||||||
</VBox>
|
|
||||||
|
|
||||||
<VBox fx:id="lineItemsVBox"/>
|
|
||||||
</VBox>
|
|
||||||
</PropertiesPane>
|
</PropertiesPane>
|
||||||
|
|
||||||
|
<!-- Container for line items -->
|
||||||
|
<VBox styleClass="std-padding">
|
||||||
|
<Label text="Line Items" styleClass="bold-text"/>
|
||||||
|
<Button text="Add Line Item" fx:id="addLineItemButton"/>
|
||||||
|
<VBox styleClass="std-spacing" fx:id="addLineItemForm">
|
||||||
|
<HBox styleClass="std-spacing">
|
||||||
|
<VBox>
|
||||||
|
<Label text="Quantity" styleClass="bold-text,small-font"/>
|
||||||
|
<Spinner fx:id="lineItemQuantitySpinner" minWidth="60" maxWidth="60"/>
|
||||||
|
</VBox>
|
||||||
|
<VBox HBox.hgrow="ALWAYS">
|
||||||
|
<Label text="Value per Item" styleClass="bold-text,small-font"/>
|
||||||
|
<TextField fx:id="lineItemValueField"/>
|
||||||
|
</VBox>
|
||||||
|
</HBox>
|
||||||
|
<VBox>
|
||||||
|
<Label text="Description" styleClass="bold-text,small-font"/>
|
||||||
|
<TextField fx:id="lineItemDescriptionField"/>
|
||||||
|
</VBox>
|
||||||
|
<VBox>
|
||||||
|
<Label text="Category" styleClass="bold-text,small-font"/>
|
||||||
|
<CategorySelectionBox fx:id="lineItemCategoryComboBox" maxWidth="Infinity"/>
|
||||||
|
</VBox>
|
||||||
|
<HBox styleClass="std-spacing" alignment="CENTER_RIGHT">
|
||||||
|
<Button text="Add" fx:id="addLineItemAddButton"/>
|
||||||
|
<Button text="Cancel" fx:id="addLineItemCancelButton"/>
|
||||||
|
</HBox>
|
||||||
|
</VBox>
|
||||||
|
|
||||||
|
<VBox fx:id="lineItemsVBox" styleClass="std-padding, std-spacing"/>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
fx:id="lineItemsValueMatchLabel"
|
||||||
|
text="Total value of line items equals the transaction amount."
|
||||||
|
styleClass="positive-color-text-fill"
|
||||||
|
/>
|
||||||
|
<Button fx:id="lineItemsAmountSyncButton" text="Set transaction amount to line items total" styleClass="small-font"/>
|
||||||
|
</VBox>
|
||||||
|
|
||||||
<!-- Container for attachments -->
|
<!-- Container for attachments -->
|
||||||
<VBox styleClass="std-padding">
|
<VBox styleClass="std-padding">
|
||||||
<Label text="Attachments" styleClass="bold-text"/>
|
<Label text="Attachments" styleClass="bold-text"/>
|
||||||
|
|
|
@ -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;">
|
||||||
<columnConstraints>
|
<!-- Basic properties -->
|
||||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||||
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
<columnConstraints>
|
||||||
</columnConstraints>
|
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||||
|
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||||
|
</columnConstraints>
|
||||||
|
|
||||||
<Label text="Name" labelFor="${nameField}"/>
|
<Label text="Name" labelFor="${nameField}" styleClass="bold-text"/>
|
||||||
<TextField fx:id="nameField"/>
|
<TextField fx:id="nameField"/>
|
||||||
|
|
||||||
<Label text="Description" labelFor="${descriptionField}"/>
|
<Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
|
||||||
<TextArea fx:id="descriptionField" wrapText="true"/>
|
<TextArea
|
||||||
</PropertiesPane>
|
fx:id="descriptionField"
|
||||||
<Separator/>
|
wrapText="true"
|
||||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
|
style="-fx-pref-height: 100px; -fx-min-height: 100px;"
|
||||||
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
/>
|
||||||
<Button text="Cancel" onAction="#cancel"/>
|
</PropertiesPane>
|
||||||
</HBox>
|
|
||||||
</VBox>
|
<!-- Some stats about the vendor -->
|
||||||
|
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||||
|
<columnConstraints>
|
||||||
|
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||||
|
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||||
|
</columnConstraints>
|
||||||
|
|
||||||
|
<Label text="Total Spent" labelFor="${totalSpentField}" styleClass="bold-text"/>
|
||||||
|
<Label fx:id="totalSpentField" styleClass="mono-font"/>
|
||||||
|
</PropertiesPane>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<Separator/>
|
||||||
|
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
|
||||||
|
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
||||||
|
<Button text="Cancel" onAction="#cancel"/>
|
||||||
|
</HBox>
|
||||||
|
</VBox>
|
||||||
|
</ScrollPane>
|
||||||
</center>
|
</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.8.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,22 +37,27 @@
|
||||||
</ScrollPane>
|
</ScrollPane>
|
||||||
</center>
|
</center>
|
||||||
<bottom>
|
<bottom>
|
||||||
<BorderPane>
|
<VBox>
|
||||||
<left>
|
<BorderPane>
|
||||||
<AnchorPane styleClass="std-padding">
|
<left>
|
||||||
<Label text="Add New Profile" styleClass="bold-text" AnchorPane.leftAnchor="0" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
|
<AnchorPane styleClass="std-padding">
|
||||||
</AnchorPane>
|
<Label text="Add New Profile" styleClass="bold-text" AnchorPane.leftAnchor="0" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
|
||||||
</left>
|
</AnchorPane>
|
||||||
<center>
|
</left>
|
||||||
<VBox styleClass="std-padding">
|
<center>
|
||||||
<TextField fx:id="newProfileNameField" style="-fx-min-width: 50px; -fx-pref-width: 50px;"/>
|
<VBox styleClass="std-padding">
|
||||||
</VBox>
|
<TextField fx:id="newProfileNameField" style="-fx-min-width: 50px; -fx-pref-width: 50px;"/>
|
||||||
</center>
|
</VBox>
|
||||||
<right>
|
</center>
|
||||||
<VBox styleClass="std-padding">
|
<right>
|
||||||
<Button text="Add" onAction="#addProfile" fx:id="addProfileButton"/>
|
<VBox styleClass="std-padding">
|
||||||
</VBox>
|
<Button text="Add" onAction="#addProfile" fx:id="addProfileButton"/>
|
||||||
</right>
|
</VBox>
|
||||||
</BorderPane>
|
</right>
|
||||||
|
</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,18 @@
|
||||||
|
/*
|
||||||
|
This migration adds a few things:
|
||||||
|
- A `description` column to the account table, so people can add extra notes and
|
||||||
|
content to their accounts that isn't otherwise captured by the other fields.
|
||||||
|
- A `category_id` is added to transaction line items, so that each line item can
|
||||||
|
individually be marked with a category, so that you can further differentiate
|
||||||
|
large purchases consisting of smaller items.
|
||||||
|
*/
|
||||||
|
ALTER TABLE account
|
||||||
|
ADD COLUMN description VARCHAR(255) DEFAULT NULL AFTER currency;
|
||||||
|
|
||||||
|
ALTER TABLE transaction_line_item
|
||||||
|
ADD COLUMN category_id BIGINT DEFAULT NULL AFTER description;
|
||||||
|
|
||||||
|
ALTER TABLE transaction_line_item
|
||||||
|
ADD CONSTRAINT fk_transaction_line_item_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE;
|
|
@ -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,
|
||||||
|
@ -5,7 +13,16 @@ CREATE TABLE account (
|
||||||
account_type VARCHAR(31) NOT NULL,
|
account_type VARCHAR(31) NOT NULL,
|
||||||
account_number VARCHAR(255) NOT NULL UNIQUE,
|
account_number VARCHAR(255) NOT NULL UNIQUE,
|
||||||
name VARCHAR(63) NOT NULL,
|
name VARCHAR(63) NOT NULL,
|
||||||
currency VARCHAR(3) NOT NULL
|
currency VARCHAR(3) NOT 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 (
|
||||||
|
@ -102,9 +119,13 @@ CREATE TABLE transaction_line_item (
|
||||||
quantity INT NOT NULL DEFAULT 1,
|
quantity INT NOT NULL DEFAULT 1,
|
||||||
idx INT NOT NULL DEFAULT 0,
|
idx INT NOT NULL DEFAULT 0,
|
||||||
description VARCHAR(255) NOT NULL,
|
description VARCHAR(255) NOT NULL,
|
||||||
|
category_id BIGINT DEFAULT NULL,
|
||||||
CONSTRAINT fk_transaction_line_item_transaction
|
CONSTRAINT fk_transaction_line_item_transaction
|
||||||
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_transaction_line_item_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
CONSTRAINT ck_transaction_line_item_quantity_positive
|
CONSTRAINT ck_transaction_line_item_quantity_positive
|
||||||
CHECK quantity > 0
|
CHECK quantity > 0
|
||||||
);
|
);
|
||||||
|
@ -115,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
|
||||||
|
@ -134,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
|
||||||
);
|
);
|
||||||
|
|
|
@ -74,6 +74,12 @@
|
||||||
<Hyperlink fx:id="creditAccountLink"/>
|
<Hyperlink fx:id="creditAccountLink"/>
|
||||||
</TextFlow>
|
</TextFlow>
|
||||||
</VBox>
|
</VBox>
|
||||||
|
|
||||||
|
<VBox styleClass="std-spacing">
|
||||||
|
<Label text="Line Items" styleClass="bold-text"/>
|
||||||
|
<VBox fx:id="lineItemsVBox" styleClass="std-spacing"/>
|
||||||
|
</VBox>
|
||||||
|
|
||||||
<AttachmentsViewPane fx:id="attachmentsViewPane"/>
|
<AttachmentsViewPane fx:id="attachmentsViewPane"/>
|
||||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
|
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_LEFT">
|
||||||
<Button text="Edit" onAction="#editTransaction"/>
|
<Button text="Edit" onAction="#editTransaction"/>
|
||||||
|
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,7 +35,8 @@ public class AccountTest {
|
||||||
AccountType.CHECKING,
|
AccountType.CHECKING,
|
||||||
num,
|
num,
|
||||||
"Testing Account",
|
"Testing Account",
|
||||||
Currency.getInstance("USD")
|
Currency.getInstance("USD"),
|
||||||
|
"Description"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue