Compare commits

..

34 Commits

Author SHA1 Message Date
Andrew Lalis 86ee9f8187 Added sample profile generator. 2024-07-21 20:25:08 -04:00
Andrew Lalis 36c29e0d06 Added function to clear search field when navigating to transactions view, if a selected transaction id is given. 2024-07-21 13:17:58 -04:00
Andrew Lalis b91b0a8263 Set version to 1.19.0 2024-07-20 18:38:15 -04:00
Andrew Lalis 6b63b777cf Added tests! 2024-07-20 18:35:17 -04:00
Andrew Lalis 71cc5b1612 Added more documentation to PerfinApp.java, and added JSON export. 2024-07-20 17:55:37 -04:00
Andrew Lalis 6d720b9645 Set version to 1.18.0 2024-07-20 14:58:11 -04:00
Andrew Lalis 408d5e415d Added export to file button with CSV support. 2024-07-20 14:57:34 -04:00
Andrew Lalis 3908515ca4 Set version to 1.17.0 2024-07-12 22:10:54 -04:00
Andrew Lalis b74119a233 Added credit limit and general purpose credit card properties to only credit card accounts. 2024-07-12 22:08:09 -04:00
Andrew Lalis 2abbd6ca43 Set version to 1.16.0 2024-07-10 17:06:20 -04:00
Andrew Lalis f23d2c85a9 Added updates to use and show asset value of brokerage accounts. 2024-07-10 17:05:02 -04:00
Andrew Lalis ec6bc83353 Added saved queries to the SQL Console page. 2024-07-10 14:35:59 -04:00
Andrew Lalis feda2e1897 Add balance record "type" attribute, for cash and assets. 2024-07-10 08:45:30 -04:00
Andrew Lalis d4bd5cc6ec Add help page for the SQL console. 2024-07-09 20:04:11 -04:00
Andrew Lalis 83e9043057 Cleaned up. 2024-07-09 19:51:10 -04:00
Andrew Lalis ea94f09702 Added SQL Console View. 2024-07-09 19:50:41 -04:00
Andrew Lalis 411f384775 Removed most fancy search features for now while I add an SQL console. 2024-07-09 18:40:58 -04:00
Andrew Lalis 72d624afdc Refactored searches into a tab thing, not sure whether I like it though. 2024-07-09 13:39:41 -04:00
Andrew Lalis 2dbb3d944d Add experimental "Advanced Search" features, may incorporate into the main search interface yet... 2024-07-08 22:52:45 -04:00
Andrew Lalis a88ebc8e13 Set version to 1.15.0 2024-06-09 20:13:06 -04:00
Andrew Lalis d360de5d6f Changed time interval cutoff. 2024-06-09 20:10:27 -04:00
Andrew Lalis 6e862a2709 Improved TotalAssetsGraphModule with currency and time range choices. 2024-06-09 14:10:17 -04:00
Andrew Lalis b6fef8d42f Added basic implementation of a total assets graph. 2024-06-07 09:41:28 -04:00
Andrew Lalis e08c528b71 Cleanup 2024-06-07 08:55:49 -04:00
Andrew Lalis 28002fd32d Hide currency choice box when there's only one currency in use for the profile. 2024-06-07 08:55:31 -04:00
Andrew Lalis a3558b33e6 Set version to 1.14.0 2024-06-03 20:43:43 -04:00
Andrew Lalis 5ce2360f05 Added transaction descriptions to account history tiles. 2024-06-03 20:42:58 -04:00
Andrew Lalis 4cf95dba85 Set version to 1.13.0 2024-05-31 13:10:02 -04:00
Andrew Lalis e6d5b280aa Cleanup 2024-05-31 13:08:52 -04:00
Andrew Lalis 1898783c56 Added total spent amount to vendor page, and added analytics method to get total vendor spend. 2024-05-31 13:08:29 -04:00
Andrew Lalis 77f2966291 Added total data refresh on account page when navigating to it, and added additional sorts for the transactions search so order is more consistent. 2024-05-31 12:35:35 -04:00
Andrew Lalis 20eed2108f Set version to 1.12.1, and fixed false positive with duplicate detection when editing existing transactions. 2024-05-30 16:04:54 -04:00
Andrew Lalis e4783e5a47 Added check for duplicate transactions, id-exact-match search for transactions, and some cleanup. Updated to version 1.12.0. 2024-05-30 15:51:00 -04:00
Andrew Lalis a13c9c22df Removed GitHub-specific information. 2024-05-30 11:43:49 -04:00
63 changed files with 1809 additions and 251 deletions

View File

@ -1,8 +1,5 @@
# Perfin # Perfin
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/andrewlalis/perfin/run-tests.yaml?style=flat-square&logo=github)
![GitHub release (with filter)](https://img.shields.io/github/v/release/andrewlalis/perfin?style=flat-square)
A personal accounting desktop app to track your finances using an approachable A personal accounting desktop app to track your finances using an approachable
interface and interoperable file formats for maximum compatibility. interface and interoperable file formats for maximum compatibility.

View File

@ -6,7 +6,7 @@
<groupId>com.andrewlalis</groupId> <groupId>com.andrewlalis</groupId>
<artifactId>perfin</artifactId> <artifactId>perfin</artifactId>
<version>1.11.0</version> <version>1.19.0</version>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>

View File

@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar"
jpackage \ jpackage \
--name "Perfin" \ --name "Perfin" \
--app-version "1.11.0" \ --app-version "1.19.0" \
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \ --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \
--icon design/perfin-logo_256.png \ --icon design/perfin-logo_256.png \
--vendor "Andrew Lalis" \ --vendor "Andrew Lalis" \

View File

@ -12,7 +12,7 @@ $modulePath = "$modulePath;target\modules\h2-2.2.224.jar"
jpackage ` jpackage `
--name "Perfin" ` --name "Perfin" `
--app-version "1.11.0" ` --app-version "1.19.0" `
--description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." ` --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." `
--icon design\perfin-logo_256.ico ` --icon design\perfin-logo_256.ico `
--vendor "Andrew Lalis" ` --vendor "Andrew Lalis" `

View File

@ -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",

View File

@ -4,9 +4,7 @@ import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AccountHistoryView; import com.andrewlalis.perfin.view.component.AccountHistoryView;
import com.andrewlalis.perfin.view.component.PropertiesPane; import com.andrewlalis.perfin.view.component.PropertiesPane;
@ -14,7 +12,10 @@ import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.*; import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Button; import javafx.scene.control.Button;
@ -23,7 +24,10 @@ import javafx.scene.control.Label;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.text.Text; import javafx.scene.text.Text;
import java.time.*; import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
@ -31,6 +35,8 @@ public class AccountViewController implements RouteSelectionListener {
private final ObjectProperty<Account> accountProperty = new SimpleObjectProperty<>(null); private final ObjectProperty<Account> accountProperty = new SimpleObjectProperty<>(null);
private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived()); private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived());
private final StringProperty balanceTextProperty = new SimpleStringProperty(null); private final StringProperty balanceTextProperty = new SimpleStringProperty(null);
private final StringProperty assetValueTextProperty = new SimpleStringProperty(null);
private final StringProperty creditLimitTextProperty = new SimpleStringProperty(null);
@FXML public Label titleLabel; @FXML public Label titleLabel;
@FXML public Label accountNameLabel; @FXML public Label accountNameLabel;
@ -38,6 +44,10 @@ public class AccountViewController implements RouteSelectionListener {
@FXML public Label accountCurrencyLabel; @FXML public Label accountCurrencyLabel;
@FXML public Label accountCreatedAtLabel; @FXML public Label accountCreatedAtLabel;
@FXML public Label accountBalanceLabel; @FXML public Label accountBalanceLabel;
@FXML public PropertiesPane assetValuePane;
@FXML public Label latestAssetsValueLabel;
@FXML public PropertiesPane creditCardPropertiesPane;
@FXML public Label creditLimitLabel;
@FXML public PropertiesPane descriptionPane; @FXML public PropertiesPane descriptionPane;
@FXML public Text accountDescriptionText; @FXML public Text accountDescriptionText;
@ -59,13 +69,27 @@ public class AccountViewController implements RouteSelectionListener {
BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription); BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription);
accountBalanceLabel.textProperty().bind(balanceTextProperty); accountBalanceLabel.textProperty().bind(balanceTextProperty);
var isBrokerageAccount = accountProperty.map(a -> a.getType() == AccountType.BROKERAGE);
BindingUtil.bindManagedAndVisible(assetValuePane, isBrokerageAccount);
latestAssetsValueLabel.textProperty().bind(assetValueTextProperty);
var isCreditCardAccount = accountProperty.map(a -> a.getType() == AccountType.CREDIT_CARD);
BindingUtil.bindManagedAndVisible(creditCardPropertiesPane, isCreditCardAccount);
creditLimitLabel.textProperty().bind(creditLimitTextProperty);
actionsBox.getChildren().forEach(node -> { actionsBox.getChildren().forEach(node -> {
Button button = (Button) node; Button button = (Button) node;
ObservableValue<Boolean> buttonActive = accountArchived; ObservableValue<Boolean> buttonDisabled = accountArchived;
if (button.getText().equalsIgnoreCase("Unarchive")) { if (button.getText().equalsIgnoreCase("Unarchive")) {
buttonActive = BooleanExpression.booleanExpression(buttonActive).not(); buttonDisabled = BooleanExpression.booleanExpression(buttonDisabled).not();
} }
button.disableProperty().bind(buttonActive); if (button.getText().equalsIgnoreCase("Record Asset Value")) {
buttonDisabled = BooleanExpression.booleanExpression(
accountProperty.map(Account::getType)
.map(t -> !t.equals(AccountType.BROKERAGE))
).or(BooleanExpression.booleanExpression(accountArchived));
}
button.disableProperty().bind(buttonDisabled);
button.managedProperty().bind(button.visibleProperty()); button.managedProperty().bind(button.visibleProperty());
button.visibleProperty().bind(button.disableProperty().not()); button.visibleProperty().bind(button.disableProperty().not());
}); });
@ -81,7 +105,7 @@ public class AccountViewController implements RouteSelectionListener {
.toInstant(); .toInstant();
Profile.getCurrent().dataSource().mapRepoAsync( Profile.getCurrent().dataSource().mapRepoAsync(
AccountRepository.class, AccountRepository.class,
repo -> repo.deriveBalance(getAccount().id, timestamp) repo -> repo.deriveCashBalance(getAccount().id, timestamp)
).thenAccept(balance -> Platform.runLater(() -> { ).thenAccept(balance -> Platform.runLater(() -> {
String msg = String.format( String msg = String.format(
"Your balance as of %s is %s, according to Perfin's data.", "Your balance as of %s is %s, according to Perfin's data.",
@ -91,23 +115,43 @@ public class AccountViewController implements RouteSelectionListener {
Popups.message(balanceCheckerButton, msg); Popups.message(balanceCheckerButton, msg);
})); }));
}); });
accountProperty.addListener((observable, oldValue, newValue) -> {
accountHistory.clear();
if (newValue == null) {
balanceTextProperty.set(null);
} else {
accountHistory.setAccountId(newValue.id);
accountHistory.loadMoreHistory();
Profile.getCurrent().dataSource().getAccountBalanceText(newValue)
.thenAccept(s -> Platform.runLater(() -> balanceTextProperty.set(s)));
}
});
} }
@Override @Override
public void onRouteSelected(Object context) { public void onRouteSelected(Object context) {
this.accountProperty.set((Account) context); accountHistory.clear();
balanceTextProperty.set(null);
assetValueTextProperty.set(null);
if (context instanceof Account account) {
this.accountProperty.set(account);
accountHistory.setAccountId(account.id);
accountHistory.loadMoreHistory();
Profile.getCurrent().dataSource().getAccountBalanceText(account)
.thenAccept(s -> Platform.runLater(() -> balanceTextProperty.set(s)));
if (account.getType() == AccountType.BROKERAGE) {
Profile.getCurrent().dataSource().mapRepoAsync(
AccountRepository.class,
repo -> repo.getNearestAssetValue(account.id)
).thenApply(value -> CurrencyUtil.formatMoney(new MoneyValue(value, account.getCurrency())))
.thenAccept(text -> Platform.runLater(() -> assetValueTextProperty.set(text)));
} else if (account.getType() == AccountType.CREDIT_CARD) {
Profile.getCurrent().dataSource().mapRepoAsync(
AccountRepository.class,
repo -> repo.getCreditCardProperties(account.id)
).thenAccept(props -> Platform.runLater(() -> {
if (props == null) {
creditLimitTextProperty.set("No credit card info.");
return;
}
if (props.creditLimit() == null) {
creditLimitTextProperty.set("No credit limit set.");
} else {
MoneyValue money = new MoneyValue(props.creditLimit(), account.getCurrency());
creditLimitTextProperty.set(CurrencyUtil.formatMoney(money));
}
}));
}
}
} }
@FXML @FXML
@ -116,7 +160,11 @@ public class AccountViewController implements RouteSelectionListener {
} }
@FXML public void goToCreateBalanceRecord() { @FXML public void goToCreateBalanceRecord() {
router.navigate("create-balance-record", getAccount()); router.navigate("create-balance-record", new CreateBalanceRecordController.RouteContext(getAccount(), BalanceRecordType.CASH));
}
@FXML public void goToCreateAssetRecord() {
router.navigate("create-balance-record", new CreateBalanceRecordController.RouteContext(getAccount(), BalanceRecordType.ASSETS));
} }
@FXML @FXML

View File

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

View File

@ -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(

View File

@ -2,6 +2,7 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.module.TotalAssetsGraphModule;
import com.andrewlalis.perfin.view.component.module.*; import com.andrewlalis.perfin.view.component.module.*;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.geometry.Bounds; import javafx.geometry.Bounds;
@ -30,7 +31,10 @@ public class DashboardController implements RouteSelectionListener {
var m4 = new VendorSpendChartModule(modulesFlowPane); var m4 = new VendorSpendChartModule(modulesFlowPane);
m4.columnsProperty.set(2); m4.columnsProperty.set(2);
modulesFlowPane.getChildren().addAll(accountsModule, transactionsModule, m3, m4); var m5 = new TotalAssetsGraphModule(modulesFlowPane);
m5.columnsProperty.set(1);
modulesFlowPane.getChildren().addAll(accountsModule, transactionsModule, m3, m4, m5);
} }
@Override @Override

View File

@ -1,15 +1,15 @@
package com.andrewlalis.perfin.control; package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.model.AccountType; import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.PropertiesPane; import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleBooleanProperty;
@ -38,6 +38,7 @@ public class EditAccountController implements RouteSelectionListener {
@FXML public TextField accountNumberField; @FXML public TextField accountNumberField;
@FXML public ComboBox<Currency> accountCurrencyComboBox; @FXML public ComboBox<Currency> accountCurrencyComboBox;
@FXML public ChoiceBox<AccountType> accountTypeChoiceBox; @FXML public ChoiceBox<AccountType> accountTypeChoiceBox;
@FXML public TextField creditLimitField;
@FXML public TextArea descriptionField; @FXML public TextArea descriptionField;
@FXML public PropertiesPane initialBalanceContent; @FXML public PropertiesPane initialBalanceContent;
@FXML public TextField initialBalanceField; @FXML public TextField initialBalanceField;
@ -60,12 +61,25 @@ public class EditAccountController implements RouteSelectionListener {
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false) new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false)
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty()); ).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty());
var isEditingCreditCardAccount = accountTypeChoiceBox.valueProperty().isEqualTo(AccountType.CREDIT_CARD);
BindingUtil.bindManagedAndVisible(creditLimitField, isEditingCreditCardAccount);
var creditLimitValid = new ValidationApplier<>(new CurrencyAmountValidator(
() -> accountCurrencyComboBox.getValue(),
false,
true
)).validatedInitially().attachToTextField(creditLimitField)
.or(isEditingCreditCardAccount.not());
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>() var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.") .addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.")
).attach(descriptionField, descriptionField.textProperty()); ).attach(descriptionField, descriptionField.textProperty());
// Combine validity of all fields for an expression that determines if the whole form is valid. // Combine validity of all fields for an expression that determines if the whole form is valid.
BooleanExpression formValid = nameValid.and(numberValid).and(balanceValid.or(creatingNewAccount.not())).and(descriptionValid); BooleanExpression formValid = nameValid
.and(numberValid)
.and(balanceValid.or(creatingNewAccount.not()))
.and(descriptionValid)
.and(creditLimitValid);
saveButton.disableProperty().bind(formValid.not()); saveButton.disableProperty().bind(formValid.not());
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD") List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
@ -114,6 +128,10 @@ public class EditAccountController implements RouteSelectionListener {
description = description.strip(); description = description.strip();
if (description.isBlank()) description = null; if (description.isBlank()) description = null;
} }
BigDecimal creditLimit = null;
if (type == AccountType.CREDIT_CARD && creditLimitField.getText() != null && !creditLimitField.getText().isBlank()) {
creditLimit = new BigDecimal(creditLimitField.getText());
}
try ( try (
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository(); var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository() var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
@ -132,13 +150,19 @@ public class EditAccountController implements RouteSelectionListener {
boolean success = Popups.confirm(accountNameField, prompt); boolean success = Popups.confirm(accountNameField, prompt);
if (success) { if (success) {
long id = accountRepo.insert(type, number, name, currency, description); long id = accountRepo.insert(type, number, name, currency, description);
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments); balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, BalanceRecordType.CASH, initialBalance, currency, attachments);
if (type == AccountType.CREDIT_CARD && creditLimit != null) {
accountRepo.saveCreditCardProperties(new CreditCardProperties(id, creditLimit));
}
// Once we create the new account, go to the account. // Once we create the new account, go to the account.
Account newAccount = accountRepo.findById(id).orElseThrow(); Account newAccount = accountRepo.findById(id).orElseThrow();
router.replace("account", newAccount); router.replace("account", newAccount);
} }
} else { } else {
accountRepo.update(account.id, type, number, name, currency, description); accountRepo.update(account.id, type, number, name, currency, description);
if (type == AccountType.CREDIT_CARD) {
accountRepo.saveCreditCardProperties(new CreditCardProperties(account.id, creditLimit));
}
Account updatedAccount = accountRepo.findById(account.id).orElseThrow(); Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
router.replace("account", updatedAccount); router.replace("account", updatedAccount);
} }
@ -161,12 +185,28 @@ public class EditAccountController implements RouteSelectionListener {
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD")); accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
initialBalanceField.setText(String.format("%.02f", 0f)); initialBalanceField.setText(String.format("%.02f", 0f));
descriptionField.setText(null); descriptionField.setText(null);
creditLimitField.setText(null);
} else { } else {
accountNameField.setText(account.getName()); accountNameField.setText(account.getName());
accountNumberField.setText(account.getAccountNumber()); accountNumberField.setText(account.getAccountNumber());
accountTypeChoiceBox.getSelectionModel().select(account.getType()); accountTypeChoiceBox.getSelectionModel().select(account.getType());
accountCurrencyComboBox.getSelectionModel().select(account.getCurrency()); accountCurrencyComboBox.getSelectionModel().select(account.getCurrency());
descriptionField.setText(account.getDescription()); descriptionField.setText(account.getDescription());
// Fetch the account's credit limit if it's a credit card account.
if (account.getType() == AccountType.CREDIT_CARD) {
Profile.getCurrent().dataSource().mapRepoAsync(
AccountRepository.class,
repo -> repo.getCreditCardProperties(account.id)
).thenAccept(props -> Platform.runLater(() -> {
if (props != null && props.creditLimit() != null) {
creditLimitField.setText(CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(props.creditLimit(), account.getCurrency())));
} else {
creditLimitField.setText(null);
}
}));
}
} }
} }
} }

View File

@ -13,12 +13,12 @@ import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.CategorySelectionBox; import com.andrewlalis.perfin.view.component.CategorySelectionBox;
import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.TransactionLineItemTile; import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.ValidationResult; import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanExpression; import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.*; import javafx.beans.property.*;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
@ -41,6 +41,7 @@ import java.time.DateTimeException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
@ -57,6 +58,7 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public TextField timestampField; @FXML public TextField timestampField;
@FXML public TextField amountField; @FXML public TextField amountField;
@FXML public ChoiceBox<Currency> currencyChoiceBox; @FXML public ChoiceBox<Currency> currencyChoiceBox;
private final BooleanProperty basicTransactionInfoValid = new SimpleBooleanProperty(false);
@FXML public TextArea descriptionField; @FXML public TextArea descriptionField;
@FXML public HBox linkedAccountsContainer; @FXML public HBox linkedAccountsContainer;
@ -132,11 +134,13 @@ public class EditTransactionController implements RouteSelectionListener {
var linkedAccountsValid = initializeLinkedAccountsValidationUi(); var linkedAccountsValid = initializeLinkedAccountsValidationUi();
initializeTagSelectionUi(); initializeTagSelectionUi();
initializeLineItemsUi(); initializeLineItemsUi();
initializeDuplicateTransactionUi();
vendorsHyperlink.setOnAction(event -> router.navigate("vendors")); vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
categoriesHyperlink.setOnAction(event -> router.navigate("categories")); categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
tagsHyperlink.setOnAction(event -> router.navigate("tags")); tagsHyperlink.setOnAction(event -> router.navigate("tags"));
basicTransactionInfoValid.bind(timestampValid.and(amountValid).and(currencyChoiceBox.valueProperty().isNotNull()));
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not()); saveButton.disableProperty().bind(formValid.not());
} }
@ -313,6 +317,49 @@ public class EditTransactionController implements RouteSelectionListener {
.attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty()); .attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty());
} }
record BasicTransactionInfo(LocalDateTime timestamp, BigDecimal amount, Currency currency) {}
private BasicTransactionInfo getBasicTransactionInfo() {
if (!basicTransactionInfoValid.get()) return null;
return new BasicTransactionInfo(
DateUtil.localToUTC(parseTimestamp()),
new BigDecimal(amountField.getText()),
currencyChoiceBox.getValue()
);
}
/**
* Initializes the duplicate transaction validation, which operates on the
* basic transaction properties: timestamp, amount, and currency. We listen
* for changes to these, and if they're all at least valid, we search for
* existing transactions with the same values.
*/
private void initializeDuplicateTransactionUi() {
Property<BasicTransactionInfo> txInfoProperty = new SimpleObjectProperty<>(getBasicTransactionInfo());
basicTransactionInfoValid.addListener((observable, oldValue, newValue) -> {
if (newValue) {
txInfoProperty.setValue(new BasicTransactionInfo(
DateUtil.localToUTC(parseTimestamp()),
new BigDecimal(amountField.getText()),
currencyChoiceBox.getValue()
));
} else {
txInfoProperty.setValue(null);
}
});
AsyncValidationFunction<BasicTransactionInfo> validationFunction = info -> {
if (info == null || transaction != null) return CompletableFuture.completedFuture(ValidationResult.valid());
return Profile.getCurrent().dataSource().mapRepoAsync(
TransactionRepository.class,
repo -> repo.findDuplicates(info.timestamp(), info.amount(), info.currency())
)
.thenApply(matches -> matches.stream().map(m -> "Found possible duplicate transaction: #" + m.id).toList())
.thenApply(ValidationResult::new);
};
new ValidationApplier<>(validationFunction)
.attach(descriptionField.getParent(), txInfoProperty);
}
private void initializeTagSelectionUi() { private void initializeTagSelectionUi() {
addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank())); addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
addTagButton.setOnAction(event -> { addTagButton.setOnAction(event -> {
@ -403,12 +450,10 @@ public class EditTransactionController implements RouteSelectionListener {
} }
}); });
BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false); BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false);
lineItemsTotalValue.addListener((observable, oldValue, newValue) -> { lineItemsTotalValue.addListener((observable, oldValue, newValue) ->
lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0); lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0));
}); amountFieldValue.addListener((observable, oldValue, newValue) ->
amountFieldValue.addListener((observable, oldValue, newValue) -> { lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0));
lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0);
});
BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not())); BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not()));
// Logic for button that syncs line items total to the amount field. // Logic for button that syncs line items total to the amount field.

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,6 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort; import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher; import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
import com.andrewlalis.perfin.data.search.SearchFilter; import com.andrewlalis.perfin.data.search.SearchFilter;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.Pair; import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
@ -29,11 +28,7 @@ import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import java.io.File;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@ -47,13 +42,19 @@ import static com.andrewlalis.perfin.PerfinApp.router;
* to a specific page. * to a specific page.
*/ */
public class TransactionsViewController implements RouteSelectionListener { public class TransactionsViewController implements RouteSelectionListener {
public static List<Sort> DEFAULT_SORTS = List.of(Sort.desc("timestamp")); public static List<Sort> DEFAULT_SORTS = List.of(
Sort.desc("timestamp"),
Sort.desc("amount"),
Sort.desc("currency")
);
public record RouteContext(Long selectedTransactionId) {} public record RouteContext(Long selectedTransactionId) {}
@FXML public BorderPane transactionsListBorderPane; @FXML public BorderPane transactionsListBorderPane;
@FXML public TextField searchField; @FXML public TextField searchField;
@FXML public AccountSelectionBox filterByAccountComboBox; @FXML public AccountSelectionBox filterByAccountComboBox;
@FXML public VBox transactionsVBox; @FXML public VBox transactionsVBox;
private DataSourcePaginationControls paginationControls; private DataSourcePaginationControls paginationControls;
@ -66,6 +67,9 @@ public class TransactionsViewController implements RouteSelectionListener {
paginationControls.setPage(1); paginationControls.setPage(1);
selectedTransaction.set(null); selectedTransaction.set(null);
}); });
// Add a listener to the search field that sets the page to 1 (thus
// doing a new search with the contents of the field), and deselects any
// selected transaction.
searchField.textProperty().addListener((observable, oldValue, newValue) -> { searchField.textProperty().addListener((observable, oldValue, newValue) -> {
paginationControls.setPage(1); paginationControls.setPage(1);
selectedTransaction.set(null); selectedTransaction.set(null);
@ -134,6 +138,7 @@ public class TransactionsViewController implements RouteSelectionListener {
// If a transaction id is given in the route context, navigate to the page it's on and select it. // If a transaction id is given in the route context, navigate to the page it's on and select it.
if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) { if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
searchField.setText(null);// First clear the search field if it's already populated.
Profile.getCurrent().dataSource().useRepoAsync( Profile.getCurrent().dataSource().useRepoAsync(
TransactionRepository.class, TransactionRepository.class,
repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> { repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
@ -155,37 +160,34 @@ public class TransactionsViewController implements RouteSelectionListener {
} }
@FXML public void exportTransactions() { @FXML public void exportTransactions() {
FileChooser fileChooser = new FileChooser(); Popups.message(transactionsListBorderPane, "Exporting transactions is not yet supported.");
fileChooser.setTitle("Export Transactions");
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("CSV Files", ".csv"));
File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow());
if (file != null) {
try (
var repo = Profile.getCurrent().dataSource().getTransactionRepository();
var out = new PrintWriter(file, StandardCharsets.UTF_8)
) {
out.println("id,utc-timestamp,amount,currency,description");
List<Transaction> allTransactions = repo.findAll(PageRequest.unpaged(Sort.desc("timestamp"))).items();
for (Transaction tx : allTransactions) {
out.println("%d,%s,%s,%s,%s".formatted(
tx.id,
tx.getTimestamp().format(DateUtil.DEFAULT_DATETIME_FORMAT),
tx.getAmount().toPlainString(),
tx.getCurrency().getCurrencyCode(),
tx.getDescription() == null ? "" : tx.getDescription()
));
}
} catch (Exception e) {
Popups.error(transactionsListBorderPane, e);
}
}
} }
private List<SearchFilter> getCurrentSearchFilters() { private List<SearchFilter> getCurrentSearchFilters() {
List<SearchFilter> filters = new ArrayList<>(); List<SearchFilter> filters = new ArrayList<>();
if (searchField.getText() != null && !searchField.getText().isBlank()) { if (searchField.getText() != null && !searchField.getText().isBlank()) {
var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+")) final String text = searchField.getText().strip();
// Special case: for input like "#123", search directly for the transaction id.
if (text.matches("#\\d+")) {
int idQuery = Integer.parseInt(text.substring(1));
var filter = new SearchFilter.Builder().where("id = ?").withArg(idQuery).build();
return List.of(filter);
}
// Special case: for input like "tag: abc", search directly for transactions with tags like that.
if (text.matches("tag:\\s*.+")) {
String tagQuery = "%" + text.substring(4).strip().toLowerCase() + "%";
var filter = new SearchFilter.Builder().where("""
id IN (
SELECT ttj.transaction_id
FROM transaction_tag_join ttj
LEFT JOIN transaction_tag tt ON tt.id = ttj.tag_id
WHERE LOWER(tt.name) LIKE ?
)""").withArg(tagQuery).build();
return List.of(filter);
}
// General case: split the input into a list of terms, then apply each term in a LIKE %term% query.
var likeTerms = Arrays.stream(text.toLowerCase().split("\\s+"))
.map(t -> '%'+t+'%') .map(t -> '%'+t+'%')
.toList(); .toList();
var builder = new SearchFilter.Builder(); var builder = new SearchFilter.Builder();
@ -213,13 +215,6 @@ public class TransactionsViewController implements RouteSelectionListener {
return filters; return filters;
} }
// Temporary utility to try out the new filter builder.
private List<SearchFilter> tmpFilter() {
return new JdbcTransactionSearcher.FilterBuilder()
.byHasLineItems(true)
.build();
}
private TransactionTile makeTile(Transaction transaction) { private TransactionTile makeTile(Transaction transaction) {
var tile = new TransactionTile(transaction); var tile = new TransactionTile(transaction);
tile.setOnMouseClicked(event -> { tile.setOnMouseClicked(event -> {

View File

@ -4,6 +4,7 @@ import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType; import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.CreditCardProperties;
import com.andrewlalis.perfin.model.Timestamped; import com.andrewlalis.perfin.model.Timestamped;
import java.math.BigDecimal; import java.math.BigDecimal;
@ -23,15 +24,22 @@ public interface AccountRepository extends Repository, AutoCloseable {
List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive); List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive);
List<Account> findAllByCurrency(Currency currency); List<Account> findAllByCurrency(Currency currency);
Optional<Account> findById(long id); Optional<Account> findById(long id);
CreditCardProperties getCreditCardProperties(long id);
void saveCreditCardProperties(CreditCardProperties properties);
void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description); void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description);
void delete(Account account); void delete(Account account);
void archive(long accountId); void archive(long accountId);
void unarchive(long accountId); void unarchive(long accountId);
BigDecimal deriveBalance(long accountId, Instant timestamp); BigDecimal deriveCashBalance(long accountId, Instant timestamp);
default BigDecimal deriveCurrentBalance(long accountId) { default BigDecimal deriveCurrentCashBalance(long accountId) {
return deriveBalance(accountId, Instant.now(Clock.systemUTC())); return deriveCashBalance(accountId, Instant.now(Clock.systemUTC()));
} }
BigDecimal getNearestAssetValue(long accountId, Instant timestamp);
default BigDecimal getNearestAssetValue(long accountId) {
return getNearestAssetValue(accountId, Instant.now(Clock.systemUTC()));
}
Set<Currency> findAllUsedCurrencies(); Set<Currency> findAllUsedCurrencies();
List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults); List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults);
} }

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import javafx.application.Platform;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
@ -35,6 +36,7 @@ public interface DataSource {
TransactionLineItemRepository getTransactionLineItemRepository(); TransactionLineItemRepository getTransactionLineItemRepository();
AttachmentRepository getAttachmentRepository(); AttachmentRepository getAttachmentRepository();
HistoryRepository getHistoryRepository(); HistoryRepository getHistoryRepository();
SavedQueryRepository getSavedQueryRepository();
AnalyticsRepository getAnalyticsRepository(); AnalyticsRepository getAnalyticsRepository();
@ -91,6 +93,7 @@ public interface DataSource {
TransactionLineItemRepository.class, this::getTransactionLineItemRepository, TransactionLineItemRepository.class, this::getTransactionLineItemRepository,
AttachmentRepository.class, this::getAttachmentRepository, AttachmentRepository.class, this::getAttachmentRepository,
HistoryRepository.class, this::getHistoryRepository, HistoryRepository.class, this::getHistoryRepository,
SavedQueryRepository.class, this::getSavedQueryRepository,
AnalyticsRepository.class, this::getAnalyticsRepository AnalyticsRepository.class, this::getAnalyticsRepository
); );
return (Supplier<R>) repoSuppliers.get(type); return (Supplier<R>) repoSuppliers.get(type);
@ -101,7 +104,7 @@ public interface DataSource {
default CompletableFuture<String> getAccountBalanceText(Account account) { default CompletableFuture<String> getAccountBalanceText(Account account) {
CompletableFuture<String> cf = new CompletableFuture<>(); CompletableFuture<String> cf = new CompletableFuture<>();
mapRepoAsync(AccountRepository.class, repo -> { mapRepoAsync(AccountRepository.class, repo -> {
BigDecimal balance = repo.deriveCurrentBalance(account.id); BigDecimal balance = repo.deriveCurrentCashBalance(account.id);
MoneyValue money = new MoneyValue(balance, account.getCurrency()); MoneyValue money = new MoneyValue(balance, account.getCurrency());
return CurrencyUtil.formatMoney(money); return CurrencyUtil.formatMoney(money);
}).thenAccept(s -> Platform.runLater(() -> cf.complete(s))); }).thenAccept(s -> Platform.runLater(() -> cf.complete(s)));
@ -111,17 +114,20 @@ public interface DataSource {
/** /**
* Gets a list of combined total assets for each currency that's tracked, * Gets a list of combined total assets for each currency that's tracked,
* ordered with highest assets first. * ordered with highest assets first.
* @param timestamp The timestamp at which to get the balance.
* @return A future that resolves to the list of amounts for each currency. * @return A future that resolves to the list of amounts for each currency.
*/ */
default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances() { default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances(Instant timestamp) {
return mapRepoAsync(AccountRepository.class, repo -> { return mapRepoAsync(AccountRepository.class, repo -> {
List<Account> accounts = repo.findAll(PageRequest.unpaged()).items(); List<Account> accounts = repo.findAll(PageRequest.unpaged()).items();
Map<Currency, BigDecimal> totals = new HashMap<>(); Map<Currency, BigDecimal> totals = new HashMap<>();
for (var account : accounts) { for (var account : accounts) {
BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO); BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO);
BigDecimal accountBalance = repo.deriveCurrentBalance(account.id); BigDecimal accountBalance = repo.deriveCashBalance(account.id, timestamp);
BigDecimal accountAssetsValue = repo.getNearestAssetValue(account.id, timestamp);
if (account.getType() == AccountType.CREDIT_CARD) accountBalance = accountBalance.negate(); if (account.getType() == AccountType.CREDIT_CARD) accountBalance = accountBalance.negate();
totals.put(account.getCurrency(), currencyTotal.add(accountBalance)); BigDecimal accountTotal = accountBalance.add(accountAssetsValue);
totals.put(account.getCurrency(), currencyTotal.add(accountTotal));
} }
List<MoneyValue> values = new ArrayList<>(totals.size()); List<MoneyValue> values = new ArrayList<>(totals.size());
for (var entry : totals.entrySet()) { for (var entry : totals.entrySet()) {
@ -131,4 +137,8 @@ public interface DataSource {
return values; return values;
}); });
} }
default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances() {
return getCombinedAccountBalances(Instant.now());
}
} }

View File

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

View File

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

View File

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

View File

@ -31,10 +31,13 @@ public interface TransactionRepository extends Repository, AutoCloseable {
Optional<Transaction> findById(long id); Optional<Transaction> findById(long id);
Page<Transaction> findAll(PageRequest pagination); Page<Transaction> findAll(PageRequest pagination);
List<Transaction> findRecentN(int n); List<Transaction> findRecentN(int n);
List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency);
long countAll(); long countAll();
long countAllAfter(long transactionId); long countAllAfter(long transactionId);
long countAllByAccounts(Set<Long> accountIds); long countAllByAccounts(Set<Long> accountIds);
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination); Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
Optional<Transaction> findEarliest();
Optional<Transaction> findLatest();
CreditAndDebitAccounts findLinkedAccounts(long transactionId); CreditAndDebitAccounts findLinkedAccounts(long transactionId);
List<Attachment> findAttachments(long transactionId); List<Attachment> findAttachments(long transactionId);
List<String> findTags(long transactionId); List<String> findTags(long transactionId);

View File

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

View File

@ -25,7 +25,6 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
@Override @Override
public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) { public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) {
return DbUtil.doTransaction(conn, () -> { return DbUtil.doTransaction(conn, () -> {
long accountId = DbUtil.insertOne( long accountId = DbUtil.insertOne(
conn, conn,
"INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)",
@ -36,6 +35,10 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
currency.getCurrencyCode(), currency.getCurrencyCode(),
description description
); );
// If it's a credit card account, preemptively create a credit card properties record.
if (type == AccountType.CREDIT_CARD) {
saveCreditCardProperties(new CreditCardProperties(accountId, null));
}
// Insert a history item indicating the creation of the account. // Insert a history item indicating the creation of the account.
HistoryRepository historyRepo = new JdbcHistoryRepository(conn); HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
@ -114,7 +117,49 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
} }
@Override @Override
public BigDecimal deriveBalance(long accountId, Instant timestamp) { public CreditCardProperties getCreditCardProperties(long id) {
AccountType accountType = getAccountType(id);
if (accountType != AccountType.CREDIT_CARD) return null;
Optional<CreditCardProperties> optionalProperties = DbUtil.findOne(
conn,
"SELECT * FROM credit_card_account_properties WHERE account_id = ?",
List.of(id),
JdbcAccountRepository::parseCreditCardProperties
);
if (optionalProperties.isPresent()) return optionalProperties.get();
// No properties were found for the credit card account, so create an empty properties.
CreditCardProperties defaultProperties = new CreditCardProperties(id, null);
saveCreditCardProperties(defaultProperties);
return defaultProperties;
}
@Override
public void saveCreditCardProperties(CreditCardProperties properties) {
AccountType accountType = getAccountType(properties.accountId());
if (accountType != AccountType.CREDIT_CARD) return;
CreditCardProperties existingProperties = DbUtil.findOne(
conn,
"SELECT * FROM credit_card_account_properties WHERE account_id = ?",
List.of(properties.accountId()),
JdbcAccountRepository::parseCreditCardProperties
).orElse(null);
if (existingProperties != null) {
DbUtil.updateOne(
conn,
"UPDATE credit_card_account_properties SET credit_limit = ? WHERE account_id = ?",
properties.creditLimit(), properties.accountId()
);
} else {
DbUtil.updateOne(
conn,
"INSERT INTO credit_card_account_properties (account_id, credit_limit) VALUES (?, ?)",
properties.accountId(), properties.creditLimit()
);
}
}
@Override
public BigDecimal deriveCashBalance(long accountId, Instant timestamp) {
// First find the account itself, since its properties influence the balance. // First find the account itself, since its properties influence the balance.
Account account = findById(accountId).orElse(null); Account account = findById(accountId).orElse(null);
if (account == null) throw new EntityNotFoundException(Account.class, accountId); if (account == null) throw new EntityNotFoundException(Account.class, accountId);
@ -122,7 +167,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir); BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn); AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn);
// Find the most recent balance record before timestamp. // Find the most recent balance record before timestamp.
Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, utcTimestamp); Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, BalanceRecordType.CASH, utcTimestamp);
if (closestPastRecord.isPresent()) { if (closestPastRecord.isPresent()) {
// Then find any entries on the account since that balance record and the timestamp. // Then find any entries on the account since that balance record and the timestamp.
List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween( List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
@ -133,7 +178,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow); return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow);
} else { } else {
// There is no balance record present before the given timestamp. Try and find the closest one after. // There is no balance record present before the given timestamp. Try and find the closest one after.
Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, utcTimestamp); Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, BalanceRecordType.CASH, utcTimestamp);
if (closestFutureRecord.isPresent()) { if (closestFutureRecord.isPresent()) {
// Now find any entries on the account from the timestamp until that balance record. // Now find any entries on the account from the timestamp until that balance record.
List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween( List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
@ -145,13 +190,22 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
} else { } else {
// No balance records exist for the account! Assume balance of 0 when the account was created. // No balance records exist for the account! Assume balance of 0 when the account was created.
log.warn("No balance record exists for account {}! Assuming balance was 0 at account creation.", account.getShortName()); log.warn("No balance record exists for account {}! Assuming balance was 0 at account creation.", account.getShortName());
BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BigDecimal.ZERO, account.getCurrency()); BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BalanceRecordType.CASH, BigDecimal.ZERO, account.getCurrency());
List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp); List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp);
return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated); return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated);
} }
} }
} }
@Override
public BigDecimal getNearestAssetValue(long accountId, Instant timestamp) {
LocalDateTime utcTimestamp = timestamp.atZone(ZoneOffset.UTC).toLocalDateTime();
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
Optional<BalanceRecord> mostRecentRecord = balanceRecordRepo.findClosestBefore(accountId, BalanceRecordType.ASSETS, utcTimestamp);
if (mostRecentRecord.isEmpty()) return BigDecimal.ZERO;
return mostRecentRecord.get().getBalance();
}
@Override @Override
public Set<Currency> findAllUsedCurrencies() { public Set<Currency> findAllUsedCurrencies() {
return new HashSet<>(DbUtil.findAll( return new HashSet<>(DbUtil.findAll(
@ -179,7 +233,7 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id
FROM balance_record FROM balance_record
) )
WHERE account_id = ? AND timestamp <= ? WHERE account_id = ? AND timestamp < ?
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT\s""" + maxResults; LIMIT\s""" + maxResults;
try (var stmt = conn.prepareStatement(query)) { try (var stmt = conn.prepareStatement(query)) {
@ -245,7 +299,10 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
@Override @Override
public void delete(Account account) { public void delete(Account account) {
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", List.of(account.id)); DbUtil.doTransaction(conn, () -> {
DbUtil.update(conn, "DELETE FROM credit_card_account_properties WHERE account_id = ?", account.id);
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", account.id);
});
} }
@Override @Override
@ -280,6 +337,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
return new Account(id, createdAt, archived, type, accountNumber, name, currency, description); return new Account(id, createdAt, archived, type, accountNumber, name, currency, description);
} }
public static CreditCardProperties parseCreditCardProperties(ResultSet rs) throws SQLException {
long accountId = rs.getLong("account_id");
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
return new CreditCardProperties(accountId, creditLimit);
}
@Override @Override
public void close() throws Exception { public void close() throws Exception {
conn.close(); conn.close();
@ -301,4 +364,11 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements
} }
return balance; return balance;
} }
private AccountType getAccountType(long id) {
String accountTypeStr = DbUtil.findOne(conn, "SELECT account_type FROM account WHERE id = ?", List.of(id), rs -> rs.getString(1))
.orElse(null);
if (accountTypeStr == null) return null;
return AccountType.valueOf(accountTypeStr.toUpperCase());
}
} }

View File

@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.TimestampRange;
import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.Pair; import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.AccountEntry; import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.TransactionCategory; import com.andrewlalis.perfin.model.TransactionCategory;
import com.andrewlalis.perfin.model.TransactionVendor; import com.andrewlalis.perfin.model.TransactionVendor;
import javafx.scene.paint.Color; import javafx.scene.paint.Color;
@ -72,6 +73,38 @@ public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepos
); );
} }
@Override
public List<MoneyValue> getVendorSpend(TimestampRange range, long vendorId) {
return DbUtil.findAll(
conn,
"""
SELECT
SUM(transaction.amount) AS total,
transaction.currency AS currency,
FROM transaction
WHERE
transaction.vendor_id = ? AND
transaction.timestamp >= ? AND
transaction.timestamp <= ? AND
'!exclude' NOT IN (
SELECT tt.name
FROM transaction_tag tt
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
WHERE ttj.transaction_id = transaction.id
) AND
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id) = 1 AND
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id AND ae.type = 'CREDIT') = 1
GROUP BY transaction.currency
ORDER BY total DESC""",
List.of(vendorId, range.start(), range.end()),
rs -> {
BigDecimal total = rs.getBigDecimal(1);
String currencyCode = rs.getString(2);
return new MoneyValue(total, Currency.getInstance(currencyCode));
}
);
}
@Override @Override
public void close() throws Exception { public void close() throws Exception {
conn.close(); conn.close();

View File

@ -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"))
); );

View File

@ -74,6 +74,11 @@ public class JdbcDataSource implements DataSource {
return new JdbcHistoryRepository(getConnection()); return new JdbcHistoryRepository(getConnection());
} }
@Override
public SavedQueryRepository getSavedQueryRepository() {
return new FileSystemSavedQueryRepository(contentDir);
}
@Override @Override
public AnalyticsRepository getAnalyticsRepository() { public AnalyticsRepository getAnalyticsRepository() {
return new JdbcAnalyticsRepository(getConnection()); return new JdbcAnalyticsRepository(getConnection());

View File

@ -34,8 +34,11 @@ public class JdbcDataSourceFactory implements DataSourceFactory {
* loaded with an old schema version, then we'll migrate to the latest. If * loaded with an old schema version, then we'll migrate to the latest. If
* the profile has a newer schema version, we'll exit and prompt the user * the profile has a newer schema version, we'll exit and prompt the user
* to update their app. * to update their app.
* <p>
* This value should be one higher than the
* </p>
*/ */
public static final int SCHEMA_VERSION = 4; public static final int SCHEMA_VERSION = 6;
public DataSource getDataSource(String profileName) throws ProfileLoadException { public DataSource getDataSource(String profileName) throws ProfileLoadException {
final boolean dbExists = Files.exists(getDatabaseFile(profileName)); final boolean dbExists = Files.exists(getDatabaseFile(profileName));

View File

@ -153,6 +153,16 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
); );
} }
@Override
public List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency) {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction WHERE timestamp = ? AND amount = ? AND currency = ? ORDER BY timestamp DESC",
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode()),
JdbcTransactionRepository::parseTransaction
);
}
@Override @Override
public long countAll() { public long countAll() {
return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L); return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L);
@ -192,6 +202,26 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction); return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
} }
@Override
public Optional<Transaction> findEarliest() {
return DbUtil.findOne(
conn,
"SELECT * FROM transaction ORDER BY timestamp ASC LIMIT 1",
Collections.emptyList(),
JdbcTransactionRepository::parseTransaction
);
}
@Override
public Optional<Transaction> findLatest() {
return DbUtil.findOne(
conn,
"SELECT * FROM transaction ORDER BY timestamp DESC LIMIT 1",
Collections.emptyList(),
JdbcTransactionRepository::parseTransaction
);
}
@Override @Override
public CreditAndDebitAccounts findLinkedAccounts(long transactionId) { public CreditAndDebitAccounts findLinkedAccounts(long transactionId) {
Account creditAccount = DbUtil.findOne( Account creditAccount = DbUtil.findOne(

View File

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

View File

@ -113,8 +113,13 @@ public final class DbUtil {
} }
public static void updateOne(Connection conn, String query, List<Object> args) { public static void updateOne(Connection conn, String query, List<Object> args) {
Object[] argsArray = args.toArray(); try (var stmt = conn.prepareStatement(query)) {
updateOne(conn, query, argsArray); setArgs(stmt, args);
int updateCount = stmt.executeUpdate();
if (updateCount != 1) throw new UncheckedSqlException("Update count is " + updateCount + "; expected 1.");
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
} }
public static void updateOne(Connection conn, String query, Object... args) { public static void updateOne(Connection conn, String query, Object... args) {

View File

@ -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("\"", "\"\"") + '"';
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,8 @@
<?import com.andrewlalis.perfin.view.component.PropertiesPane?> <?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?> <?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<?import javafx.scene.text.*?> <?import javafx.scene.text.Text?>
<?import javafx.scene.text.TextFlow?>
<BorderPane <BorderPane
xmlns="http://javafx.com/javafx" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"
@ -15,34 +16,45 @@
</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="descriptionPane"> <PropertiesPane vgap="5" hgap="5" fx:id="assetValuePane" styleClass="std-padding,std-spacing">
<Label text="Latest Assets Value" styleClass="bold-text" labelFor="${latestAssetsValueLabel}"/>
<VBox>
<Label fx:id="latestAssetsValueLabel" styleClass="mono-font"/>
<Label
styleClass="small-font,secondary-color-fill"
>Derived using nearest recorded asset value.</Label>
</VBox>
</PropertiesPane>
<PropertiesPane vgap="5" hgap="5" fx:id="creditCardPropertiesPane" styleClass="std-padding,std-spacing">
<Label text="Credit Limit" styleClass="bold-text" labelFor="${creditLimitLabel}"/>
<Label fx:id="creditLimitLabel" styleClass="mono-font"/>
</PropertiesPane>
<PropertiesPane vgap="5" hgap="5" fx:id="descriptionPane" styleClass="std-padding,std-spacing">
<Label text="Description" styleClass="bold-text" labelFor="${accountDescriptionText}"/> <Label text="Description" styleClass="bold-text" labelFor="${accountDescriptionText}"/>
<TextFlow maxWidth="500"><Text fx:id="accountDescriptionText"/></TextFlow> <TextFlow maxWidth="500"><Text fx:id="accountDescriptionText"/></TextFlow>
</PropertiesPane> </PropertiesPane>
@ -51,6 +63,7 @@
<HBox fx:id="actionsBox" styleClass="std-padding,std-spacing,small-font"> <HBox fx:id="actionsBox" styleClass="std-padding,std-spacing,small-font">
<Button text="Edit" onAction="#goToEditPage"/> <Button text="Edit" onAction="#goToEditPage"/>
<Button text="Record Balance" onAction="#goToCreateBalanceRecord"/> <Button text="Record Balance" onAction="#goToCreateBalanceRecord"/>
<Button text="Record Asset Value" onAction="#goToCreateAssetRecord"/>
<Button text="Archive" onAction="#archiveAccount"/> <Button text="Archive" onAction="#archiveAccount"/>
<Button text="Delete" onAction="#deleteAccount"/> <Button text="Delete" onAction="#deleteAccount"/>
<Button text="Unarchive" onAction="#unarchiveAccount"/> <Button text="Unarchive" onAction="#unarchiveAccount"/>

View File

@ -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"/>

View File

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

View File

@ -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"

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@
<Button text="Forward" onAction="#goForward"/> <Button text="Forward" onAction="#goForward"/>
<Button text="Dashboard" onAction="#goToDashboard"/> <Button text="Dashboard" onAction="#goToDashboard"/>
<Button text="Profiles" onAction="#viewProfiles"/> <Button text="Profiles" onAction="#viewProfiles"/>
<Button text="SQL Console" onAction="#goToSqlConsole"/>
<Button text="View Help" fx:id="showManualButton" onAction="#showManual"/> <Button text="View Help" fx:id="showManualButton" onAction="#showManual"/>
<Button text="Hide Help" fx:id="hideManualButton" onAction="#hideManual"/> <Button text="Hide Help" fx:id="hideManualButton" onAction="#hideManual"/>
@ -27,7 +28,7 @@
<!-- App footer --> <!-- App footer -->
<bottom> <bottom>
<HBox styleClass="std-padding,std-spacing"> <HBox styleClass="std-padding,std-spacing">
<Label text="Perfin Version 1.11.0"/> <Label text="Perfin Version 1.19.0"/>
<AnchorPane> <AnchorPane>
<Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/> <Label text="© 2024 Andrew Lalis" styleClass="small-font,secondary-color-text-fill" AnchorPane.topAnchor="0" AnchorPane.bottomAnchor="0"/>
</AnchorPane> </AnchorPane>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,11 @@
/*
+--------------------------+
| PERFIN Database Schema |
+--------------------------+
This file defines the relational database schema for Perfin. Various sections
are labeled below.
*/
CREATE TABLE account ( CREATE TABLE account (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
created_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL,
@ -9,6 +17,14 @@ CREATE TABLE account (
description VARCHAR(255) DEFAULT NULL description VARCHAR(255) DEFAULT NULL
); );
CREATE TABLE credit_card_account_properties (
account_id BIGINT PRIMARY KEY,
credit_limit NUMERIC(12, 4) NULL,
CONSTRAINT fk_credit_card_account_properties_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE attachment ( CREATE TABLE attachment (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
uploaded_at TIMESTAMP NOT NULL, uploaded_at TIMESTAMP NOT NULL,
@ -120,6 +136,7 @@ CREATE TABLE balance_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
timestamp TIMESTAMP NOT NULL, timestamp TIMESTAMP NOT NULL,
account_id BIGINT NOT NULL, account_id BIGINT NOT NULL,
type ENUM('CASH', 'ASSETS') NOT NULL DEFAULT 'CASH',
balance NUMERIC(12, 4) NOT NULL, balance NUMERIC(12, 4) NOT NULL,
currency VARCHAR(3) NOT NULL, currency VARCHAR(3) NOT NULL,
CONSTRAINT fk_balance_record_account CONSTRAINT fk_balance_record_account
@ -139,7 +156,8 @@ CREATE TABLE balance_record_attachment (
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
/* HISTORY */ /* HISTORY ENTITIES */
CREATE TABLE history ( CREATE TABLE history (
id BIGINT PRIMARY KEY AUTO_INCREMENT id BIGINT PRIMARY KEY AUTO_INCREMENT
); );

View File

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

View File

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

View File

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

View File

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