Compare commits

...

115 Commits
v1.0.1 ... main

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
Andrew Lalis 8f5ff09891 Set version to 1.11.0 2024-02-28 12:44:40 -05:00
Andrew Lalis 06d9aa016d Upgrade to javafx 21.0.2 (from 21.0.1). 2024-02-20 17:28:52 -05:00
Andrew Lalis a9cdc6c41e Added label for confirming line items total, and for setting amount equal to the line items total. 2024-02-20 17:17:21 -05:00
Andrew Lalis 9222b8f990 Cleaned up some analysis warnings in Ulid.java 2024-02-11 08:54:11 -05:00
Andrew Lalis d85ff6676e Set version to 1.10.0 2024-02-11 08:50:10 -05:00
Andrew Lalis 7f65466d6d Refactored the account-view.fxml and associated stuff to include the account's description. 2024-02-11 08:48:46 -05:00
Andrew Lalis b52148fd3b Set to version 1.9.0 2024-02-09 13:53:51 -05:00
Andrew Lalis 9bc4d1e494 Fixed AccountTest 2024-02-09 13:52:05 -05:00
Andrew Lalis 012b60d1f8 Added some insane logic to compute actual spending by category. 2024-02-09 13:51:25 -05:00
Andrew Lalis 41530d5276 Added the ability to add, edit, and remove transaction line items. 2024-02-09 12:21:06 -05:00
Andrew Lalis 5cc789419c Set version 1.8.0 2024-02-08 11:34:03 -05:00
Andrew Lalis cc83e3eb0f Make positioning of category and vendor in transaction tile consistent. 2024-02-08 11:33:46 -05:00
Andrew Lalis 5c1036b72f Cleaned up some formatting. 2024-02-08 11:29:22 -05:00
Andrew Lalis fb2b8d933b Added timestamp ranges to the dashboard pie charts. 2024-02-08 09:49:21 -05:00
Andrew Lalis 5a339cbee6 Add new "Brokerage" account type, and add more convenience for getting total assets. 2024-02-08 09:06:07 -05:00
Andrew Lalis d43c61d6ee Update version to 1.7.1 2024-02-07 09:09:30 -05:00
Andrew Lalis 6bafd06fc0 Added historical balance checker and cleaned up actions layout on account page. 2024-02-07 09:05:50 -05:00
Andrew Lalis 104de66a66 Added padding to dashboard modules, set version to 1.7.0 2024-02-06 18:01:26 -05:00
Andrew Lalis abf132ec99 Fixed account editing. 2024-02-06 17:59:13 -05:00
Andrew Lalis 807259b2a5 Update to 1.6.1 2024-02-06 09:18:03 -05:00
Andrew Lalis 38d61c056b Removed unused imports. 2024-02-06 09:17:09 -05:00
Andrew Lalis 970ca46ef6 Cleaned up account history, improved transaction tiles in dashboard and transactions list, and fixed small bug in balance record validation. 2024-02-06 09:16:12 -05:00
Andrew Lalis 7d50b12a4f Added functionality for an !exclude tag. 2024-02-05 15:22:26 -05:00
Andrew Lalis 2237293eda Updated to version 1.6.0 2024-02-05 11:47:06 -05:00
Andrew Lalis 64c46e6be9 Added connection test when creating any data source now. 2024-02-05 11:38:10 -05:00
Andrew Lalis 54f6612048 Improve backups, and add pie chart modules for vendor and category. 2024-02-05 11:27:20 -05:00
Andrew Lalis f4d8a4803b Added automatic backup creation on profile load. 2024-02-04 21:33:49 -05:00
Andrew Lalis 81598fc57c Added backup button with native zip file creation. 2024-02-04 21:10:26 -05:00
Andrew Lalis c9d7b9f4da Updated main-view-screenshot.png 2024-02-04 12:55:13 -05:00
Andrew Lalis c00a4b65bb Added documentation to the adding-a-transaction.fxml help page. 2024-02-04 12:52:08 -05:00
Andrew Lalis f9a0fea9ab Added dashboard page and initial account and transaction modules. 2024-02-04 12:35:59 -05:00
Andrew Lalis 6900fdb481
Merge pull request #15 from andrewlalis/transaction-properties
Add Transaction Properties
2024-02-03 23:31:03 -05:00
Andrew Lalis 396fd122a8 Added migration info to README.md, and added ability to insert default categories into existing perfin profiles. 2024-02-03 23:27:23 -05:00
Andrew Lalis 0fe451029d Update to version 1.5.0 2024-02-03 23:07:19 -05:00
Andrew Lalis 8f36380e21 Refactored account history. 2024-02-03 22:59:29 -05:00
Andrew Lalis 3493003588 Made query logging debug only. 2024-02-02 12:46:53 -05:00
Andrew Lalis 85627fb8ad Added basic query building mechanic for dynamic searching. 2024-02-02 12:42:11 -05:00
Andrew Lalis 90ec1e9b09 Added thing to load default categories from a JSON file. 2024-02-02 09:22:46 -05:00
Andrew Lalis eefbb1c09b Compacted the amount and currency fields. 2024-01-31 11:21:54 -05:00
Andrew Lalis 39794e36a2 Added tags view, and cleaned up some other parts of the app. 2024-01-31 11:05:09 -05:00
Andrew Lalis aaa1081ddf Added category view and editor. 2024-01-31 10:16:53 -05:00
Andrew Lalis 77291ba724 Added pages for viewing and editing vendors, and refactored validation to support async validators. 2024-01-30 21:54:34 -05:00
Andrew Lalis ae2713dbd0 Added hyperlinks for entity management pages. 2024-01-30 17:04:31 -05:00
Andrew Lalis 1cdadc9fc4 Added default transaction categories. 2024-01-29 14:08:42 -05:00
Andrew Lalis b9678313bf Added ability to edit tags, vendor, and category of a transaction. 2024-01-29 14:01:49 -05:00
Andrew Lalis e17e2c55a5 Fixed issue with profile not being set. 2024-01-18 11:10:12 -05:00
Andrew Lalis 788e043269 Added more popups for user when opening a profile that requires migration. 2024-01-18 11:03:15 -05:00
Andrew Lalis da589807ef Updated popups to include owner. 2024-01-18 10:44:37 -05:00
Andrew Lalis 4951b8720d Refactor profile loading and turn profile into a record. 2024-01-18 10:09:06 -05:00
Andrew Lalis b783234794 Added M001_AddTransactionProperties.sql migration and schema updates. 2024-01-18 08:53:25 -05:00
Andrew Lalis 2d2ddeb8f2 Updated to version 1.4.0 2024-01-13 12:55:40 -05:00
Andrew Lalis 47ac75af45 Finished transaction editing logic. 2024-01-13 12:54:59 -05:00
Andrew Lalis f0b061c34d Refactored repository access to use fancy generic method. 2024-01-12 22:44:29 -05:00
Andrew Lalis 4600470cdb Fixed AccountSelectionBox. 2024-01-12 17:28:55 -05:00
Andrew Lalis 1a40b78a70 Added AccountSelectionBox and cleaned up logic in the EditTransactionController quite a bit. 2024-01-12 11:30:59 -05:00
Andrew Lalis 3521dee149 Added new AccountSelectionBox, still buggy due to weird javafx combobox stuff. 2024-01-11 22:16:24 -05:00
Andrew Lalis 7ceaca7068 Added start of transaction edit page. 2024-01-11 08:46:57 -05:00
Andrew Lalis 89d7438ab1 Added basic transactions export, just to say it has it. Also did more to sanitize transaction descriptions. 2024-01-11 07:48:46 -05:00
Andrew Lalis 2c49dd5766 Improved attachments preview in transaction and balance record view. 2024-01-11 07:25:28 -05:00
Andrew Lalis 6b563003ec Improved attachment repository logic. 2024-01-10 18:10:43 -05:00
Andrew Lalis 952d149825 Added latest scene router version, replaced history clear and navigate by router's new "replace" function, and added orphan deletion to transaction delete logic. 2024-01-09 21:51:18 -05:00
Andrew Lalis fdfb9d0412 Updated to version 1.3.0 2024-01-09 12:54:42 -05:00
Andrew Lalis 26daf14390 Added about page, improved splash screen again. 2024-01-09 12:52:42 -05:00
Andrew Lalis ce78df559e Added help pages, styled text implementation, and improved splash screen. 2024-01-09 12:34:06 -05:00
Andrew Lalis 2a79afe1b5 Added first placeholder help pages, and initialization to controller. 2024-01-08 23:13:15 -05:00
Andrew Lalis 8270c5a273 Added basic side-panel help info, soon will add router implementation for pages. 2024-01-08 22:43:41 -05:00
Andrew Lalis eb6f79d428 Update branding images. 2024-01-08 22:29:44 -05:00
Andrew Lalis 43ba6e4d73 Update screenshot! 2024-01-08 22:13:12 -05:00
Andrew Lalis 57df76cada Fix release workflow. 2024-01-08 12:30:20 -05:00
Andrew Lalis 1b22b3bd49 Clear transactions when navigating to /transactions. 2024-01-08 12:20:36 -05:00
Andrew Lalis 4370d8221f Set version to 1.2.0, fixed formatting for pagination for the most part. 2024-01-08 12:06:39 -05:00
Andrew Lalis a94666a8d6 More style refactoring for tiled collections. 2024-01-08 11:49:02 -05:00
Andrew Lalis 8a43862725 Cleaned up account and transaction tiles, and removed unneeded CSS references from most places. 2024-01-08 11:21:40 -05:00
Andrew Lalis 65595a47ac Added final validation and warnings to the CreateBalanceRecordController for inconsistent balance records. 2024-01-08 10:55:05 -05:00
Andrew Lalis 5f692bf8e2 Refactor creating a balance record. 2024-01-08 09:49:34 -05:00
Andrew Lalis c02e5d3fc6 Added validation, cleaned up CSS colors with theme definitions. 2024-01-07 19:05:09 -05:00
Andrew Lalis ebf4880297 Updated to version 1.1.0 2024-01-05 12:28:39 -05:00
Andrew Lalis 30f792a7c2 Added font files, and more style refactoring. 2024-01-05 12:25:54 -05:00
Andrew Lalis 02d392d6c7 Refactored styling in most views and components to follow base CSS. 2024-01-05 10:59:44 -05:00
Andrew Lalis a914197634 Added screenshot to readme. 2024-01-04 21:55:56 -05:00
Andrew Lalis bde5185ea6 Added release procedure to readme. 2024-01-04 21:52:53 -05:00
209 changed files with 10140 additions and 1741 deletions

View File

@ -71,7 +71,7 @@ jobs:
- name: Create Release
uses: softprops/action-gh-release@v1
with:
fail-on-unmatched-files: true
fail_on_unmatched_files: true
files: |
release-artifacts/*.deb
release-artifacts/*.msi

View File

@ -1,11 +1,10 @@
# Perfin
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/andrewlalis/perfin/run-tests.yaml?style=flat-square&logo=github)
![GitHub release (with filter)](https://img.shields.io/github/v/release/andrewlalis/perfin?style=flat-square)
A personal accounting desktop app to track your finances using an approachable
interface and interoperable file formats for maximum compatibility.
![](design/main-view-screenshot.png "main view screenshot")
## Download
Head to the [releases](https://github.com/andrewlalis/perfin/releases) page and
@ -22,5 +21,43 @@ checking, credit, etc.).
Because the app lives and works entirely on your local computer, you can rest
assured that your data remains completely private.
Currently, the application is still a work-in-progress, and is not yet suitable
for actual usage with your real financial data, so stay tuned for updates.
## Release Procedure
Platform-specific package installers are generated automatically via GitHub
Actions (see `.github/workflows/make-release.yaml`), which is triggered by a
new tag being pushed to the `main` branch. Follow these steps to push a release:
1. Run `java scripts/SetVersion.java 1.2.3` (replacing `1.2.3` with the new version number)
to set the version everywhere that it needs to be.
2. Add a tag to the `main` branch with `git tag v1.2.3`.
3. Push the tag to GitHub with `git push origin v1.2.3`.
Once that's done, the workflow will start, and you should see a release appear
in the next few minutes.
## Migration Procedure
Because this application relies on a structured relational database schema,
changes to the schema must be handled with care to avoid destroying users' data.
Specifically, when changes are made to the schema, a *migration* must be defined
which provides instructions for Perfin to safely apply changes to an old schema.
The database schema is versioned using whole-number versions (1, 2, 3, ...), and
a migration is defined for each transition from version to version, such that
any older version can be incrementally upgraded, step by step, to the latest
schema version.
Perfin only supports the latest schema version, as defined by `JdbcDataSourceFactory.SCHEMA_VERSION`.
When the app loads a profile, it'll check that profile's schema version by
reading a `.jdbc-schema-version.txt` file in the profile's main directory. If
the profile's schema version is **less than** the current, Perfin will
ask the user if they want to upgrade. If the profile's schema version is
**greater than** the current, Perfin will tell the user that it can't load a
schema from a newer version, and will prompt the user to upgrade.
### Writing a Migration
1. Write your migration. This can be plain SQL (placed in `resources/sql/migration`), or Java code.
2. Add your migration to `com.andrewlalis.perfin.data.impl.migration.Migrations#getMigrations()`.
3. Increment the schema version defined in `JdbcDataSourceFactory`.
4. Test the migration yourself on a profile with data.

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@ -26,9 +26,9 @@
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
inkscape:zoom="8"
inkscape:cx="25.125"
inkscape:cy="33.375"
inkscape:zoom="11.313709"
inkscape:cx="32.438524"
inkscape:cy="30.228815"
inkscape:window-width="1920"
inkscape:window-height="1025"
inkscape:window-x="1080"
@ -66,13 +66,15 @@
d="M 15.696199 1.2371338 L 1.2371338 15.696199 C 2.002308 16.461373 3.0607012 16.933333 4.2333332 16.933333 L 12.7 16.933333 C 15.045264 16.933333 16.933333 15.045264 16.933333 12.7 L 16.933333 4.2333332 C 16.933333 3.0607012 16.461373 2.002308 15.696199 1.2371338 z " />
<path
id="lower-dollar"
style="fill:#346b23;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-linecap:round"
d="m 13.000241,3.9330926 -1.304313,1.304313 c 0.0076,0.032651 0.01631,0.063696 0.02325,0.097152 L 13.398662,5.0162308 C 13.290974,4.6118006 13.157768,4.2511887 13.000237,3.9330929 Z M 9.537919,7.3954134 7.984009,8.9493237 V 13.384195 C 7.108943,13.339155 6.414126,13.117013 5.899382,12.718086 5.591151,12.475354 5.346639,12.158421 5.162993,11.77034 l -1.204061,1.204061 c 0.792283,1.097222 2.132722,1.678716 4.025077,1.741495 v 1.553911 h 1.235584 v -1.553911 c 1.441285,-0.05791 2.548094,-0.389429 3.320211,-0.994254 0.772117,-0.611259 1.158069,-1.437594 1.158069,-2.479952 0,-0.514745 -0.07422,-0.96528 -0.222209,-1.3513385 C 13.327675,9.4978588 13.10605,9.1533177 12.810072,8.8573396 12.514093,8.5613615 12.117989,8.2978085 11.622547,8.0661735 11.178148,7.8584026 10.474116,7.6342452 9.537919,7.3954134 Z M 9.219593,8.8573396 c 0.8815,0.2252008 1.482826,0.4314552 1.804541,0.6180501 0.321715,0.1801606 0.569381,0.4085543 0.743107,0.6852293 0.180161,0.276675 0.270268,0.627468 0.270268,1.052132 0,0.649865 -0.23803,1.164468 -0.714168,1.544092 -0.476139,0.37319 -1.177208,0.588563 -2.103748,0.646472 z" />
style="display:inline;fill:#346b23;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-linecap:round"
d="M 9.537919,7.3954134 7.984009,8.9493237 V 13.384195 C 7.108943,13.339155 6.414126,13.117013 5.899382,12.718086 5.591151,12.475354 5.346639,12.158421 5.162993,11.77034 l -1.204061,1.204061 c 0.792283,1.097222 2.132722,1.678716 4.025077,1.741495 v 1.553911 h 1.235584 v -1.553911 c 1.441285,-0.05791 2.548094,-0.389429 3.320211,-0.994254 0.772117,-0.611259 1.158069,-1.437594 1.158069,-2.479952 0,-0.514745 -0.07422,-0.96528 -0.222209,-1.3513385 C 13.327675,9.4978588 13.10605,9.1533177 12.810072,8.8573396 12.514093,8.5613615 12.117989,8.2978085 11.622547,8.0661735 11.178148,7.8584026 10.474116,7.6342452 9.537919,7.3954134 Z M 9.219593,8.8573396 c 0.8815,0.2252008 1.482826,0.4314552 1.804541,0.6180501 0.321715,0.1801606 0.569381,0.4085543 0.743107,0.6852293 0.180161,0.276675 0.270268,0.627468 0.270268,1.052132 0,0.649865 -0.23803,1.164468 -0.714168,1.544092 -0.476139,0.37319 -1.177208,0.588563 -2.103748,0.646472 z"
sodipodi:nodetypes="cccccccccccccscssccccsccc" />
<path
id="upper-dollar"
style="font-size:8px;line-height:8.64px;font-family:'Liberation Mono';-inkscape-font-specification:'Liberation Mono';letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect3);fill:#ca9c00;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round"
d="M 25.689419 18.184344 L 25.689419 18.692161 C 25.145149 18.710391 24.729758 18.828758 24.4433 19.047508 C 24.159446 19.263654 24.017678 19.565737 24.017678 19.953758 C 24.017678 20.198549 24.064478 20.409564 24.158228 20.586647 C 24.254582 20.763731 24.404358 20.916037 24.607483 21.043641 C 24.813212 21.168641 25.134795 21.284479 25.572295 21.39125 L 25.689419 21.422622 L 25.689419 21.537864 L 26.318335 20.908948 C 26.274814 20.897846 26.235644 20.886962 26.189498 20.875694 L 26.189498 19.211691 C 26.473352 19.229921 26.698597 19.309289 26.865264 19.449914 C 27.023755 19.583641 27.132164 19.779351 27.191748 20.035536 L 27.719644 19.50764 C 27.611975 19.290229 27.475742 19.122396 27.310545 19.004632 C 27.047525 18.814528 26.673873 18.710391 26.189498 18.692161 L 26.189498 18.184344 L 25.689419 18.184344 z M 25.689419 19.203744 L 25.689419 20.817131 C 25.379523 20.736402 25.165978 20.663464 25.048791 20.59836 C 24.931603 20.530652 24.841696 20.447383 24.779196 20.348425 C 24.7193 20.246863 24.68947 20.121855 24.68947 19.973418 C 24.68947 19.736439 24.774108 19.552723 24.943379 19.422515 C 25.112649 19.289703 25.361294 19.216765 25.689419 19.203744 z M 24.431587 22.324898 L 23.767534 22.469421 C 23.822782 22.741711 23.9209 22.973823 24.060345 23.166938 L 24.547666 22.679617 C 24.497867 22.574382 24.458752 22.45654 24.431587 22.324898 z "
transform="matrix(2.4707763,0,0,2.4707763,-55.488799,-44.26592)" />
style="font-size:8px;line-height:8.64px;font-family:'Liberation Mono';-inkscape-font-specification:'Liberation Mono';letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect3);display:inline;fill:#ca9c00;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round"
d="m 25.689419,18.184344 v 0.507817 c -0.54427,0.01823 -0.959661,0.136597 -1.246119,0.355347 -0.283854,0.216146 -0.425622,0.518229 -0.425622,0.90625 0,0.244791 0.0468,0.455806 0.14055,0.632889 0.09635,0.177084 0.24613,0.32939 0.449255,0.456994 0.205729,0.125 0.527312,0.240838 0.964812,0.347609 l 0.117124,0.03137 v 0.115242 l 0.628916,-0.628916 c -0.04352,-0.0111 -0.08269,-0.02199 -0.128837,-0.03325 v -1.664003 c 0.283854,0.01823 0.509099,0.0976 0.675766,0.238223 0.158491,0.133727 0.2669,0.329437 0.326484,0.585622 l 0.527896,-0.527896 c -0.107669,-0.217411 -0.243902,-0.385244 -0.409099,-0.503008 -0.26302,-0.190104 -0.636672,-0.294241 -1.121047,-0.312471 v -0.507817 z m 0,1.0194 v 1.613387 c -0.309896,-0.08073 -0.523441,-0.153667 -0.640628,-0.218771 -0.117188,-0.06771 -0.207095,-0.150977 -0.269595,-0.249935 -0.0599,-0.101562 -0.08973,-0.22657 -0.08973,-0.375007 0,-0.236979 0.08464,-0.420695 0.253909,-0.550903 0.16927,-0.132812 0.417915,-0.20575 0.74604,-0.218771 z"
transform="matrix(2.4707763,0,0,2.4707763,-55.488799,-44.26592)"
sodipodi:nodetypes="cccscccccccccccccccccccscsc" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -12,11 +12,11 @@
inkscape:export-filename="../src/main/resources/images/splash-screen.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
@ -27,33 +27,116 @@
inkscape:deskcolor="#505050"
inkscape:document-units="px"
inkscape:zoom="2.0863221"
inkscape:cx="182.37836"
inkscape:cy="101.13491"
inkscape:cx="122.70397"
inkscape:cy="106.40735"
inkscape:window-width="1920"
inkscape:window-height="1025"
inkscape:window-x="1080"
inkscape:window-y="470"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:current-layer="layer1"
showguides="true"><sodipodi:guide
position="103.64882,22.578013"
orientation="1,0"
id="guide6"
inkscape:locked="false" /></sodipodi:namedview><defs
id="defs1"><rect
x="317.65446"
y="235.76737"
width="216.44269"
height="83.027779"
id="rect4" /><rect
x="23.509132"
y="17.511823"
width="25.696689"
height="28.301114"
id="rect2" /><rect
x="23.509132"
y="17.511823"
width="25.696689"
height="28.301114"
id="rect3" /><rect
x="317.65446"
y="235.76737"
width="216.44269"
height="83.027779"
id="rect5" /><rect
x="317.65446"
y="235.76737"
width="216.44269"
height="83.027779"
id="rect6" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#354427;stroke-width:1.27;fill-opacity:1"
id="layer1"><rect
style="fill:#346b23;stroke-width:1.27;fill-opacity:1"
id="rect1"
width="105.83333"
height="52.916664"
x="0"
y="0" />
<ellipse
style="fill:#363636;fill-opacity:1;stroke-width:1.27"
y="0" /><path
style="display:inline;fill:#397526;fill-opacity:1;stroke:none;stroke-width:35.921;stroke-linecap:round"
d="M -51.278833,56.497593 160.38037,45.238259 159.49177,-7.633004 c 0,0 -24.42624,1.512546 -47.38917,9.245796 -22.962932,7.733249 -18.862832,17.268118 -51.065942,24.86209 -32.20311,7.593969 -45.158576,4.517345 -74.188418,14.395906 -29.029843,9.87856 -38.127073,15.626805 -38.127073,15.626805 z"
id="path5"
sodipodi:nodetypes="ccczzzc" /><path
style="display:inline;fill:#378028;fill-opacity:1;stroke:none;stroke-width:25.4;stroke-linecap:round"
d="M -12.002166,51.937332 59.703289,68.080262 115.1408,2.45871 c 0,0 -8.49961,-1.59887499 -23.137676,4.7162921 C 77.365059,13.490169 68.481373,25.884552 51.377371,30.713549 c -17.104,4.828996 -17.526548,-0.824049 -36.139652,7.286155 -18.6131001,8.1102 -27.239885,13.937628 -27.239885,13.937628 z"
id="path6"
sodipodi:nodetypes="ccczzzc" /><path
style="display:inline;fill:#e7b300;fill-opacity:1;stroke:none;stroke-width:25.4;stroke-linecap:round"
d="M -1.3473491,68.51775 104.48598,33.263506 v -52.916667 c 0,0 -12.21534,4.282234 -23.751883,14.636677 C 69.197554,5.3379594 71.165788,14.434953 55.014288,25.695895 38.862788,36.956837 32.416445,35.338519 17.831036,48.531087 3.2456269,61.723654 -1.3473491,68.51775 -1.3473491,68.51775 Z"
id="path3"
sodipodi:nodetypes="ccczzzc" /><path
style="display:inline;fill:#ba8e00;fill-opacity:1;stroke:none;stroke-width:25.4;stroke-linecap:round"
d="M 1.2353516e-7,65.117916 105.83333,40.715416 V -12.20125 c 0,0 -12.215341,3.0297196 -23.751884,12.20125 C 70.544903,9.1715303 72.513137,18.470338 56.361637,28.075167 40.210137,37.679996 33.763794,35.400695 19.178385,47.097731 4.5929761,58.794766 1.2353516e-7,65.117916 1.2353516e-7,65.117916 Z"
id="path2"
sodipodi:nodetypes="ccczzzc" /><path
style="display:inline;fill:#ca9c00;fill-opacity:1;stroke:none;stroke-width:25.4;stroke-linecap:round"
d="M 0,52.916666 H 105.83333 V 0 c 0,0 -12.215341,0.21316952 -23.751884,6.7246636 C 70.544903,13.236158 72.513137,22.988791 56.361637,28.869491 40.210137,34.750191 33.763794,30.984525 19.178385,39.318533 4.592976,47.65254 0,52.916666 0,52.916666 Z"
id="path1"
cx="105.83333"
cy="2.2747312"
rx="39.789673"
ry="24.084095" />
</g>
</svg>
sodipodi:nodetypes="ccczzzc" /><g
id="logo-group"
transform="translate(41.550528,32.475407)"
style="display:inline"><path
id="top-right-background"
style="fill:#346b23;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-linecap:round"
d="M 4.2333332,0 C 1.8880691,0 0,1.8880691 0,4.2333332 V 12.7 c 0,2.345264 1.8880691,4.233333 4.2333332,4.233333 H 12.7 c 2.345264,0 4.233333,-1.888069 4.233333,-4.233333 V 4.2333332 C 16.933333,1.8880691 15.045264,0 12.7,0 Z" /><path
id="bottom-right-background"
style="fill:#ca9c00;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-linecap:round"
d="M 15.696199,1.2371338 1.2371338,15.696199 c 0.7651742,0.765174 1.8235674,1.237134 2.9961994,1.237134 H 12.7 c 2.345264,0 4.233333,-1.888069 4.233333,-4.233333 V 4.2333332 c 0,-1.172632 -0.47196,-2.2310252 -1.237134,-2.9961994 z" /><path
id="lower-dollar"
style="display:inline;fill:#346b23;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-linecap:round"
d="M 9.537919,7.3954134 7.984009,8.9493237 V 13.384195 C 7.108943,13.339155 6.414126,13.117013 5.899382,12.718086 5.591151,12.475354 5.346639,12.158421 5.162993,11.77034 l -1.204061,1.204061 c 0.792283,1.097222 2.132722,1.678716 4.025077,1.741495 v 1.553911 h 1.235584 v -1.553911 c 1.441285,-0.05791 2.548094,-0.389429 3.320211,-0.994254 0.772117,-0.611259 1.158069,-1.437594 1.158069,-2.479952 0,-0.514745 -0.07422,-0.96528 -0.222209,-1.3513385 C 13.327675,9.4978588 13.10605,9.1533177 12.810072,8.8573396 12.514093,8.5613615 12.117989,8.2978085 11.622547,8.0661735 11.178148,7.8584026 10.474116,7.6342452 9.537919,7.3954134 Z M 9.219593,8.8573396 c 0.8815,0.2252008 1.482826,0.4314552 1.804541,0.6180501 0.321715,0.1801606 0.569381,0.4085543 0.743107,0.6852293 0.180161,0.276675 0.270268,0.627468 0.270268,1.052132 0,0.649865 -0.23803,1.164468 -0.714168,1.544092 -0.476139,0.37319 -1.177208,0.588563 -2.103748,0.646472 z"
sodipodi:nodetypes="cccccccccccccscssccccsccc" /><path
id="upper-dollar"
style="font-size:8px;line-height:8.64px;font-family:'Liberation Mono';-inkscape-font-specification:'Liberation Mono';letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect3);display:inline;fill:#ca9c00;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round"
d="m 25.689419,18.184344 v 0.507817 c -0.54427,0.01823 -0.959661,0.136597 -1.246119,0.355347 -0.283854,0.216146 -0.425622,0.518229 -0.425622,0.90625 0,0.244791 0.0468,0.455806 0.14055,0.632889 0.09635,0.177084 0.24613,0.32939 0.449255,0.456994 0.205729,0.125 0.527312,0.240838 0.964812,0.347609 l 0.117124,0.03137 v 0.115242 l 0.628916,-0.628916 c -0.04352,-0.0111 -0.08269,-0.02199 -0.128837,-0.03325 v -1.664003 c 0.283854,0.01823 0.509099,0.0976 0.675766,0.238223 0.158491,0.133727 0.2669,0.329437 0.326484,0.585622 l 0.527896,-0.527896 c -0.107669,-0.217411 -0.243902,-0.385244 -0.409099,-0.503008 -0.26302,-0.190104 -0.636672,-0.294241 -1.121047,-0.312471 v -0.507817 z m 0,1.0194 v 1.613387 c -0.309896,-0.08073 -0.523441,-0.153667 -0.640628,-0.218771 -0.117188,-0.06771 -0.207095,-0.150977 -0.269595,-0.249935 -0.0599,-0.101562 -0.08973,-0.22657 -0.08973,-0.375007 0,-0.236979 0.08464,-0.420695 0.253909,-0.550903 0.16927,-0.132812 0.417915,-0.20575 0.74604,-0.218771 z"
transform="matrix(2.4707763,0,0,2.4707763,-55.488799,-44.26592)"
sodipodi:nodetypes="cccscccccccccccccccccccscsc" /></g><text
xml:space="preserve"
transform="matrix(0.26458333,0,0,0.26458333,-25.033368,-34.700052)"
id="text4"
style="font-weight:bold;font-size:64px;line-height:69.12px;font-family:'Liberation Mono';-inkscape-font-specification:'Liberation Mono Bold';letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect4);display:inline;fill:#346b23;fill-opacity:1;stroke-width:96;stroke-linecap:round"><tspan
x="317.6543"
y="289.52758"
id="tspan4"><tspan
style="font-weight:normal;font-family:FreeSerif;-inkscape-font-specification:FreeSerif"
id="tspan1">PerFin</tspan></tspan></text><text
xml:space="preserve"
transform="matrix(0.26458333,0,0,0.26458333,-22.834115,-19.622664)"
id="text5"
style="font-weight:bold;font-size:24px;line-height:25.92px;font-family:'Liberation Mono';-inkscape-font-specification:'Liberation Mono Bold';letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect5);display:inline;fill:#346b23;fill-opacity:1;stroke-width:96;stroke-linecap:round"><tspan
x="317.6543"
y="255.92758"
id="tspan8"><tspan
style="font-weight:normal;font-family:FreeSerif;-inkscape-font-specification:FreeSerif"
id="tspan5">Personal Finance</tspan></tspan></text><text
xml:space="preserve"
transform="matrix(0.26458333,0,0,0.26458333,-3.2394418,-13.512254)"
id="text2"
style="font-weight:bold;font-size:13.3333px;line-height:14.4px;font-family:'Liberation Mono';-inkscape-font-specification:'Liberation Mono Bold';letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect6);display:inline;fill:#346b23;fill-opacity:1;stroke-width:96;stroke-linecap:round"><tspan
x="317.6543"
y="246.96757"
id="tspan10"><tspan
style="font-style:italic;font-weight:normal;font-family:FreeSerif;-inkscape-font-specification:'FreeSerif Italic'"
id="tspan9">By Andrew Lalis</tspan></tspan></text></g></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@ -6,13 +6,13 @@
<groupId>com.andrewlalis</groupId>
<artifactId>perfin</artifactId>
<version>1.0.1</version>
<version>1.19.0</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<javafx.version>21.0.1</javafx.version>
<javafx.version>21.0.2</javafx.version>
<project.main-class>com.andrewlalis.perfin.PerfinApp</project.main-class>
</properties>
@ -30,7 +30,7 @@
<dependency>
<groupId>com.andrewlalis</groupId>
<artifactId>javafx-scene-router</artifactId>
<version>1.5.1</version>
<version>1.6.0</version>
</dependency>
<dependency>

View File

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

View File

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

View File

@ -3,15 +3,22 @@ package com.andrewlalis.perfin;
import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
import com.andrewlalis.javafx_scene_router.SceneRouter;
import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.ProfileLoader;
import com.andrewlalis.perfin.view.ImageCache;
import com.andrewlalis.perfin.view.SceneUtil;
import com.andrewlalis.perfin.view.StartupSplashScreen;
import com.andrewlalis.perfin.view.component.ScrollPaneRouterView;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.text.Font;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@ -21,14 +28,24 @@ import java.util.function.Consumer;
* The class from which the JavaFX-based application starts.
*/
public class PerfinApp extends Application {
private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
/** The singleton instance of the application. */
public static PerfinApp instance;
/** The singleton profile loader for the application. */
public static ProfileLoader profileLoader;
/**
* The router that's used for navigating between different "pages" in the application.
*/
public static final SceneRouter router = new SceneRouter(new AnchorPaneRouterView(true));
/**
* A router that controls which help page is being viewed in the side-pane.
* Certain user actions may cause this router to navigate to certain pages.
*/
public static final SceneRouter helpRouter = new SceneRouter(new ScrollPaneRouterView());
public static void main(String[] args) {
launch(args);
}
@ -36,12 +53,14 @@ public class PerfinApp extends Application {
@Override
public void start(Stage stage) {
instance = this;
profileLoader = new ProfileLoader(stage, new JdbcDataSourceFactory());
loadFonts();
var splashScreen = new StartupSplashScreen(List.of(
PerfinApp::defineRoutes,
PerfinApp::initAppDir,
c -> initMainScreen(stage, c),
PerfinApp::loadLastUsedProfile
));
), false);
splashScreen.showAndWait();
if (splashScreen.isStartupSuccessful()) {
stage.show();
@ -49,38 +68,70 @@ public class PerfinApp extends Application {
}
}
/**
* Part of the app's startup, where the main scene is initialized.
* @param stage The JavaFX stage where the scene will be placed.
* @param msgConsumer A message consumer to relay status messages to the user.
*/
private void initMainScreen(Stage stage, Consumer<String> msgConsumer) {
msgConsumer.accept("Initializing main screen.");
Platform.runLater(() -> {
stage.hide();
Scene mainViewScene = SceneUtil.load("/main-view.fxml");
SceneUtil.addStylesheets(mainViewScene, "/style/base.css");
stage.setScene(mainViewScene);
stage.setTitle("Perfin");
stage.getIcons().add(ImageCache.getLogo64());
stage.getIcons().add(ImageCache.getLogo256());
});
}
private static void mapResourceRoute(String route, String resource) {
router.map(route, PerfinApp.class.getResource(resource));
}
/**
* Part of the app's startup, where all the app's routes to various views
* are defined.
* @param msgConsumer A message consumer to relay status messages to the user.
*/
private static void defineRoutes(Consumer<String> msgConsumer) {
msgConsumer.accept("Initializing application views.");
Platform.runLater(() -> {
mapResourceRoute("accounts", "/accounts-view.fxml");
mapResourceRoute("account", "/account-view.fxml");
mapResourceRoute("edit-account", "/edit-account.fxml");
mapResourceRoute("transactions", "/transactions-view.fxml");
mapResourceRoute("create-transaction", "/create-transaction.fxml");
mapResourceRoute("create-balance-record", "/create-balance-record.fxml");
// App pages.
router.map("dashboard", PerfinApp.class.getResource("/dashboard.fxml"));
router.map("accounts", PerfinApp.class.getResource("/accounts-view.fxml"));
router.map("account", PerfinApp.class.getResource("/account-view.fxml"));
router.map("edit-account", PerfinApp.class.getResource("/edit-account.fxml"));
router.map("transactions", PerfinApp.class.getResource("/transactions-view.fxml"));
router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml"));
router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml"));
router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml"));
router.map("vendors", PerfinApp.class.getResource("/vendors-view.fxml"));
router.map("edit-vendor", PerfinApp.class.getResource("/edit-vendor.fxml"));
router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
router.map("sql-console", PerfinApp.class.getResource("/sql-console-view.fxml"));
// Help pages.
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
helpRouter.map("accounts", PerfinApp.class.getResource("/help-pages/accounts-view.fxml"));
helpRouter.map("adding-an-account", PerfinApp.class.getResource("/help-pages/adding-an-account.fxml"));
helpRouter.map("transactions", PerfinApp.class.getResource("/help-pages/transactions-view.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("about", PerfinApp.class.getResource("/help-pages/about.fxml"));
helpRouter.map("sql-console", PerfinApp.class.getResource("/help-pages/sql-console.fxml"));
});
}
/**
* A part of the app's startup which ensures that the main directory exists.
* @param msgConsumer A message consumer to relay status messages to the user.
* @throws Exception If file operations fail.
*/
private static void initAppDir(Consumer<String> msgConsumer) throws Exception {
msgConsumer.accept("Validating application files.");
if (Files.notExists(APP_DIR)) {
msgConsumer.accept(APP_DIR + " doesn't exist yet. Creating it now.");
Files.createDirectory(APP_DIR);
Files.createDirectory(Profile.getProfilesDir());
} else if (Files.exists(APP_DIR) && Files.isRegularFile(APP_DIR)) {
msgConsumer.accept(APP_DIR + " is a file, when it should be a directory. Deleting it and creating new directory.");
Files.delete(APP_DIR);
@ -88,13 +139,50 @@ 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 {
msgConsumer.accept("Loading the most recent profile.");
String lastProfile = ProfileLoader.getLastProfile();
msgConsumer.accept("Loading the most recent profile: \"" + lastProfile + "\".");
try {
Profile.loadLast();
Profile.setCurrent(profileLoader.load(lastProfile));
} catch (ProfileLoadException e) {
msgConsumer.accept("Failed to load the profile: " + e.getMessage());
throw e;
}
}
/**
* Loads all application fonts from the bundled resource files.
*/
private static void loadFonts() {
List<String> fontResources = List.of(
"/font/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Regular.ttf",
"/font/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Bold.ttf",
"/font/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-Italic.ttf",
"/font/JetBrainsMono-2.304/fonts/ttf/JetBrainsMono-BoldItalic.ttf",
"/font/Roboto/Roboto-Regular.ttf",
"/font/Roboto/Roboto-Bold.ttf",
"/font/Roboto/Roboto-Italic.ttf",
"/font/Roboto/Roboto-BoldItalic.ttf"
);
for (String res : fontResources) {
URL resourceUrl = PerfinApp.class.getResource(res);
if (resourceUrl == null) {
log.warn("Font resource {} was not found.", res);
} else {
Font font = Font.loadFont(resourceUrl.toExternalForm(), 10);
if (font == null) {
log.warn("Failed to load font {}.", res);
} else {
log.trace("Loaded font: Family = {}, Name = {}, Style = {}.", font.getFamily(), font.getName(), font.getStyle());
}
}
}
}
}

View File

@ -1,91 +1,176 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import com.andrewlalis.perfin.view.component.AccountHistoryItemTile;
import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AccountHistoryView;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.DatePicker;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import javafx.scene.layout.HBox;
import javafx.scene.text.Text;
import java.time.LocalDateTime;
import java.util.List;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import static com.andrewlalis.perfin.PerfinApp.router;
public class AccountViewController implements RouteSelectionListener {
private Account account;
private final ObjectProperty<Account> accountProperty = new SimpleObjectProperty<>(null);
private final ObservableValue<Boolean> accountArchived = accountProperty.map(a -> a != null && a.isArchived());
private final StringProperty balanceTextProperty = new SimpleStringProperty(null);
private final StringProperty assetValueTextProperty = new SimpleStringProperty(null);
private final StringProperty creditLimitTextProperty = new SimpleStringProperty(null);
@FXML public Label titleLabel;
@FXML public Label accountNameLabel;
@FXML public Label accountNumberLabel;
@FXML public Label accountCurrencyLabel;
@FXML public Label accountCreatedAtLabel;
@FXML public Label accountBalanceLabel;
@FXML public BooleanProperty accountArchivedProperty = new SimpleBooleanProperty(false);
@FXML public PropertiesPane assetValuePane;
@FXML public Label latestAssetsValueLabel;
@FXML public PropertiesPane creditCardPropertiesPane;
@FXML public Label creditLimitLabel;
@FXML public PropertiesPane descriptionPane;
@FXML public Text accountDescriptionText;
@FXML public VBox historyItemsVBox;
@FXML public Button loadMoreHistoryButton;
private LocalDateTime loadHistoryFrom;
private final int historyLoadSize = 5;
@FXML public AccountHistoryView accountHistory;
@FXML public VBox actionsVBox;
@FXML public HBox actionsBox;
@FXML public DatePicker balanceCheckerDatePicker;
@FXML public Button balanceCheckerButton;
@FXML public void initialize() {
actionsVBox.getChildren().forEach(node -> {
titleLabel.textProperty().bind(accountProperty.map(a -> "Account #" + a.id));
accountNameLabel.textProperty().bind(accountProperty.map(Account::getName));
accountNumberLabel.textProperty().bind(accountProperty.map(Account::getAccountNumber));
accountCurrencyLabel.textProperty().bind(accountProperty.map(a -> a.getCurrency().getDisplayName()));
accountCreatedAtLabel.textProperty().bind(accountProperty.map(a -> DateUtil.formatUTCAsLocalWithZone(a.getCreatedAt())));
accountDescriptionText.textProperty().bind(accountProperty.map(Account::getDescription));
var hasDescription = accountProperty.map(a -> a.getDescription() != null);
BindingUtil.bindManagedAndVisible(descriptionPane, hasDescription);
accountBalanceLabel.textProperty().bind(balanceTextProperty);
var isBrokerageAccount = accountProperty.map(a -> a.getType() == AccountType.BROKERAGE);
BindingUtil.bindManagedAndVisible(assetValuePane, isBrokerageAccount);
latestAssetsValueLabel.textProperty().bind(assetValueTextProperty);
var isCreditCardAccount = accountProperty.map(a -> a.getType() == AccountType.CREDIT_CARD);
BindingUtil.bindManagedAndVisible(creditCardPropertiesPane, isCreditCardAccount);
creditLimitLabel.textProperty().bind(creditLimitTextProperty);
actionsBox.getChildren().forEach(node -> {
Button button = (Button) node;
BooleanExpression buttonActive = accountArchivedProperty;
ObservableValue<Boolean> buttonDisabled = accountArchived;
if (button.getText().equalsIgnoreCase("Unarchive")) {
buttonActive = buttonActive.not();
buttonDisabled = BooleanExpression.booleanExpression(buttonDisabled).not();
}
button.disableProperty().bind(buttonActive);
if (button.getText().equalsIgnoreCase("Record Asset Value")) {
buttonDisabled = BooleanExpression.booleanExpression(
accountProperty.map(Account::getType)
.map(t -> !t.equals(AccountType.BROKERAGE))
).or(BooleanExpression.booleanExpression(accountArchived));
}
button.disableProperty().bind(buttonDisabled);
button.managedProperty().bind(button.visibleProperty());
button.visibleProperty().bind(button.disableProperty().not());
});
var datePickerValid = new ValidationApplier<>(new PredicateValidator<LocalDate>()
.addPredicate(date -> date.isBefore(LocalDate.now()), "Date must be in the past.")
).attach(balanceCheckerDatePicker, balanceCheckerDatePicker.valueProperty());
balanceCheckerButton.disableProperty().bind(datePickerValid.not());
balanceCheckerButton.setOnAction(event -> {
LocalDate date = balanceCheckerDatePicker.getValue();
final Instant timestamp = date.atStartOfDay(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC)
.toInstant();
Profile.getCurrent().dataSource().mapRepoAsync(
AccountRepository.class,
repo -> repo.deriveCashBalance(getAccount().id, timestamp)
).thenAccept(balance -> Platform.runLater(() -> {
String msg = String.format(
"Your balance as of %s is %s, according to Perfin's data.",
date,
CurrencyUtil.formatMoney(new MoneyValue(balance, getAccount().getCurrency()))
);
Popups.message(balanceCheckerButton, msg);
}));
});
}
@Override
public void onRouteSelected(Object context) {
account = (Account) context;
accountArchivedProperty.set(account.isArchived());
titleLabel.setText("Account #" + account.id);
accountNameLabel.setText(account.getName());
accountNumberLabel.setText(account.getAccountNumber());
accountCurrencyLabel.setText(account.getCurrency().getDisplayName());
accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
Profile.getCurrent().getDataSource().getAccountBalanceText(account, accountBalanceLabel::setText);
reloadHistory();
}
public void reloadHistory() {
loadHistoryFrom = DateUtil.nowAsUTC();
historyItemsVBox.getChildren().clear();
loadMoreHistoryButton.setDisable(false);
loadMoreHistory();
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
public void goToEditPage() {
router.navigate("edit-account", account);
router.navigate("edit-account", getAccount());
}
@FXML public void goToCreateBalanceRecord() {
router.navigate("create-balance-record", account);
router.navigate("create-balance-record", new CreateBalanceRecordController.RouteContext(getAccount(), BalanceRecordType.CASH));
}
@FXML public void goToCreateAssetRecord() {
router.navigate("create-balance-record", new CreateBalanceRecordController.RouteContext(getAccount(), BalanceRecordType.ASSETS));
}
@FXML
public void archiveAccount() {
boolean confirmResult = Popups.confirm(
titleLabel,
"Are you sure you want to archive this account? It will no " +
"longer show up in the app normally, and you won't be " +
"able to add new transactions to it. You'll still be " +
@ -93,27 +178,27 @@ public class AccountViewController implements RouteSelectionListener {
"later if you need to."
);
if (confirmResult) {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> repo.archive(account.id));
router.getHistory().clear();
router.navigate("accounts");
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(getAccount().id));
router.replace("accounts");
}
}
@FXML public void unarchiveAccount() {
boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to restore this account from its archived " +
"status?"
);
if (confirm) {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> repo.unarchive(account.id));
router.getHistory().clear();
router.navigate("accounts");
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(getAccount().id));
router.replace("accounts");
}
}
@FXML
public void deleteAccount() {
boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to permanently delete this account and " +
"all data directly associated with it? This cannot be " +
"undone; deleted accounts are not recoverable at all. " +
@ -121,32 +206,12 @@ public class AccountViewController implements RouteSelectionListener {
"want to hide it."
);
if (confirm) {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> repo.delete(account));
router.getHistory().clear();
router.navigate("accounts");
Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(getAccount()));
router.replace("accounts");
}
}
@FXML public void loadMoreHistory() {
Thread.ofVirtual().start(() -> {
try (var historyRepo = Profile.getCurrent().getDataSource().getAccountHistoryItemRepository()) {
List<AccountHistoryItem> historyItems = historyRepo.findMostRecentForAccount(
account.id,
loadHistoryFrom,
historyLoadSize
);
if (historyItems.size() < historyLoadSize) {
Platform.runLater(() -> loadMoreHistoryButton.setDisable(true));
} else {
loadHistoryFrom = historyItems.getLast().getTimestamp();
}
List<? extends Node> nodes = historyItems.stream()
.map(item -> AccountHistoryItemTile.forItem(item, historyRepo, this))
.toList();
Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
private Account getAccount() {
return accountProperty.get();
}
}

View File

@ -1,9 +1,9 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.AccountTile;
import javafx.application.Platform;
@ -48,23 +48,17 @@ public class AccountsViewController implements RouteSelectionListener {
public void refreshAccounts() {
Profile.whenLoaded(profile -> {
Thread.ofVirtual().start(() -> profile.getDataSource().useAccountRepository(repo -> {
profile.dataSource().useRepoAsync(AccountRepository.class, repo -> {
List<Account> accounts = repo.findAllOrderedByRecentHistory();
Platform.runLater(() -> accountsPane.getChildren()
.setAll(accounts.stream()
.map(AccountTile::new)
.toList()
));
}));
// Compute grand totals!
Thread.ofVirtual().start(() -> {
var totals = profile.getDataSource().getCombinedAccountBalances();
StringBuilder sb = new StringBuilder("Totals: ");
for (var entry : totals.entrySet()) {
sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey())));
}
Platform.runLater(() -> totalLabel.setText(sb.toString().strip()));
});
profile.dataSource().getCombinedAccountBalances()
.thenApply(CurrencyUtil::formatMoneyValues)
.thenAccept(s -> Platform.runLater(() -> totalLabel.setText("Totals: " + s)));
});
}

View File

@ -0,0 +1,62 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import java.util.List;
import static com.andrewlalis.perfin.PerfinApp.router;
/**
* Controller for the page which shows an overview of a balance record.
*/
public class BalanceRecordViewController implements RouteSelectionListener {
private BalanceRecord balanceRecord;
@FXML public Label titleLabel;
@FXML public Label typeLabel;
@FXML public Label timestampLabel;
@FXML public Label balanceLabel;
@FXML public Label currencyLabel;
@FXML public AttachmentsViewPane attachmentsViewPane;
@FXML public void initialize() {
attachmentsViewPane.hideIfEmpty();
}
@Override
public void onRouteSelected(Object context) {
this.balanceRecord = (BalanceRecord) context;
if (balanceRecord == null) return;
titleLabel.setText("Balance Record #" + balanceRecord.id);
typeLabel.setText(balanceRecord.getType().toString());
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
Profile.getCurrent().dataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
List<Attachment> attachments = repo.findAttachments(balanceRecord.id);
Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments));
});
}
@FXML public void delete() {
boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin."
);
if (confirm) {
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
router.navigateBackAndClear();
}
}
}

View File

@ -0,0 +1,63 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.CategoryTile;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.layout.VBox;
import java.io.IOException;
import java.io.UncheckedIOException;
import static com.andrewlalis.perfin.PerfinApp.router;
public class CategoriesViewController implements RouteSelectionListener {
@FXML public VBox categoriesVBox;
private final ObservableList<TransactionCategoryRepository.CategoryTreeNode> categoryTreeNodes = FXCollections.observableArrayList();
@FXML public void initialize() {
BindingUtil.mapContent(categoriesVBox.getChildren(), categoryTreeNodes, node -> new CategoryTile(node, this::refreshCategories));
}
@Override
public void onRouteSelected(Object context) {
refreshCategories();
}
@FXML public void addCategory() {
router.navigate("edit-category");
}
private void refreshCategories() {
Profile.getCurrent().dataSource().mapRepoAsync(
TransactionCategoryRepository.class,
TransactionCategoryRepository::findTree
).thenAccept(nodes -> Platform.runLater(() -> categoryTreeNodes.setAll(nodes)));
}
@FXML public void addDefaultCategories() {
boolean confirm = Popups.confirm(categoriesVBox, "Are you sure you want to add all of Perfin's default categories to your profile? This might interfere with existing categories of the same name.");
if (!confirm) return;
JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
try (var conn = ds.getConnection()) {
DbUtil.doTransaction(conn, () -> {
try {
new JdbcDataSourceFactory().insertDefaultCategories(conn);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
refreshCategories();
} catch (Exception e) {
Popups.error(categoriesVBox, e);
}
}
}

View File

@ -1,69 +1,155 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.BalanceRecordType;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.VBox;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import static com.andrewlalis.perfin.PerfinApp.router;
/**
* Controller for the page where users can create a balance record for an
* account.
*/
public class CreateBalanceRecordController implements RouteSelectionListener {
public record RouteContext (Account account, BalanceRecordType type) {}
@FXML public TextField timestampField;
@FXML public TextField balanceField;
@FXML public VBox attachmentsVBox;
private FileSelectionArea attachmentSelectionArea;
@FXML public Label balanceWarningLabel;
@FXML public FileSelectionArea attachmentSelectionArea;
@FXML public PropertiesPane propertiesPane;
@FXML public Button saveButton;
private Account account;
private BalanceRecordType type = BalanceRecordType.CASH;
@FXML public void initialize() {
attachmentSelectionArea = new FileSelectionArea(FileUtil::newAttachmentsFileChooser, () -> attachmentsVBox.getScene().getWindow());
attachmentSelectionArea.allowMultiple.set(true);
attachmentsVBox.getChildren().add(attachmentSelectionArea);
var timestampValid = new ValidationApplier<>((ValidationFunction<String>) input -> {
try {
DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
return ValidationResult.valid();
} catch (DateTimeParseException e) {
return ValidationResult.of("Invalid timestamp format.");
}
}).validatedInitially().attachToTextField(timestampField);
var balanceValidator = new CurrencyAmountValidator(() -> account == null ? null : account.getCurrency(), true, false);
var balanceValid = new ValidationApplier<>(balanceValidator)
.validatedInitially().attachToTextField(balanceField);
balanceWarningLabel.managedProperty().bind(balanceWarningLabel.visibleProperty());
balanceWarningLabel.visibleProperty().set(false);
balanceField.textProperty().addListener((observable, oldValue, newValue) -> {
if (!balanceValidator.validate(newValue).isValid() || !timestampValid.get() || type != BalanceRecordType.CASH) {
balanceWarningLabel.visibleProperty().set(false);
return;
}
BigDecimal reportedBalance = new BigDecimal(newValue);
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
LocalDateTime utcTimestamp = DateUtil.localToUTC(localTimestamp);
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
BigDecimal derivedBalance = repo.deriveCashBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC));
boolean balancesMatch = reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance);
Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(!balancesMatch));
});
});
var formValid = timestampValid.and(balanceValid);
saveButton.disableProperty().bind(formValid.not());
}
@Override
public void onRouteSelected(Object context) {
this.account = (Account) context;
RouteContext ctx = (RouteContext) context;
this.account = ctx.account();
this.type = ctx.type();
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
BigDecimal value = repo.deriveCurrentBalance(account.id);
balanceField.setText(null);
if (ctx.type() == BalanceRecordType.CASH) {
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
BigDecimal value = repo.deriveCurrentCashBalance(account.id);
Platform.runLater(() -> balanceField.setText(
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
));
});
});
}
attachmentSelectionArea.clear();
}
@FXML public void save() {
// TODO: Add validation.
Profile.getCurrent().getDataSource().useBalanceRecordRepository(repo -> {
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
repo.insert(
DateUtil.localToUTC(localTimestamp),
account.id,
reportedBalance,
account.getCurrency(),
attachmentSelectionArea.getSelectedFiles()
);
});
router.navigateBackAndClear();
LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT);
BigDecimal reportedBalance = new BigDecimal(balanceField.getText());
String valueNoun = switch (type) {
case CASH -> "cash balance";
case ASSETS -> "asset value";
};
boolean confirm = Popups.confirm(timestampField, "Are you sure that you want to record the %s of account\n%s\nas %s,\nas of %s?".formatted(
valueNoun,
account.getShortName(),
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())),
localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
));
if (
confirm &&
(type != BalanceRecordType.CASH || confirmIfInconsistentBalance(reportedBalance, DateUtil.localToUTC(localTimestamp)))
) {
Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
repo.insert(
DateUtil.localToUTC(localTimestamp),
account.id,
type,
reportedBalance,
account.getCurrency(),
attachmentSelectionArea.getSelectedPaths()
);
});
router.navigateBackAndClear();
}
}
@FXML public void cancel() {
router.navigateBackAndClear();
}
private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance, LocalDateTime utcTimestamp) {
BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
AccountRepository.class,
repo -> repo.deriveCashBalance(account.id, utcTimestamp.toInstant(ZoneOffset.UTC))
);
if (!reportedBalance.setScale(currentDerivedBalance.scale(), RoundingMode.HALF_UP).equals(currentDerivedBalance)) {
String msg = "The balance you reported (%s) doesn't match the balance that Perfin derived from your account's transactions (%s). It's encouraged to go back and add any missing transactions first, but you may proceed now if you understand the consequences of an inconsistent account balance history.\n\nAre you absolutely sure you want to create this balance record?".formatted(
CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())),
CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency()))
);
return Popups.confirm(timestampField, msg);
}
return true;
}
}

View File

@ -1,231 +0,0 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
import com.andrewlalis.perfin.view.component.FileSelectionArea;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Currency;
import java.util.List;
import java.util.stream.Collectors;
import static com.andrewlalis.perfin.PerfinApp.router;
public class CreateTransactionController implements RouteSelectionListener {
@FXML public TextField timestampField;
@FXML public Label timestampInvalidLabel;
@FXML public Label timestampFutureLabel;
@FXML public TextField amountField;
@FXML public ChoiceBox<Currency> currencyChoiceBox;
@FXML public TextArea descriptionField;
@FXML public Label descriptionErrorLabel;
@FXML public ComboBox<Account> linkDebitAccountComboBox;
@FXML public ComboBox<Account> linkCreditAccountComboBox;
@FXML public Label linkedAccountsErrorLabel;
@FXML public VBox attachmentsVBox;
private FileSelectionArea attachmentsSelectionArea;
@FXML public void initialize() {
// Setup error field validation.
timestampInvalidLabel.managedProperty().bind(timestampInvalidLabel.visibleProperty());
timestampFutureLabel.managedProperty().bind(timestampFutureLabel.visibleProperty());
timestampField.textProperty().addListener((observable, oldValue, newValue) -> {
LocalDateTime parsedTimestamp = parseTimestamp();
timestampInvalidLabel.setVisible(parsedTimestamp == null);
timestampFutureLabel.setVisible(parsedTimestamp != null && parsedTimestamp.isAfter(LocalDateTime.now()));
});
descriptionErrorLabel.managedProperty().bind(descriptionErrorLabel.visibleProperty());
descriptionErrorLabel.visibleProperty().bind(descriptionErrorLabel.textProperty().isNotEmpty());
descriptionField.textProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null && newValue.length() > 255) {
descriptionErrorLabel.setText("Description is too long.");
} else {
descriptionErrorLabel.setText(null);
}
});
linkedAccountsErrorLabel.managedProperty().bind(linkedAccountsErrorLabel.visibleProperty());
linkedAccountsErrorLabel.visibleProperty().bind(linkedAccountsErrorLabel.textProperty().isNotEmpty());
linkDebitAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated());
linkCreditAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated());
// Update the lists of accounts available for linking based on the selected currency.
var cellFactory = new AccountComboBoxCellFactory();
linkDebitAccountComboBox.setCellFactory(cellFactory);
linkDebitAccountComboBox.setButtonCell(cellFactory.call(null));
linkCreditAccountComboBox.setCellFactory(cellFactory);
linkCreditAccountComboBox.setButtonCell(cellFactory.call(null));
currencyChoiceBox.valueProperty().addListener((observable, oldValue, newValue) -> {
updateLinkAccountComboBoxes(newValue);
});
// Initialize the file selection area.
attachmentsSelectionArea = new FileSelectionArea(
FileUtil::newAttachmentsFileChooser,
() -> attachmentsVBox.getScene().getWindow()
);
attachmentsSelectionArea.allowMultiple.set(true);
attachmentsVBox.getChildren().add(attachmentsSelectionArea);
}
@FXML public void save() {
var validationMessages = validateFormData();
if (!validationMessages.isEmpty()) {
Alert alert = new Alert(
Alert.AlertType.WARNING,
"There are some issues with your data:\n\n" +
validationMessages.stream()
.map(s -> "- " + s)
.collect(Collectors.joining("\n\n"))
);
alert.show();
} else {
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
BigDecimal amount = new BigDecimal(amountField.getText());
Currency currency = currencyChoiceBox.getValue();
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
List<Path> attachments = attachmentsSelectionArea.getSelectedFiles();
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
repo.insert(
utcTimestamp,
amount,
currency,
description,
linkedAccounts,
attachments
);
});
router.navigateBackAndClear();
}
}
@FXML public void cancel() {
router.navigateBackAndClear();
}
@Override
public void onRouteSelected(Object context) {
resetForm();
}
private void resetForm() {
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
amountField.setText("0");
descriptionField.setText(null);
attachmentsSelectionArea.clear();
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
var currencies = repo.findAllUsedCurrencies().stream()
.sorted(Comparator.comparing(Currency::getCurrencyCode))
.toList();
Platform.runLater(() -> {
currencyChoiceBox.getItems().setAll(currencies);
currencyChoiceBox.getSelectionModel().selectFirst();
});
});
});
}
private CreditAndDebitAccounts getSelectedAccounts() {
return new CreditAndDebitAccounts(
linkCreditAccountComboBox.getValue(),
linkDebitAccountComboBox.getValue()
);
}
private LocalDateTime parseTimestamp() {
List<DateTimeFormatter> formatters = List.of(
DateTimeFormatter.ISO_LOCAL_DATE_TIME,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("d/M/yyyy H:mm:ss")
);
for (var formatter : formatters) {
try {
return formatter.parse(timestampField.getText(), LocalDateTime::from);
} catch (DateTimeException e) {
// Ignore.
}
}
return null;
}
private void updateLinkAccountComboBoxes(Currency currency) {
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
List<Account> availableAccounts = new ArrayList<>();
if (currency != null) availableAccounts.addAll(repo.findAllByCurrency(currency));
availableAccounts.add(null);
Platform.runLater(() -> {
linkDebitAccountComboBox.getItems().clear();
linkDebitAccountComboBox.getItems().addAll(availableAccounts);
linkDebitAccountComboBox.getSelectionModel().selectLast();
linkDebitAccountComboBox.getButtonCell().updateIndex(availableAccounts.size() - 1);
linkCreditAccountComboBox.getItems().clear();
linkCreditAccountComboBox.getItems().addAll(availableAccounts);
linkCreditAccountComboBox.getSelectionModel().selectLast();
linkCreditAccountComboBox.getButtonCell().updateIndex(availableAccounts.size() - 1);
});
});
});
}
private void onLinkedAccountsUpdated() {
Account debitAccount = linkDebitAccountComboBox.getValue();
Account creditAccount = linkCreditAccountComboBox.getValue();
if (debitAccount == null && creditAccount == null) {
linkedAccountsErrorLabel.setText("At least one credit or debit account must be linked to the transaction for it to have any effect.");
} else if (debitAccount != null && debitAccount.equals(creditAccount)) {
linkedAccountsErrorLabel.setText("Cannot link the same account to both credit and debit.");
} else {
linkedAccountsErrorLabel.setText(null);
}
}
private List<String> validateFormData() {
List<String> errorMessages = new ArrayList<>();
if (parseTimestamp() == null) errorMessages.add("Invalid or missing timestamp.");
if (descriptionField.getText() != null && descriptionField.getText().strip().length() > 255) {
errorMessages.add("Description is too long.");
}
try {
BigDecimal value = new BigDecimal(amountField.getText());
if (value.compareTo(BigDecimal.ZERO) <= 0) {
errorMessages.add("Amount should be a positive number.");
}
} catch (NumberFormatException e) {
errorMessages.add("Invalid or missing amount.");
}
Account debitAccount = linkDebitAccountComboBox.getValue();
Account creditAccount = linkCreditAccountComboBox.getValue();
if (debitAccount == null && creditAccount == null) {
errorMessages.add("At least one account must be linked to this transaction.");
}
if (debitAccount != null && debitAccount.equals(creditAccount)) {
errorMessages.add("Credit and debit accounts cannot be the same.");
}
return errorMessages;
}
}

View File

@ -0,0 +1,49 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.component.module.TotalAssetsGraphModule;
import com.andrewlalis.perfin.view.component.module.*;
import javafx.fxml.FXML;
import javafx.geometry.Bounds;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.FlowPane;
public class DashboardController implements RouteSelectionListener {
@FXML public ScrollPane modulesScrollPane;
@FXML public FlowPane modulesFlowPane;
@FXML public void initialize() {
var viewportWidth = modulesScrollPane.viewportBoundsProperty().map(Bounds::getWidth);
modulesFlowPane.minWidthProperty().bind(viewportWidth);
modulesFlowPane.prefWidthProperty().bind(viewportWidth);
modulesFlowPane.maxWidthProperty().bind(viewportWidth);
var accountsModule = new AccountsModule(modulesFlowPane);
accountsModule.columnsProperty.set(2);
var transactionsModule = new RecentTransactionsModule(modulesFlowPane);
transactionsModule.columnsProperty.set(2);
var m3 = new SpendingCategoryChartModule(modulesFlowPane);
m3.columnsProperty.set(2);
var m4 = new VendorSpendChartModule(modulesFlowPane);
m4.columnsProperty.set(2);
var m5 = new TotalAssetsGraphModule(modulesFlowPane);
m5.columnsProperty.set(1);
modulesFlowPane.getChildren().addAll(accountsModule, transactionsModule, m3, m4, m5);
}
@Override
public void onRouteSelected(Object context) {
Profile.whenLoaded(profile -> {
for (var child : modulesFlowPane.getChildren()) {
DashboardModule module = (DashboardModule) child;
module.refreshContents();
}
});
}
}

View File

@ -1,17 +1,20 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.fxml.FXML;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.VBox;
import javafx.scene.control.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -30,23 +33,55 @@ public class EditAccountController implements RouteSelectionListener {
private Account account;
private final BooleanProperty creatingNewAccount = new SimpleBooleanProperty(false);
@FXML
public Label titleLabel;
@FXML
public TextField accountNameField;
@FXML
public TextField accountNumberField;
@FXML
public ComboBox<Currency> accountCurrencyComboBox;
@FXML
public ChoiceBox<AccountType> accountTypeChoiceBox;
@FXML
public VBox initialBalanceContent;
@FXML
public TextField initialBalanceField;
@FXML public Label titleLabel;
@FXML public TextField accountNameField;
@FXML public TextField accountNumberField;
@FXML public ComboBox<Currency> accountCurrencyComboBox;
@FXML public ChoiceBox<AccountType> accountTypeChoiceBox;
@FXML public TextField creditLimitField;
@FXML public TextArea descriptionField;
@FXML public PropertiesPane initialBalanceContent;
@FXML public TextField initialBalanceField;
@FXML public Button saveButton;
@FXML
public void initialize() {
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name should not be empty.")
.addPredicate(s -> s.strip().length() <= 63, "Name is too long.")
).attachToTextField(accountNameField);
var numberValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Account number should not be empty.")
.addPredicate(s -> s.strip().length() <= 255, "Account number is too long.")
).attachToTextField(accountNumberField);
var balanceValid = new ValidationApplier<>(
new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), true, false)
).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty());
var isEditingCreditCardAccount = accountTypeChoiceBox.valueProperty().isEqualTo(AccountType.CREDIT_CARD);
BindingUtil.bindManagedAndVisible(creditLimitField, isEditingCreditCardAccount);
var creditLimitValid = new ValidationApplier<>(new CurrencyAmountValidator(
() -> accountCurrencyComboBox.getValue(),
false,
true
)).validatedInitially().attachToTextField(creditLimitField)
.or(isEditingCreditCardAccount.not());
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addPredicate(s -> s == null || s.strip().length() <= Account.DESCRIPTION_MAX_LENGTH, "Description is too long.")
).attach(descriptionField, descriptionField.textProperty());
// Combine validity of all fields for an expression that determines if the whole form is valid.
BooleanExpression formValid = nameValid
.and(numberValid)
.and(balanceValid.or(creatingNewAccount.not()))
.and(descriptionValid)
.and(creditLimitValid);
saveButton.disableProperty().bind(formValid.not());
List<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
.map(Currency::getInstance)
.toList();
@ -63,6 +98,7 @@ public class EditAccountController implements RouteSelectionListener {
accountTypeChoiceBox.getItems().add(AccountType.CHECKING);
accountTypeChoiceBox.getItems().add(AccountType.SAVINGS);
accountTypeChoiceBox.getItems().add(AccountType.CREDIT_CARD);
accountTypeChoiceBox.getItems().add(AccountType.BROKERAGE);
accountTypeChoiceBox.getSelectionModel().select(AccountType.CHECKING);
initialBalanceContent.visibleProperty().bind(creatingNewAccount);
@ -83,42 +119,56 @@ public class EditAccountController implements RouteSelectionListener {
@FXML
public void save() {
String name = accountNameField.getText().strip();
String number = accountNumberField.getText().strip();
AccountType type = accountTypeChoiceBox.getValue();
Currency currency = accountCurrencyComboBox.getValue();
String description = descriptionField.getText();
if (description != null) {
description = description.strip();
if (description.isBlank()) description = null;
}
BigDecimal creditLimit = null;
if (type == AccountType.CREDIT_CARD && creditLimitField.getText() != null && !creditLimitField.getText().isBlank()) {
creditLimit = new BigDecimal(creditLimitField.getText());
}
try (
var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
var balanceRepo = Profile.getCurrent().getDataSource().getBalanceRecordRepository()
var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
) {
if (creatingNewAccount.get()) {
String name = accountNameField.getText().strip();
String number = accountNumberField.getText().strip();
AccountType type = accountTypeChoiceBox.getValue();
Currency currency = accountCurrencyComboBox.getValue();
BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip());
List<Path> attachments = Collections.emptyList();
boolean success = Popups.confirm("Are you sure you want to create this account?");
String prompt = String.format(
"Are you sure you want to create this account?\nName: %s\nNumber: %s\nType: %s\nInitial Balance: %s",
name,
number,
type.toString(),
CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(initialBalance, currency))
);
boolean success = Popups.confirm(accountNameField, prompt);
if (success) {
long id = accountRepo.insert(type, number, name, currency);
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments);
long id = accountRepo.insert(type, number, name, currency, description);
balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, BalanceRecordType.CASH, initialBalance, currency, attachments);
if (type == AccountType.CREDIT_CARD && creditLimit != null) {
accountRepo.saveCreditCardProperties(new CreditCardProperties(id, creditLimit));
}
// Once we create the new account, go to the account.
Account newAccount = accountRepo.findById(id).orElseThrow();
router.getHistory().clear();
router.navigate("account", newAccount);
router.replace("account", newAccount);
}
} else {
log.debug("Updating account {}", account.id);
account.setName(accountNameField.getText().strip());
account.setAccountNumber(accountNumberField.getText().strip());
account.setType(accountTypeChoiceBox.getValue());
account.setCurrency(accountCurrencyComboBox.getValue());
accountRepo.update(account);
accountRepo.update(account.id, type, number, name, currency, description);
if (type == AccountType.CREDIT_CARD) {
accountRepo.saveCreditCardProperties(new CreditCardProperties(account.id, creditLimit));
}
Account updatedAccount = accountRepo.findById(account.id).orElseThrow();
router.getHistory().clear();
router.navigate("account", updatedAccount);
router.replace("account", updatedAccount);
}
} catch (Exception e) {
log.error("Failed to save (or update) account " + account.id, e);
Popups.error("Failed to save the account: " + e.getMessage());
Popups.error(accountNameField, "Failed to save the account: " + e.getMessage());
}
}
@ -134,11 +184,29 @@ public class EditAccountController implements RouteSelectionListener {
accountTypeChoiceBox.getSelectionModel().selectFirst();
accountCurrencyComboBox.getSelectionModel().select(Currency.getInstance("USD"));
initialBalanceField.setText(String.format("%.02f", 0f));
descriptionField.setText(null);
creditLimitField.setText(null);
} else {
accountNameField.setText(account.getName());
accountNumberField.setText(account.getAccountNumber());
accountTypeChoiceBox.getSelectionModel().select(account.getType());
accountCurrencyComboBox.getSelectionModel().select(account.getCurrency());
descriptionField.setText(account.getDescription());
// Fetch the account's credit limit if it's a credit card account.
if (account.getType() == AccountType.CREDIT_CARD) {
Profile.getCurrent().dataSource().mapRepoAsync(
AccountRepository.class,
repo -> repo.getCreditCardProperties(account.id)
).thenAccept(props -> Platform.runLater(() -> {
if (props != null && props.creditLimit() != null) {
creditLimitField.setText(CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(props.creditLimit(), account.getCurrency())));
} else {
creditLimitField.setText(null);
}
}));
}
}
}
}

View File

@ -0,0 +1,108 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.TransactionCategory;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.TextField;
import javafx.scene.paint.Color;
import java.util.concurrent.CompletableFuture;
import static com.andrewlalis.perfin.PerfinApp.router;
public class EditCategoryController implements RouteSelectionListener {
public record CategoryRouteContext(TransactionCategory category) implements RouteContext {}
public record AddSubcategoryRouteContext(TransactionCategory parent) implements RouteContext {}
private sealed interface RouteContext permits AddSubcategoryRouteContext, CategoryRouteContext {}
private TransactionCategory category;
private TransactionCategory parent;
@FXML public TextField nameField;
@FXML public ColorPicker colorPicker;
@FXML public Button saveButton;
@FXML public void initialize() {
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
.addPredicate(s -> s.strip().length() <= TransactionCategory.NAME_MAX_LENGTH, "Name is too long.")
.addAsyncPredicate(
s -> {
if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
return Profile.getCurrent().dataSource().mapRepoAsync(
TransactionCategoryRepository.class,
repo -> {
var categoryByName = repo.findByName(s).orElse(null);
if (this.category != null) {
return this.category.equals(categoryByName) || categoryByName == null;
}
return categoryByName == null;
}
);
},
"Category with this name already exists."
)
).validatedInitially().attachToTextField(nameField);
saveButton.disableProperty().bind(nameValid.not());
}
@Override
public void onRouteSelected(Object context) {
this.category = null;
this.parent = null;
if (context instanceof RouteContext ctx) {
switch (ctx) {
case CategoryRouteContext(var cat):
this.category = cat;
nameField.setText(cat.getName());
colorPicker.setValue(cat.getColor());
break;
case AddSubcategoryRouteContext(var par):
this.parent = par;
nameField.setText(null);
colorPicker.setValue(parent.getColor());
break;
}
} else {
nameField.setText(null);
colorPicker.setValue(Color.WHITE);
}
}
@FXML public void save() {
final String name = nameField.getText().strip();
final Color color = colorPicker.getValue();
if (this.category == null && this.parent == null) {
// New top-level category.
Profile.getCurrent().dataSource().useRepo(
TransactionCategoryRepository.class,
repo -> repo.insert(name, color)
);
} else if (this.category == null) {
// New subcategory.
Profile.getCurrent().dataSource().useRepo(
TransactionCategoryRepository.class,
repo -> repo.insert(parent.id, name, color)
);
} else if (this.parent == null) {
// Save edits to an existing category.
Profile.getCurrent().dataSource().useRepo(
TransactionCategoryRepository.class,
repo -> repo.update(category.id, name, color)
);
}
router.replace("categories");
}
@FXML public void cancel() {
router.navigateBackAndClear();
}
}

View File

@ -0,0 +1,551 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.CategorySelectionBox;
import com.andrewlalis.perfin.view.component.FileSelectionArea;
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.DateTimeException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import static com.andrewlalis.perfin.PerfinApp.router;
/**
* Controller for the "edit-transaction" view, which is where the user can
* create or edit transactions.
*/
public class EditTransactionController implements RouteSelectionListener {
private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class);
@FXML public BorderPane container;
@FXML public Label titleLabel;
@FXML public TextField timestampField;
@FXML public TextField amountField;
@FXML public ChoiceBox<Currency> currencyChoiceBox;
private final BooleanProperty basicTransactionInfoValid = new SimpleBooleanProperty(false);
@FXML public TextArea descriptionField;
@FXML public HBox linkedAccountsContainer;
@FXML public AccountSelectionBox debitAccountSelector;
@FXML public AccountSelectionBox creditAccountSelector;
@FXML public ComboBox<String> vendorComboBox;
@FXML public Hyperlink vendorsHyperlink;
@FXML public CategorySelectionBox categoryComboBox;
@FXML public Hyperlink categoriesHyperlink;
@FXML public ComboBox<String> tagsComboBox;
@FXML public Hyperlink tagsHyperlink;
@FXML public Button addTagButton;
@FXML public VBox tagsVBox;
private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
@FXML public Spinner<Integer> lineItemQuantitySpinner;
@FXML public TextField lineItemValueField;
@FXML public TextField lineItemDescriptionField;
@FXML public CategorySelectionBox lineItemCategoryComboBox;
@FXML public Button addLineItemButton;
@FXML public VBox addLineItemForm;
@FXML public Button addLineItemAddButton;
@FXML public Button addLineItemCancelButton;
@FXML public VBox lineItemsVBox;
@FXML public Label lineItemsValueMatchLabel;
@FXML public Button lineItemsAmountSyncButton;
@FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false);
private final ObservableList<TransactionLineItem> lineItems = FXCollections.observableArrayList();
private static long tmpLineItemId = -1L;
@FXML public FileSelectionArea attachmentsSelectionArea;
@FXML public Button saveButton;
private Transaction transaction;
@FXML public void initialize() {
// Setup error field validation.
var timestampValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> parseTimestamp() != null, "Invalid timestamp.")
.addPredicate(s -> {
LocalDateTime ts = parseTimestamp();
return ts != null && ts.isBefore(LocalDateTime.now());
}, "Timestamp cannot be in the future.")
).validatedInitially().attachToTextField(timestampField);
var amountValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false) {
@Override
public ValidationResult validate(String input) {
var r = super.validate(input);
if (!r.isValid()) return r;
// Check that this amount is enough to cover the total of any line items.
BigDecimal lineItemsTotal = lineItems.stream().map(TransactionLineItem::getTotalValue)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal transactionAmount = new BigDecimal(input);
if (transactionAmount.compareTo(lineItemsTotal) < 0) {
String msg = String.format(
"Amount must be at least %s to account for line items.",
CurrencyUtil.formatMoney(new MoneyValue(lineItemsTotal, currencyChoiceBox.getValue()))
);
return ValidationResult.of(msg);
}
return ValidationResult.valid();
}
}).validatedInitially().attachToTextField(
amountField,
currencyChoiceBox.valueProperty(),
new SimpleListProperty<>(lineItems)
);
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
var linkedAccountsValid = initializeLinkedAccountsValidationUi();
initializeTagSelectionUi();
initializeLineItemsUi();
initializeDuplicateTransactionUi();
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
tagsHyperlink.setOnAction(event -> router.navigate("tags"));
basicTransactionInfoValid.bind(timestampValid.and(amountValid).and(currencyChoiceBox.valueProperty().isNotNull()));
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not());
}
@FXML public void save() {
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
BigDecimal amount = new BigDecimal(amountField.getText());
Currency currency = currencyChoiceBox.getValue();
String description = getSanitizedDescription();
CreditAndDebitAccounts linkedAccounts = getSelectedAccounts();
String vendor = vendorComboBox.getValue();
String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName();
Set<String> tags = new HashSet<>(selectedTags);
List<Path> newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths();
List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
final long idToNavigate;
if (transaction == null) {
idToNavigate = Profile.getCurrent().dataSource().mapRepo(
TransactionRepository.class,
repo -> repo.insert(
utcTimestamp,
amount,
currency,
description,
linkedAccounts,
vendor,
category,
tags,
lineItems,
newAttachmentPaths
)
);
} else {
Profile.getCurrent().dataSource().useRepo(
TransactionRepository.class,
repo -> repo.update(
transaction.id,
utcTimestamp,
amount,
currency,
description,
linkedAccounts,
vendor,
category,
tags,
lineItems,
existingAttachments,
newAttachmentPaths
)
);
idToNavigate = transaction.id;
}
router.replace("transactions", new TransactionsViewController.RouteContext(idToNavigate));
}
@FXML public void cancel() {
router.navigateBackAndClear();
}
@Override
public void onRouteSelected(Object context) {
transaction = (Transaction) context;
// Clear some initial fields immediately:
tagsComboBox.setValue(null);
vendorComboBox.setValue(null);
categoryComboBox.select(null);
addingLineItemProperty.set(false);
if (transaction == null) {
titleLabel.setText("Create New Transaction");
timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
amountField.setText(null);
descriptionField.setText(null);
} else {
titleLabel.setText("Edit Transaction #" + transaction.id);
timestampField.setText(DateUtil.formatUTCAsLocal(transaction.getTimestamp()));
amountField.setText(CurrencyUtil.formatMoneyAsBasicNumber(transaction.getMoneyAmount()));
descriptionField.setText(transaction.getDescription());
}
// Fetch some account-specific data.
container.setDisable(true);
DataSource ds = Profile.getCurrent().dataSource();
Thread.ofVirtual().start(() -> {
try (
var accountRepo = ds.getAccountRepository();
var transactionRepo = ds.getTransactionRepository();
var vendorRepo = ds.getTransactionVendorRepository();
var categoryRepo = ds.getTransactionCategoryRepository();
var lineItemRepo = ds.getTransactionLineItemRepository()
) {
// First fetch all the data.
List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
.sorted(Comparator.comparing(Currency::getCurrencyCode))
.toList();
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
final List<Attachment> attachments;
final var categoryTreeNodes = categoryRepo.findTree();
final List<String> availableTags = transactionRepo.findAllTags();
final List<String> tags;
final CreditAndDebitAccounts linkedAccounts;
final String vendorName;
final TransactionCategory category;
final List<TransactionLineItem> existingLineItems;
if (transaction == null) {
attachments = Collections.emptyList();
tags = Collections.emptyList();
linkedAccounts = new CreditAndDebitAccounts(null, null);
vendorName = null;
category = null;
existingLineItems = Collections.emptyList();
} else {
attachments = transactionRepo.findAttachments(transaction.id);
tags = transactionRepo.findTags(transaction.id);
linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id);
if (transaction.getVendorId() != null) {
vendorName = vendorRepo.findById(transaction.getVendorId())
.map(TransactionVendor::getName).orElse(null);
} else {
vendorName = null;
}
if (transaction.getCategoryId() != null) {
category = categoryRepo.findById(transaction.getCategoryId()).orElse(null);
} else {
category = null;
}
existingLineItems = lineItemRepo.findItems(transaction.id);
}
final List<TransactionVendor> availableVendors = vendorRepo.findAll();
// Then make updates to the view.
Platform.runLater(() -> {
currencyChoiceBox.getItems().setAll(currencies);
creditAccountSelector.setAccounts(accounts);
debitAccountSelector.setAccounts(accounts);
vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList());
vendorComboBox.setValue(vendorName);
categoryComboBox.loadCategories(categoryTreeNodes);
categoryComboBox.select(category);
tagsComboBox.getItems().setAll(availableTags);
attachmentsSelectionArea.clear();
attachmentsSelectionArea.addAttachments(attachments);
selectedTags.clear();
selectedTags.addAll(tags);
if (transaction == null) {
currencyChoiceBox.getSelectionModel().selectFirst();
creditAccountSelector.select(null);
debitAccountSelector.select(null);
} else {
currencyChoiceBox.getSelectionModel().select(transaction.getCurrency());
creditAccountSelector.select(linkedAccounts.creditAccount());
debitAccountSelector.select(linkedAccounts.debitAccount());
}
lineItemCategoryComboBox.loadCategories(categoryTreeNodes);
lineItemCategoryComboBox.select(null);
lineItems.setAll(existingLineItems);
container.setDisable(false);
});
} catch (Exception e) {
log.error("Failed to get repositories.", e);
Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage()));
router.navigateBackAndClear();
}
});
}
private BooleanExpression initializeLinkedAccountsValidationUi() {
Property<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts());
debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts()));
return new ValidationApplier<>(getLinkedAccountsValidator())
.validatedInitially()
.attach(linkedAccountsContainer, linkedAccountsProperty, currencyChoiceBox.valueProperty());
}
record BasicTransactionInfo(LocalDateTime timestamp, BigDecimal amount, Currency currency) {}
private BasicTransactionInfo getBasicTransactionInfo() {
if (!basicTransactionInfoValid.get()) return null;
return new BasicTransactionInfo(
DateUtil.localToUTC(parseTimestamp()),
new BigDecimal(amountField.getText()),
currencyChoiceBox.getValue()
);
}
/**
* Initializes the duplicate transaction validation, which operates on the
* basic transaction properties: timestamp, amount, and currency. We listen
* for changes to these, and if they're all at least valid, we search for
* existing transactions with the same values.
*/
private void initializeDuplicateTransactionUi() {
Property<BasicTransactionInfo> txInfoProperty = new SimpleObjectProperty<>(getBasicTransactionInfo());
basicTransactionInfoValid.addListener((observable, oldValue, newValue) -> {
if (newValue) {
txInfoProperty.setValue(new BasicTransactionInfo(
DateUtil.localToUTC(parseTimestamp()),
new BigDecimal(amountField.getText()),
currencyChoiceBox.getValue()
));
} else {
txInfoProperty.setValue(null);
}
});
AsyncValidationFunction<BasicTransactionInfo> validationFunction = info -> {
if (info == null || transaction != null) return CompletableFuture.completedFuture(ValidationResult.valid());
return Profile.getCurrent().dataSource().mapRepoAsync(
TransactionRepository.class,
repo -> repo.findDuplicates(info.timestamp(), info.amount(), info.currency())
)
.thenApply(matches -> matches.stream().map(m -> "Found possible duplicate transaction: #" + m.id).toList())
.thenApply(ValidationResult::new);
};
new ValidationApplier<>(validationFunction)
.attach(descriptionField.getParent(), txInfoProperty);
}
private void initializeTagSelectionUi() {
addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank()));
addTagButton.setOnAction(event -> {
if (tagsComboBox.getValue() == null) return;
String tag = tagsComboBox.getValue().strip();
if (!selectedTags.contains(tag)) {
selectedTags.add(tag);
selectedTags.sort(String::compareToIgnoreCase);
}
tagsComboBox.setValue(null);
});
tagsComboBox.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
addTagButton.fire();
}
});
BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, this::createTagListTile);
}
private Node createTagListTile(String tag) {
Label label = new Label(tag);
label.setMaxWidth(Double.POSITIVE_INFINITY);
label.getStyleClass().addAll("bold-text");
Button removeButton = new Button("Remove");
removeButton.setOnAction(event -> selectedTags.remove(tag));
BorderPane tile = new BorderPane(label);
tile.setRight(removeButton);
tile.getStyleClass().addAll("std-spacing");
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
return tile;
}
private void initializeLineItemsUi() {
addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true));
addLineItemCancelButton.setOnAction(event -> addingLineItemProperty.set(false));
addingLineItemProperty.addListener((observable, oldValue, newValue) -> {
if (!newValue) { // The form has been closed.
lineItemQuantitySpinner.getValueFactory().setValue(1);
lineItemValueField.setText(null);
lineItemDescriptionField.setText(null);
lineItemCategoryComboBox.setValue(categoryComboBox.getValue());
}
});
BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not());
BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty);
BindingUtil.mapContent(lineItemsVBox.getChildren(), lineItems, this::createLineItemTile);
lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1));
var lineItemValueValid = new ValidationApplier<>(new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false))
.validatedInitially().attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty());
var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.")
.addPredicate(s -> s.strip().length() <= TransactionLineItem.DESCRIPTION_MAX_LENGTH, "Description is too long.")
.addPredicate(
s -> lineItems.stream().map(TransactionLineItem::getDescription).noneMatch(d -> d.equalsIgnoreCase(s)),
"Description must be unique."
)
).validatedInitially().attachToTextField(lineItemDescriptionField);
var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid);
addLineItemAddButton.disableProperty().bind(lineItemFormValid.not());
addLineItemAddButton.setOnAction(event -> {
int quantity = lineItemQuantitySpinner.getValue();
BigDecimal valuePerItem = new BigDecimal(lineItemValueField.getText());
String description = lineItemDescriptionField.getText().strip();
TransactionCategory category = lineItemCategoryComboBox.getValue();
Long categoryId = category == null ? null : category.id;
long tmpId = tmpLineItemId--;
TransactionLineItem tmpItem = new TransactionLineItem(tmpId, -1L, valuePerItem, quantity, -1, description, categoryId);
lineItems.add(tmpItem);
addingLineItemProperty.set(false);
});
// Logic for showing an indicator when the line items total exactly matches the entered amount.
ListProperty<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItems);
ObservableValue<BigDecimal> lineItemsTotalValue = lineItemsProperty.map(items -> items.stream()
.map(TransactionLineItem::getTotalValue)
.reduce(BigDecimal.ZERO, BigDecimal::add));
ObjectProperty<BigDecimal> amountFieldValue = new SimpleObjectProperty<>(BigDecimal.ZERO);
amountField.textProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
amountFieldValue.set(BigDecimal.ZERO);
} else {
try {
BigDecimal amount = new BigDecimal(newValue);
amountFieldValue.set(amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount);
} catch (NumberFormatException e) {
amountFieldValue.set(BigDecimal.ZERO);
}
}
});
BooleanProperty lineItemsTotalMatchesAmount = new SimpleBooleanProperty(false);
lineItemsTotalValue.addListener((observable, oldValue, newValue) ->
lineItemsTotalMatchesAmount.set(newValue.compareTo(amountFieldValue.getValue()) == 0));
amountFieldValue.addListener((observable, oldValue, newValue) ->
lineItemsTotalMatchesAmount.set(newValue.compareTo(lineItemsTotalValue.getValue()) == 0));
BindingUtil.bindManagedAndVisible(lineItemsValueMatchLabel, lineItemsTotalMatchesAmount.and(lineItemsProperty.emptyProperty().not()));
// Logic for button that syncs line items total to the amount field.
BindingUtil.bindManagedAndVisible(lineItemsAmountSyncButton, lineItemsTotalMatchesAmount.not().and(lineItemsProperty.emptyProperty().not()));
lineItemsAmountSyncButton.setOnAction(event -> amountField.setText(
CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(
lineItemsTotalValue.getValue(),
currencyChoiceBox.getValue()
))
));
}
private Node createLineItemTile(TransactionLineItem item) {
TransactionLineItemTile tile = TransactionLineItemTile.build(item, currencyChoiceBox.valueProperty(), categoryComboBox.getItems()).join();
Button removeButton = new Button("Remove");
removeButton.setMaxWidth(Double.POSITIVE_INFINITY);
removeButton.setOnAction(event -> lineItems.remove(item));
Button moveUpButton = new Button("Move Up");
moveUpButton.setMaxWidth(Double.POSITIVE_INFINITY);
moveUpButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getFirst().equals(item)));
moveUpButton.setOnAction(event -> {
int currentIdx = lineItems.indexOf(item);
lineItems.remove(currentIdx);
lineItems.add(currentIdx - 1, item);
});
Button moveDownButton = new Button("Move Down");
moveDownButton.setMaxWidth(Double.POSITIVE_INFINITY);
moveDownButton.disableProperty().bind(new SimpleListProperty<>(lineItems).map(items -> items.isEmpty() || items.getLast().equals(item)));
moveDownButton.setOnAction(event -> {
int currentIdx = lineItems.indexOf(item);
lineItems.remove(currentIdx);
lineItems.add(currentIdx + 1, item);
});
VBox buttonsBox = new VBox(removeButton, moveUpButton, moveDownButton);
buttonsBox.getStyleClass().addAll("std-spacing");
tile.setRight(buttonsBox);
return tile;
}
private CreditAndDebitAccounts getSelectedAccounts() {
return new CreditAndDebitAccounts(
creditAccountSelector.getValue(),
debitAccountSelector.getValue()
);
}
private PredicateValidator<CreditAndDebitAccounts> getLinkedAccountsValidator() {
return new PredicateValidator<CreditAndDebitAccounts>()
.addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.")
.addPredicate(
accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()),
"The credit and debit accounts cannot be the same."
)
.addPredicate(
accounts -> (
(!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) &&
(!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue()))
),
"Linked accounts must use the same currency."
)
.addPredicate(
accounts -> (
(!accounts.hasCredit() || !accounts.creditAccount().isArchived()) &&
(!accounts.hasDebit() || !accounts.debitAccount().isArchived())
),
"Linked accounts must not be archived."
);
}
private LocalDateTime parseTimestamp() {
List<DateTimeFormatter> formatters = List.of(
DateTimeFormatter.ISO_LOCAL_DATE_TIME,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("d/M/yyyy H:mm:ss")
);
for (var formatter : formatters) {
try {
return formatter.parse(timestampField.getText(), LocalDateTime::from);
} catch (DateTimeException e) {
// Ignore.
}
}
return null;
}
private String getSanitizedDescription() {
String raw = descriptionField.getText();
if (raw == null) return null;
if (raw.isBlank()) return null;
return raw.strip();
}
}

View File

@ -0,0 +1,110 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AnalyticsRepository;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.TimestampRange;
import com.andrewlalis.perfin.data.TransactionVendorRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.TransactionVendor;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import static com.andrewlalis.perfin.PerfinApp.router;
public class EditVendorController implements RouteSelectionListener {
private TransactionVendor vendor;
@FXML public TextField nameField;
@FXML public TextArea descriptionField;
@FXML public Button saveButton;
@FXML public Label totalSpentField;
@FXML public void initialize() {
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
.addPredicate(s -> s.strip().length() <= TransactionVendor.NAME_MAX_LENGTH, "Name is too long.")
// A predicate that prevents duplicate names.
.addAsyncPredicate(
s -> {
if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
return Profile.getCurrent().dataSource().mapRepoAsync(
TransactionVendorRepository.class,
repo -> {
var vendorByName = repo.findByName(s).orElse(null);
if (this.vendor != null) {
return this.vendor.equals(vendorByName) || vendorByName == null;
}
return vendorByName == null;
}
);
},
"Vendor with this name already exists."
)
).validatedInitially().attachToTextField(nameField);
var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
.addPredicate(
s -> s == null || s.strip().length() <= TransactionVendor.DESCRIPTION_MAX_LENGTH,
"Description is too long."
)
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
var formValid = nameValid.and(descriptionValid);
saveButton.disableProperty().bind(formValid.not());
}
@Override
public void onRouteSelected(Object context) {
if (context instanceof TransactionVendor tv) {
this.vendor = tv;
nameField.setText(vendor.getName());
descriptionField.setText(vendor.getDescription());
Profile.getCurrent().dataSource().mapRepoAsync(
AnalyticsRepository.class,
repo -> repo.getVendorSpend(TimestampRange.unbounded(), vendor.id)
).thenAccept(amounts -> {
String text = amounts.stream()
.map(CurrencyUtil::formatMoney)
.collect(Collectors.joining(", "));
Platform.runLater(() -> totalSpentField.setText(text.isBlank() ? "None" : text));
});
} else {
nameField.setText(null);
descriptionField.setText(null);
totalSpentField.setText(null);
}
}
@FXML public void save() {
String name = nameField.getText().strip();
String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
DataSource ds = Profile.getCurrent().dataSource();
if (vendor != null) {
ds.useRepo(TransactionVendorRepository.class, repo -> repo.update(vendor.id, name, description));
} else {
ds.useRepo(TransactionVendorRepository.class, repo -> {
if (description == null || description.isEmpty()) {
repo.insert(name);
} else {
repo.insert(name, description);
}
});
}
router.replace("vendors");
}
@FXML public void cancel() {
router.navigateBackAndClear();
}
}

View File

@ -3,20 +3,29 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.ProfilesStage;
import com.andrewlalis.perfin.view.component.ScrollPaneRouterView;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import static com.andrewlalis.perfin.PerfinApp.helpRouter;
import static com.andrewlalis.perfin.PerfinApp.router;
public class MainViewController {
@FXML public BorderPane mainContainer;
@FXML public HBox breadcrumbHBox;
@FXML public Button showManualButton;
@FXML public Button hideManualButton;
@FXML public BorderPane helpPane;
@FXML public Button helpBackButton;
@FXML public void initialize() {
AnchorPaneRouterView routerView = (AnchorPaneRouterView) router.getView();
mainContainer.setCenter(routerView.getAnchorPane());
mainContainer.setCenter(routerView.getPane());
// Set up a simple breadcrumb display in the top bar.
BindingUtil.mapContent(
@ -25,13 +34,37 @@ public class MainViewController {
breadCrumb -> {
Label label = new Label("> " + breadCrumb.route());
if (breadCrumb.current()) {
label.setStyle("-fx-font-weight: bold");
label.getStyleClass().add("bold-text");
}
return label;
}
);
router.navigate("accounts");
router.navigate("dashboard");
// Initialize the help manual components.
helpPane.managedProperty().bind(helpPane.visibleProperty());
helpPane.setVisible(false);
showManualButton.managedProperty().bind(showManualButton.visibleProperty());
showManualButton.visibleProperty().bind(helpPane.visibleProperty().not());
hideManualButton.managedProperty().bind(hideManualButton.visibleProperty());
hideManualButton.visibleProperty().bind(helpPane.visibleProperty());
helpBackButton.managedProperty().bind(helpBackButton.visibleProperty());
helpRouter.currentRouteProperty().addListener((observable, oldValue, newValue) -> {
helpBackButton.setVisible(helpRouter.getHistory().canGoBack());
});
helpBackButton.setOnAction(event -> helpRouter.navigateBack());
ScrollPaneRouterView helpRouterView = (ScrollPaneRouterView) helpRouter.getView();
ScrollPane helpRouterScrollPane = helpRouterView.getScrollPane();
helpRouterScrollPane.setMinWidth(200.0);
helpRouterScrollPane.setMaxWidth(400.0);
helpRouterScrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.ALWAYS);
helpRouterScrollPane.getStyleClass().addAll("padding-extra");
helpPane.setCenter(helpRouterScrollPane);
helpRouter.navigate("home");
}
@FXML public void goBack() {
@ -42,17 +75,35 @@ public class MainViewController {
router.navigateForward();
}
@FXML public void goToAccounts() {
router.getHistory().clear();
router.navigate("accounts");
}
@FXML public void goToTransactions() {
router.getHistory().clear();
router.navigate("transactions");
}
@FXML public void viewProfiles() {
ProfilesStage.open(mainContainer.getScene().getWindow());
}
@FXML public void showManual() {
helpPane.setVisible(true);
}
@FXML public void hideManual() {
helpPane.setVisible(false);
}
@FXML public void helpViewHome() {
helpRouter.replace("home");
}
@FXML public void helpViewAccounts() {
helpRouter.replace("accounts");
}
@FXML public void helpViewTransactions() {
helpRouter.replace("transactions");
}
@FXML public void goToDashboard() {
router.replace("dashboard");
}
@FXML public void goToSqlConsole() {
router.replace("sql-console");
}
}

View File

@ -1,30 +1,70 @@
package com.andrewlalis.perfin.control;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.stage.Modality;
import javafx.stage.Window;
/**
* Helper class for standardized popups and confirmation dialogs for the app.
*/
public class Popups {
public static boolean confirm(String text) {
public static boolean confirm(Window owner, String text) {
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text);
alert.initOwner(owner);
alert.initModality(Modality.APPLICATION_MODAL);
var result = alert.showAndWait();
return result.isPresent() && result.get() == ButtonType.OK;
}
public static void message(String text) {
public static boolean confirm(Node node, String text) {
return confirm(getWindowFromNode(node), text);
}
public static void message(Window owner, String text) {
Alert alert = new Alert(Alert.AlertType.NONE, text);
alert.initOwner(owner);
alert.initModality(Modality.APPLICATION_MODAL);
alert.getButtonTypes().setAll(ButtonType.OK);
alert.showAndWait();
}
public static void error(String text) {
public static void message(Node node, String text) {
message(getWindowFromNode(node), text);
}
public static void error(Window owner, String text) {
Alert alert = new Alert(Alert.AlertType.WARNING, text);
alert.initOwner(owner);
alert.initModality(Modality.APPLICATION_MODAL);
alert.showAndWait();
}
public static void error(Node node, String text) {
error(getWindowFromNode(node), text);
}
public static void error(Window owner, Exception e) {
error(owner, "An " + e.getClass().getSimpleName() + " occurred: " + e.getMessage());
}
public static void error(Node node, Exception e) {
error(getWindowFromNode(node), e);
}
public static void errorLater(Node node, Exception e) {
Platform.runLater(() -> error(node, e));
}
private static Window getWindowFromNode(Node n) {
Window owner = null;
Scene scene = n.getScene();
if (scene != null) {
owner = scene.getWindow();
}
return owner;
}
}

View File

@ -2,11 +2,14 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.perfin.PerfinApp;
import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.SampleProfileGenerator;
import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.ProfileBackups;
import com.andrewlalis.perfin.model.ProfileLoader;
import com.andrewlalis.perfin.view.ProfilesStage;
import javafx.beans.binding.BooleanExpression;
import javafx.beans.property.BooleanProperty;
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.Button;
@ -16,30 +19,27 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static com.andrewlalis.perfin.PerfinApp.router;
public class ProfilesViewController {
private static final Logger log = LoggerFactory.getLogger(ProfilesViewController.class);
@FXML public VBox profilesVBox;
@FXML public TextField newProfileNameField;
@FXML public Text newProfileNameErrorLabel;
@FXML public Button addProfileButton;
@FXML public void initialize() {
BooleanExpression newProfileNameValid = BooleanProperty.booleanExpression(newProfileNameField.textProperty()
.map(text -> (
text != null &&
!text.isBlank() &&
Profile.validateName(text) &&
!Profile.getAvailableProfiles().contains(text)
)));
newProfileNameErrorLabel.managedProperty().bind(newProfileNameErrorLabel.visibleProperty());
newProfileNameErrorLabel.visibleProperty().bind(newProfileNameValid.not().and(newProfileNameField.textProperty().isNotEmpty()));
newProfileNameErrorLabel.wrappingWidthProperty().bind(newProfileNameField.widthProperty());
var newProfileNameValid = new ValidationApplier<>(new PredicateValidator<String>()
.addPredicate(s -> s == null || s.isBlank() || Profile.validateName(s), "Profile name should consist of only lowercase numbers.")
).attachToTextField(newProfileNameField);
addProfileButton.disableProperty().bind(newProfileNameValid.not());
refreshAvailableProfiles();
@ -48,40 +48,43 @@ public class ProfilesViewController {
@FXML public void addProfile() {
String name = newProfileNameField.getText();
boolean valid = Profile.validateName(name);
if (valid && !Profile.getAvailableProfiles().contains(name)) {
boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?");
if (valid && !ProfileLoader.getAvailableProfiles().contains(name)) {
boolean confirm = Popups.confirm(profilesVBox, "Are you sure you want to add a new profile named \"" + name + "\"?");
if (confirm) {
if (openProfile(name, false)) {
Popups.message("Created new profile \"" + name + "\" and loaded it.");
Popups.message(profilesVBox, "Created new profile \"" + name + "\" and loaded it.");
}
newProfileNameField.clear();
}
}
}
@FXML public void createSampleProfile() {
SampleProfileGenerator generator = new SampleProfileGenerator(PerfinApp.profileLoader);
try {
generator.createSampleProfile();
refreshAvailableProfiles();
} catch (Exception e) {
Popups.error(profilesVBox, e);
}
}
private void refreshAvailableProfiles() {
List<String> profileNames = Profile.getAvailableProfiles();
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName();
List<String> profileNames = ProfileLoader.getAvailableProfiles();
String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name();
List<Node> nodes = new ArrayList<>(profileNames.size());
for (String profileName : profileNames) {
boolean isCurrent = profileName.equals(currentProfile);
AnchorPane profilePane = new AnchorPane();
profilePane.setStyle("""
-fx-border-color: lightgray;
-fx-border-radius: 5px;
-fx-padding: 5px;
""");
profilePane.getStyleClass().add("tile");
Text nameTextElement = new Text(profileName);
nameTextElement.setStyle("-fx-font-size: large;");
nameTextElement.getStyleClass().add("large-font");
TextFlow nameLabel = new TextFlow(nameTextElement);
if (isCurrent) {
nameTextElement.setStyle("-fx-font-size: large; -fx-font-weight: bold;");
nameTextElement.getStyleClass().addAll("large-font", "bold-text");
Text currentProfileIndicator = new Text(" Currently Selected Profile");
currentProfileIndicator.setStyle("""
-fx-font-size: small;
-fx-fill: grey;
""");
currentProfileIndicator.getStyleClass().addAll("small-font", "secondary-color-fill");
nameLabel.getChildren().add(currentProfileIndicator);
}
AnchorPane.setLeftAnchor(nameLabel, 0.0);
@ -102,6 +105,9 @@ public class ProfilesViewController {
PerfinApp.instance.getHostServices().showDocument(Profile.getDir(profileName).toUri().toString());
});
buttonBox.getChildren().add(viewFilesButton);
Button backupButton = new Button("Backup");
backupButton.setOnAction(event -> makeBackup(profileName));
buttonBox.getChildren().add(backupButton);
Button deleteButton = new Button("Delete");
deleteButton.setOnAction(event -> deleteProfile(profileName));
buttonBox.getChildren().add(deleteButton);
@ -113,33 +119,42 @@ public class ProfilesViewController {
}
private boolean openProfile(String name, boolean showPopup) {
System.out.println("Opening profile: " + name);
log.info("Opening profile \"{}\".", name);
try {
Profile.load(name);
Profile.setCurrent(PerfinApp.profileLoader.load(name));
ProfileLoader.saveLastProfile(name);
ProfilesStage.closeView();
router.getHistory().clear();
router.navigate("accounts");
if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded.");
router.replace("dashboard");
if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded.");
return true;
} catch (ProfileLoadException e) {
Popups.error("Failed to load the profile: " + e.getMessage());
Popups.error(profilesVBox, "Failed to load the profile: " + e.getMessage());
return false;
}
}
private void makeBackup(String name) {
try {
Path backupFile = ProfileBackups.makeBackup(name);
Popups.message(profilesVBox, "A new backup was created at " + backupFile.toAbsolutePath());
} catch (IOException e) {
Popups.error(profilesVBox, e);
}
}
private void deleteProfile(String name) {
boolean confirmA = Popups.confirm("Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered.");
boolean confirmA = Popups.confirm(profilesVBox, "Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered.");
if (confirmA) {
boolean confirmB = Popups.confirm("Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back.");
boolean confirmB = Popups.confirm(profilesVBox, "Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back.");
if (confirmB) {
try {
FileUtil.deleteDirRecursive(Profile.getDir(name));
// Reset the app's "last profile" to the default if it was the deleted profile.
if (Profile.getLastProfile().equals(name)) {
Profile.saveLastProfile("default");
if (ProfileLoader.getLastProfile().equals(name)) {
ProfileLoader.saveLastProfile("default");
}
// If the current profile was deleted, switch to the default.
if (Profile.getCurrent() != null && Profile.getCurrent().getName().equals(name)) {
if (Profile.getCurrent() != null && Profile.getCurrent().name().equals(name)) {
openProfile("default", true);
}
refreshAvailableProfiles();

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

@ -0,0 +1,64 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.BindingUtil;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
public class TagsViewController implements RouteSelectionListener {
@FXML public VBox tagsVBox;
private final ObservableList<String> tags = FXCollections.observableArrayList();
@FXML public void initialize() {
BindingUtil.mapContent(tagsVBox.getChildren(), tags, this::buildTagTile);
}
@Override
public void onRouteSelected(Object context) {
refreshTags();
}
private void refreshTags() {
Profile.getCurrent().dataSource().mapRepoAsync(
TransactionRepository.class,
TransactionRepository::findAllTags
).thenAccept(strings -> Platform.runLater(() -> tags.setAll(strings)));
}
private Node buildTagTile(String name) {
BorderPane tile = new BorderPane();
tile.getStyleClass().addAll("tile");
Label nameLabel = new Label(name);
nameLabel.getStyleClass().addAll("bold-text");
Label usagesLabel = new Label();
usagesLabel.getStyleClass().addAll("small-font", "secondary-color-text-fill");
Profile.getCurrent().dataSource().mapRepoAsync(
TransactionRepository.class,
repo -> repo.countTagUsages(name)
).thenAccept(count -> Platform.runLater(() -> usagesLabel.setText("Tagged transactions: " + count)));
VBox contentBox = new VBox(nameLabel, usagesLabel);
tile.setLeft(contentBox);
Button removeButton = new Button("Remove");
removeButton.setOnAction(event -> {
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this tag? It will be removed from any transactions. This cannot be undone.");
if (confirm) {
Profile.getCurrent().dataSource().useRepo(
TransactionRepository.class,
repo -> repo.deleteTag(name)
);
refreshTags();
}
});
tile.setRight(removeButton);
return tile;
}
}

View File

@ -1,31 +1,49 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.AttachmentPreview;
import com.andrewlalis.perfin.view.component.AttachmentsViewPane;
import com.andrewlalis.perfin.view.component.PropertiesPane;
import com.andrewlalis.perfin.view.component.TransactionLineItemTile;
import javafx.application.Platform;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Circle;
import javafx.scene.text.TextFlow;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Currency;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import static com.andrewlalis.perfin.PerfinApp.router;
public class TransactionViewController {
private Transaction transaction;
private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class);
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
private final ObservableValue<Currency> observableCurrency = transactionProperty.map(Transaction::getCurrency);
private final ObjectProperty<CreditAndDebitAccounts> linkedAccountsProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<TransactionVendor> vendorProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<TransactionCategory> categoryProperty = new SimpleObjectProperty<>(null);
private final ObservableList<String> tagsList = FXCollections.observableArrayList();
private final ListProperty<String> tagsListProperty = new SimpleListProperty<>(tagsList);
private final ObservableList<TransactionLineItem> lineItemsList = FXCollections.observableArrayList();
private final ListProperty<TransactionLineItem> lineItemsProperty = new SimpleListProperty<>(lineItemsList);
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
@FXML public Label titleLabel;
@ -33,56 +51,134 @@ public class TransactionViewController {
@FXML public Label timestampLabel;
@FXML public Label descriptionLabel;
@FXML public Label vendorLabel;
@FXML public Circle categoryColorIndicator;
@FXML public Label categoryLabel;
@FXML public Label tagsLabel;
@FXML public Hyperlink debitAccountLink;
@FXML public Hyperlink creditAccountLink;
@FXML public VBox attachmentsContainer;
@FXML public HBox attachmentsHBox;
private final ObservableList<Attachment> attachmentsList = FXCollections.observableArrayList();
@FXML public VBox lineItemsVBox;
public void setTransaction(Transaction transaction) {
this.transaction = transaction;
if (transaction == null) return;
titleLabel.setText("Transaction #" + transaction.id);
amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
descriptionLabel.setText(transaction.getDescription());
@FXML public AttachmentsViewPane attachmentsViewPane;
configureAccountLinkBindings(debitAccountLink);
configureAccountLinkBindings(creditAccountLink);
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
@FXML public void initialize() {
titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id));
amountLabel.textProperty().bind(transactionProperty.map(t -> CurrencyUtil.formatMoney(t.getMoneyAmount())));
timestampLabel.textProperty().bind(transactionProperty.map(t -> DateUtil.formatUTCAsLocalWithZone(t.getTimestamp())));
descriptionLabel.textProperty().bind(transactionProperty.map(Transaction::getDescription));
PropertiesPane vendorPane = (PropertiesPane) vendorLabel.getParent();
BindingUtil.bindManagedAndVisible(vendorPane, vendorProperty.isNotNull());
vendorLabel.textProperty().bind(vendorProperty.map(TransactionVendor::getName));
PropertiesPane categoryPane = (PropertiesPane) categoryLabel.getParent().getParent();
BindingUtil.bindManagedAndVisible(categoryPane, categoryProperty.isNotNull());
categoryLabel.textProperty().bind(categoryProperty.map(TransactionCategory::getName));
categoryColorIndicator.fillProperty().bind(categoryProperty.map(TransactionCategory::getColor));
PropertiesPane tagsPane = (PropertiesPane) tagsLabel.getParent();
BindingUtil.bindManagedAndVisible(tagsPane, tagsListProperty.emptyProperty().not());
tagsLabel.textProperty().bind(tagsListProperty.map(tags -> String.join(", ", tags)));
TextFlow debitText = (TextFlow) debitAccountLink.getParent();
BindingUtil.bindManagedAndVisible(debitText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasDebit));
debitAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasDebit() ? la.debitAccount().getShortName() : null));
debitAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
if (la.hasDebit()) {
return event -> router.navigate("account", la.debitAccount());
}
return event -> {};
}));
TextFlow creditText = (TextFlow) creditAccountLink.getParent();
BindingUtil.bindManagedAndVisible(creditText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasCredit));
creditAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasCredit() ? la.creditAccount().getShortName() : null));
creditAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> {
if (la.hasCredit()) {
return event -> router.navigate("account", la.creditAccount());
}
return event -> {};
}));
VBox lineItemsContainer = (VBox) lineItemsVBox.getParent();
BindingUtil.bindManagedAndVisible(lineItemsContainer, lineItemsProperty.emptyProperty().not());
lineItemsProperty.addListener((observable, oldValue, newValue) -> {
lineItemsVBox.getChildren().clear();
Label loadingLabel = new Label("Loading line items...");
loadingLabel.getStyleClass().addAll("secondary-color-text-fill");
lineItemsVBox.getChildren().add(loadingLabel);
List<CompletableFuture<TransactionLineItemTile>> tileFutures = lineItemsList.stream()
.map(item -> TransactionLineItemTile.build(item, observableCurrency, null))
.toList();
Thread.ofVirtual().start(() -> {
List<TransactionLineItemTile> tiles = tileFutures.stream()
.map(CompletableFuture::join).toList();
Platform.runLater(() -> {
accounts.ifDebit(acc -> {
debitAccountLink.setText(acc.getShortName());
debitAccountLink.setOnAction(event -> router.navigate("account", acc));
});
accounts.ifCredit(acc -> {
creditAccountLink.setText(acc.getShortName());
creditAccountLink.setOnAction(event -> router.navigate("account", acc));
});
lineItemsVBox.getChildren().remove(loadingLabel);
lineItemsVBox.getChildren().addAll(tiles);
});
});
});
attachmentsContainer.managedProperty().bind(attachmentsContainer.visibleProperty());
attachmentsContainer.visibleProperty().bind(new SimpleListProperty<>(attachmentsList).emptyProperty().not());
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
List<Attachment> attachments = repo.findAttachments(transaction.id);
Platform.runLater(() -> attachmentsList.setAll(attachments));
});
attachmentsViewPane.hideIfEmpty();
attachmentsViewPane.listProperty().bindContent(attachmentsList);
transactionProperty.addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
linkedAccountsProperty.set(null);
vendorProperty.set(null);
categoryProperty.set(null);
tagsList.clear();
lineItemsList.clear();
attachmentsList.clear();
} else {
updateLinkedData(newValue);
}
});
attachmentsHBox.setMinHeight(AttachmentPreview.HEIGHT);
attachmentsHBox.setPrefHeight(AttachmentPreview.HEIGHT);
((ScrollPane) attachmentsHBox.getParent().getParent().getParent()).minHeightProperty().bind(attachmentsHBox.heightProperty().map(n -> n.doubleValue() + 2));
((ScrollPane) attachmentsHBox.getParent().getParent().getParent()).prefHeightProperty().bind(attachmentsHBox.heightProperty().map(n -> n.doubleValue() + 2));
BindingUtil.mapContent(attachmentsHBox.getChildren(), attachmentsList, AttachmentPreview::new);
}
public void setTransaction(Transaction transaction) {
this.transactionProperty.set(transaction);
}
private void updateLinkedData(Transaction tx) {
var ds = Profile.getCurrent().dataSource();
Thread.ofVirtual().start(() -> {
try (
var transactionRepo = ds.getTransactionRepository();
var vendorRepo = ds.getTransactionVendorRepository();
var categoryRepo = ds.getTransactionCategoryRepository();
var lineItemsRepo = ds.getTransactionLineItemRepository()
) {
final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id);
final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null);
final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null);
final var attachments = transactionRepo.findAttachments(tx.id);
final var tags = transactionRepo.findTags(tx.id);
final var lineItems = lineItemsRepo.findItems(tx.id);
Platform.runLater(() -> {
linkedAccountsProperty.set(linkedAccounts);
vendorProperty.set(vendor);
categoryProperty.set(category);
attachmentsList.setAll(attachments);
tagsList.setAll(tags);
lineItemsList.setAll(lineItems);
});
} catch (Exception e) {
log.error("Failed to fetch additional transaction data.", e);
Popups.errorLater(titleLabel, e);
}
});
}
@FXML public void editTransaction() {
router.navigate("edit-transaction", this.transactionProperty.get());
}
@FXML public void deleteTransaction() {
boolean confirm = Popups.confirm(
titleLabel,
"Are you sure you want to delete this transaction? This will " +
"permanently remove the transaction and its effects on any linked " +
"accounts, as well as remove any attachments from storage within " +
@ -92,19 +188,8 @@ public class TransactionViewController {
"it's derived from the most recent balance-record, and transactions."
);
if (confirm) {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
// TODO: Delete attachments first!
repo.delete(transaction.id);
router.getHistory().clear();
router.navigate("transactions");
});
Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(this.transactionProperty.get().id));
router.replace("transactions");
}
}
private void configureAccountLinkBindings(Hyperlink link) {
TextFlow parent = (TextFlow) link.getParent();
parent.managedProperty().bind(parent.visibleProperty());
parent.visibleProperty().bind(link.textProperty().isNotEmpty());
link.setText(null);
}
}

View File

@ -1,15 +1,21 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher;
import com.andrewlalis.perfin.data.search.SearchFilter;
import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.view.AccountComboBoxCellFactory;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.SceneUtil;
import com.andrewlalis.perfin.view.component.AccountSelectionBox;
import com.andrewlalis.perfin.view.component.DataSourcePaginationControls;
import com.andrewlalis.perfin.view.component.TransactionTile;
import javafx.application.Platform;
@ -18,13 +24,14 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import static com.andrewlalis.perfin.PerfinApp.router;
@ -35,13 +42,19 @@ import static com.andrewlalis.perfin.PerfinApp.router;
* to a specific page.
*/
public class TransactionsViewController implements RouteSelectionListener {
public static List<Sort> DEFAULT_SORTS = List.of(Sort.desc("timestamp"));
public static int DEFAULT_ITEMS_PER_PAGE = 5;
public static List<Sort> DEFAULT_SORTS = List.of(
Sort.desc("timestamp"),
Sort.desc("amount"),
Sort.desc("currency")
);
public record RouteContext(Long selectedTransactionId) {}
@FXML public BorderPane transactionsListBorderPane;
@FXML public ComboBox<Account> filterByAccountComboBox;
@FXML public TextField searchField;
@FXML public AccountSelectionBox filterByAccountComboBox;
@FXML public VBox transactionsVBox;
private DataSourcePaginationControls paginationControls;
@ -50,40 +63,37 @@ public class TransactionsViewController implements RouteSelectionListener {
@FXML public void initialize() {
// Initialize the left-hand paginated transactions list.
var accountCellFactory = new AccountComboBoxCellFactory("All");
filterByAccountComboBox.setCellFactory(accountCellFactory);
filterByAccountComboBox.setButtonCell(accountCellFactory.call(null));
filterByAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> {
paginationControls.setPage(1);
selectedTransaction.set(null);
});
// Add a listener to the search field that sets the page to 1 (thus
// doing a new search with the contents of the field), and deselects any
// selected transaction.
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
paginationControls.setPage(1);
selectedTransaction.set(null);
});
this.paginationControls = new DataSourcePaginationControls(
transactionsVBox.getChildren(),
new DataSourcePaginationControls.PageFetcherFunction() {
@Override
public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
Account accountFilter = filterByAccountComboBox.getValue();
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
Page<Transaction> result;
if (accountFilter == null) {
result = repo.findAll(pagination);
} else {
result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination);
}
return result.map(TransactionsViewController.this::makeTile);
JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
try (var conn = ds.getConnection()) {
JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
return searcher.search(pagination, getCurrentSearchFilters())
.map(TransactionsViewController.this::makeTile);
}
}
@Override
public int getTotalCount() throws Exception {
Account accountFilter = filterByAccountComboBox.getValue();
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
if (accountFilter == null) {
return (int) repo.countAll();
} else {
return (int) repo.countAllByAccounts(Set.of(accountFilter.id));
}
JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
try (var conn = ds.getConnection()) {
JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
return (int) searcher.resultCount(getCurrentSearchFilters());
}
}
}
@ -96,18 +106,13 @@ public class TransactionsViewController implements RouteSelectionListener {
detailPanel.minWidthProperty().bind(halfWidthProp);
detailPanel.maxWidthProperty().bind(halfWidthProp);
detailPanel.prefWidthProperty().bind(halfWidthProp);
detailPanel.managedProperty().bind(detailPanel.visibleProperty());
detailPanel.visibleProperty().bind(selectedTransaction.isNotNull());
BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull());
Pair<BorderPane, TransactionViewController> detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml");
TransactionViewController transactionViewController = detailComponents.second();
BorderPane transactionDetailView = detailComponents.first();
transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty());
transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull());
detailPanel.getChildren().add(transactionDetailView);
selectedTransaction.addListener((observable, oldValue, newValue) -> {
transactionViewController.setTransaction(newValue);
});
selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue));
// Clear the transactions when a new profile is loaded.
Profile.whenLoaded(profile -> {
@ -119,43 +124,95 @@ public class TransactionsViewController implements RouteSelectionListener {
@Override
public void onRouteSelected(Object context) {
paginationControls.sorts.setAll(DEFAULT_SORTS);
paginationControls.itemsPerPage.set(DEFAULT_ITEMS_PER_PAGE);
selectedTransaction.set(null); // Initially set the selected transaction as null.
// Refresh account filter options.
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
accounts.add(null);
Platform.runLater(() -> {
filterByAccountComboBox.getItems().clear();
filterByAccountComboBox.getItems().addAll(accounts);
filterByAccountComboBox.getSelectionModel().selectLast();
filterByAccountComboBox.getButtonCell().updateIndex(accounts.size() - 1);
});
Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
Platform.runLater(() -> {
filterByAccountComboBox.setAccounts(accounts);
filterByAccountComboBox.select(null);
});
});
// 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) {
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
searchField.setText(null);// First clear the search field if it's already populated.
Profile.getCurrent().dataSource().useRepoAsync(
TransactionRepository.class,
repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
long offset = repo.countAllAfter(tx.id);
int pageNumber = (int) (offset / DEFAULT_ITEMS_PER_PAGE) + 1;
paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx));
});
});
});
int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
Platform.runLater(() -> {
paginationControls.setPage(pageNumber);
selectedTransaction.set(tx);
});
})
);
} else {
paginationControls.setPage(1);
selectedTransaction.set(null);
}
}
@FXML
public void addTransaction() {
router.navigate("create-transaction");
@FXML public void addTransaction() {
router.navigate("edit-transaction");
}
@FXML public void exportTransactions() {
Popups.message(transactionsListBorderPane, "Exporting transactions is not yet supported.");
}
private List<SearchFilter> getCurrentSearchFilters() {
List<SearchFilter> filters = new ArrayList<>();
if (searchField.getText() != null && !searchField.getText().isBlank()) {
final String text = searchField.getText().strip();
// Special case: for input like "#123", search directly for the transaction id.
if (text.matches("#\\d+")) {
int idQuery = Integer.parseInt(text.substring(1));
var filter = new SearchFilter.Builder().where("id = ?").withArg(idQuery).build();
return List.of(filter);
}
// Special case: for input like "tag: abc", search directly for transactions with tags like that.
if (text.matches("tag:\\s*.+")) {
String tagQuery = "%" + text.substring(4).strip().toLowerCase() + "%";
var filter = new SearchFilter.Builder().where("""
id IN (
SELECT ttj.transaction_id
FROM transaction_tag_join ttj
LEFT JOIN transaction_tag tt ON tt.id = ttj.tag_id
WHERE LOWER(tt.name) LIKE ?
)""").withArg(tagQuery).build();
return List.of(filter);
}
// General case: split the input into a list of terms, then apply each term in a LIKE %term% query.
var likeTerms = Arrays.stream(text.toLowerCase().split("\\s+"))
.map(t -> '%'+t+'%')
.toList();
var builder = new SearchFilter.Builder();
List<String> orClauses = new ArrayList<>(likeTerms.size());
for (var term : likeTerms) {
orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?");
builder.withArg(term);
builder.withArg(term);
builder.withArg(term);
}
builder.where(String.join(" OR ", orClauses));
builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id");
builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id");
filters.add(builder.build());
}
if (filterByAccountComboBox.getValue() != null) {
Account filteredAccount = filterByAccountComboBox.getValue();
var filter = new SearchFilter.Builder()
.where("fae.account_id = ?")
.withArg(filteredAccount.id)
.withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id")
.build();
filters.add(filter);
}
return filters;
}
private TransactionTile makeTile(Transaction transaction) {

View File

@ -0,0 +1,45 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.TransactionVendorRepository;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.TransactionVendor;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.VendorTile;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.layout.VBox;
import java.util.List;
import static com.andrewlalis.perfin.PerfinApp.router;
public class VendorsViewController implements RouteSelectionListener {
@FXML public VBox vendorsVBox;
private final ObservableList<TransactionVendor> vendors = FXCollections.observableArrayList();
@FXML public void initialize() {
BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, vendor -> new VendorTile(vendor, this::refreshVendors));
}
@Override
public void onRouteSelected(Object context) {
refreshVendors();
}
@FXML public void addVendor() {
router.navigate("edit-vendor");
}
private void refreshVendors() {
Profile.getCurrent().dataSource().useRepoAsync(TransactionVendorRepository.class, repo -> {
final List<TransactionVendor> vendors = repo.findAll();
Platform.runLater(() -> {
this.vendors.clear();
this.vendors.addAll(vendors);
});
});
}
}

View File

@ -6,8 +6,9 @@ import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
import java.util.Optional;
public interface AccountEntryRepository extends AutoCloseable {
public interface AccountEntryRepository extends Repository, AutoCloseable {
long insert(
LocalDateTime timestamp,
long accountId,
@ -16,5 +17,7 @@ public interface AccountEntryRepository extends AutoCloseable {
AccountEntry.Type type,
Currency currency
);
Optional<AccountEntry> findById(long id);
List<AccountEntry> findAllByAccountId(long accountId);
List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax);
}

View File

@ -1,25 +0,0 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface AccountHistoryItemRepository extends AutoCloseable {
void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId);
void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId);
void recordText(LocalDateTime timestamp, long accountId, String text);
List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count);
default Optional<AccountHistoryItem> getMostRecentForAccount(long accountId) {
var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1);
if (items.isEmpty()) return Optional.empty();
return Optional.of(items.getFirst());
}
String getTextItem(long itemId);
AccountEntry getAccountEntryItem(long itemId);
BalanceRecord getBalanceRecordItem(long itemId);
}

View File

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

View File

@ -0,0 +1,27 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.TransactionCategory;
import com.andrewlalis.perfin.model.TransactionVendor;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.List;
public interface AnalyticsRepository extends Repository, AutoCloseable {
List<Pair<TransactionCategory, BigDecimal>> getSpendByCategory(TimestampRange range, Currency currency);
List<Pair<TransactionCategory, BigDecimal>> getSpendByRootCategory(TimestampRange range, Currency currency);
List<Pair<TransactionCategory, BigDecimal>> getIncomeByCategory(TimestampRange range, Currency currency);
List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency);
List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(TimestampRange range, Currency currency);
/**
* Gets the amount spent, grouped by currency, on a specific vendor.
* @param range The time range to search in.
* @param vendorId The id of the vendor to search with.
* @return A list of money values with the total amount spent in each
* currency. An empty list is returned if no money is spent.
*/
List<MoneyValue> getVendorSpend(TimestampRange range, long vendorId);
}

View File

@ -5,9 +5,10 @@ import com.andrewlalis.perfin.model.Attachment;
import java.nio.file.Path;
import java.util.Optional;
public interface AttachmentRepository extends AutoCloseable {
public interface AttachmentRepository extends Repository, AutoCloseable {
Attachment insert(Path sourcePath);
Optional<Attachment> findById(long attachmentId);
Optional<Attachment> findByIdentifier(String identifier);
void deleteById(long attachmentId);
void deleteAllOrphans();
}

View File

@ -1,15 +1,22 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.BalanceRecordType;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
import java.util.Optional;
public interface BalanceRecordRepository extends AutoCloseable {
long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments);
BalanceRecord findLatestByAccountId(long accountId);
public interface BalanceRecordRepository extends Repository, AutoCloseable {
long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments);
BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type);
Optional<BalanceRecord> findById(long id);
Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp);
List<Attachment> findAttachments(long recordId);
void deleteById(long id);
}

View File

@ -2,8 +2,6 @@ package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.ThrowableConsumer;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.MoneyValue;
@ -11,11 +9,12 @@ import javafx.application.Platform;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Currency;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Interface for methods to obtain any data from a {@link com.andrewlalis.perfin.model.Profile}
@ -32,48 +31,114 @@ public interface DataSource {
AccountRepository getAccountRepository();
BalanceRecordRepository getBalanceRecordRepository();
TransactionRepository getTransactionRepository();
TransactionVendorRepository getTransactionVendorRepository();
TransactionCategoryRepository getTransactionCategoryRepository();
TransactionLineItemRepository getTransactionLineItemRepository();
AttachmentRepository getAttachmentRepository();
AccountHistoryItemRepository getAccountHistoryItemRepository();
HistoryRepository getHistoryRepository();
SavedQueryRepository getSavedQueryRepository();
default void useAccountRepository(ThrowableConsumer<AccountRepository> repoConsumer) {
DbUtil.useClosable(this::getAccountRepository, repoConsumer);
AnalyticsRepository getAnalyticsRepository();
// Repository helper methods:
@SuppressWarnings("unchecked")
default <R extends Repository, T> T mapRepo(Class<R> repoType, Function<R, T> action) {
Supplier<R> repoSupplier = getRepo(repoType);
if (repoSupplier == null) throw new IllegalArgumentException("Repository type " + repoType + " is not supported.");
boolean repoCloseable = Arrays.asList(repoType.getInterfaces()).contains(AutoCloseable.class);
if (repoCloseable) {
try (AutoCloseable c = (AutoCloseable) repoSupplier.get()) {
R repo = (R) c;
return action.apply(repo);
} catch (Exception e) {
throw new RuntimeException(e);
}
} else {
R repo = repoSupplier.get();
return action.apply(repo);
}
}
default void useBalanceRecordRepository(ThrowableConsumer<BalanceRecordRepository> repoConsumer) {
DbUtil.useClosable(this::getBalanceRecordRepository, repoConsumer);
default <R extends Repository, T> CompletableFuture<T> mapRepoAsync(Class<R> repoType, Function<R, T> action) {
CompletableFuture<T> cf = new CompletableFuture<>();
Thread.ofVirtual().start(() -> {
cf.complete(mapRepo(repoType, action));
});
return cf;
}
default void useTransactionRepository(ThrowableConsumer<TransactionRepository> repoConsumer) {
DbUtil.useClosable(this::getTransactionRepository, repoConsumer);
default <R extends Repository> void useRepo(Class<R> repoType, Consumer<R> action) {
mapRepo(repoType, (Function<R, Void>) repo -> {
action.accept(repo);
return null;
});
}
default void useAttachmentRepository(ThrowableConsumer<AttachmentRepository> repoConsumer) {
DbUtil.useClosable(this::getAttachmentRepository, repoConsumer);
default <R extends Repository> CompletableFuture<Void> useRepoAsync(Class<R> repoType, Consumer<R> action) {
return mapRepoAsync(repoType, repo -> {
action.accept(repo);
return null;
});
}
@SuppressWarnings("unchecked")
private <R extends Repository> Supplier<R> getRepo(Class<R> type) {
final Map<Class<? extends Repository>, Supplier<? extends Repository>> repoSuppliers = Map.of(
AccountRepository.class, this::getAccountRepository,
BalanceRecordRepository.class, this::getBalanceRecordRepository,
TransactionRepository.class, this::getTransactionRepository,
TransactionVendorRepository.class, this::getTransactionVendorRepository,
TransactionCategoryRepository.class, this::getTransactionCategoryRepository,
TransactionLineItemRepository.class, this::getTransactionLineItemRepository,
AttachmentRepository.class, this::getAttachmentRepository,
HistoryRepository.class, this::getHistoryRepository,
SavedQueryRepository.class, this::getSavedQueryRepository,
AnalyticsRepository.class, this::getAnalyticsRepository
);
return (Supplier<R>) repoSuppliers.get(type);
}
// Utility methods:
default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) {
Thread.ofVirtual().start(() -> useAccountRepository(repo -> {
BigDecimal balance = repo.deriveCurrentBalance(account.id);
default CompletableFuture<String> getAccountBalanceText(Account account) {
CompletableFuture<String> cf = new CompletableFuture<>();
mapRepoAsync(AccountRepository.class, repo -> {
BigDecimal balance = repo.deriveCurrentCashBalance(account.id);
MoneyValue money = new MoneyValue(balance, account.getCurrency());
Platform.runLater(() -> balanceConsumer.accept(CurrencyUtil.formatMoney(money)));
}));
return CurrencyUtil.formatMoney(money);
}).thenAccept(s -> Platform.runLater(() -> cf.complete(s)));
return cf;
}
default Map<Currency, BigDecimal> getCombinedAccountBalances() {
try (var accountRepo = getAccountRepository()) {
List<Account> accounts = accountRepo.findAll(PageRequest.unpaged()).items();
/**
* Gets a list of combined total assets for each currency that's tracked,
* ordered with highest assets first.
* @param timestamp The timestamp at which to get the balance.
* @return A future that resolves to the list of amounts for each currency.
*/
default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances(Instant timestamp) {
return mapRepoAsync(AccountRepository.class, repo -> {
List<Account> accounts = repo.findAll(PageRequest.unpaged()).items();
Map<Currency, BigDecimal> totals = new HashMap<>();
for (var account : accounts) {
BigDecimal currencyTotal = totals.computeIfAbsent(account.getCurrency(), c -> BigDecimal.ZERO);
BigDecimal accountBalance = accountRepo.deriveCurrentBalance(account.id);
BigDecimal accountBalance = repo.deriveCashBalance(account.id, timestamp);
BigDecimal accountAssetsValue = repo.getNearestAssetValue(account.id, timestamp);
if (account.getType() == AccountType.CREDIT_CARD) accountBalance = accountBalance.negate();
totals.put(account.getCurrency(), currencyTotal.add(accountBalance));
BigDecimal accountTotal = accountBalance.add(accountAssetsValue);
totals.put(account.getCurrency(), currencyTotal.add(accountTotal));
}
return totals;
} catch (Exception e) {
throw new RuntimeException(e);
}
List<MoneyValue> values = new ArrayList<>(totals.size());
for (var entry : totals.entrySet()) {
values.add(new MoneyValue(entry.getValue(), entry.getKey()));
}
values.sort((m1, m2) -> m2.amount().compareTo(m1.amount()));
return values;
});
}
default CompletableFuture<List<MoneyValue>> getCombinedAccountBalances() {
return getCombinedAccountBalances(Instant.now());
}
}

View File

@ -0,0 +1,21 @@
package com.andrewlalis.perfin.data;
import java.io.IOException;
/**
* Interface that defines the data source factory, a component responsible for
* obtaining a data source, and performing some introspection around that data
* source before one is obtained.
*/
public interface DataSourceFactory {
DataSource getDataSource(String profileName) throws ProfileLoadException;
enum SchemaStatus {
UP_TO_DATE,
NEEDS_MIGRATION,
INCOMPATIBLE
}
SchemaStatus getSchemaStatus(String profileName) throws IOException;
int getSchemaVersion(String profileName) throws IOException;
}

View File

@ -0,0 +1,30 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.history.HistoryItem;
import com.andrewlalis.perfin.model.history.HistoryTextItem;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
public interface HistoryRepository extends Repository, AutoCloseable {
long getOrCreateHistoryForAccount(long accountId);
long getOrCreateHistoryForTransaction(long transactionId);
void deleteHistoryForAccount(long accountId);
void deleteHistoryForTransaction(long transactionId);
HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description);
default HistoryTextItem addTextItem(long historyId, String description) {
return addTextItem(historyId, DateUtil.nowAsUTC(), description);
}
Optional<HistoryItem> getItem(long id);
Page<HistoryItem> getItems(long historyId, PageRequest pagination);
List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp);
default List<HistoryItem> getNItemsBeforeNow(long historyId, int n) {
return getNItemsBefore(historyId, n, LocalDateTime.now(ZoneOffset.UTC));
}
}

View File

@ -0,0 +1,6 @@
package com.andrewlalis.perfin.data;
/**
* Marker interface used to identify any data repository.
*/
public interface Repository {}

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

@ -0,0 +1,43 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.data.util.DateUtil;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
public record TimestampRange(LocalDateTime start, LocalDateTime end) {
public static TimestampRange lastNDays(int days) {
LocalDateTime now = DateUtil.nowAsUTC();
return new TimestampRange(now.minusDays(days), now);
}
public static TimestampRange lastNMonths(int months) {
LocalDateTime now = DateUtil.nowAsUTC();
return new TimestampRange(now.minusMonths(months), now);
}
public static TimestampRange thisMonth() {
LocalDateTime localStartOfMonth = LocalDate.now(ZoneId.systemDefault()).atStartOfDay().withDayOfMonth(1);
LocalDateTime utcStart = localStartOfMonth.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC)
.toLocalDateTime();
return new TimestampRange(utcStart, DateUtil.nowAsUTC());
}
public static TimestampRange thisYear() {
LocalDateTime utcStart = LocalDate.now(ZoneId.systemDefault())
.withDayOfYear(1)
.atStartOfDay()
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC)
.toLocalDateTime();
return new TimestampRange(utcStart, DateUtil.nowAsUTC());
}
public static TimestampRange unbounded() {
LocalDateTime now = DateUtil.nowAsUTC();
return new TimestampRange(LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), now);
}
}

View File

@ -0,0 +1,35 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.TransactionCategory;
import javafx.scene.paint.Color;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
public interface TransactionCategoryRepository extends Repository, AutoCloseable {
Optional<TransactionCategory> findById(long id);
Optional<TransactionCategory> findByName(String name);
List<TransactionCategory> findAllBaseCategories();
List<TransactionCategory> findAll();
TransactionCategory findRoot(long categoryId);
long insert(long parentId, String name, Color color);
long insert(String name, Color color);
void update(long id, String name, Color color);
void deleteById(long id);
record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children) {
public Set<Long> allIds() {
Set<Long> ids = new HashSet<>();
ids.add(category.id);
for (var child : children) {
ids.addAll(child.allIds());
}
return ids;
}
}
List<CategoryTreeNode> findTree();
CategoryTreeNode findTree(TransactionCategory root);
}

View File

@ -0,0 +1,10 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.TransactionLineItem;
import java.util.List;
public interface TransactionLineItemRepository extends Repository, AutoCloseable {
List<TransactionLineItem> findItems(long transactionId);
List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items);
}

View File

@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Transaction;
import com.andrewlalis.perfin.model.TransactionLineItem;
import java.math.BigDecimal;
import java.nio.file.Path;
@ -14,22 +15,48 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
public interface TransactionRepository extends AutoCloseable {
public interface TransactionRepository extends Repository, AutoCloseable {
long insert(
LocalDateTime utcTimestamp,
BigDecimal amount,
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<TransactionLineItem> lineItems,
List<Path> attachments
);
Optional<Transaction> findById(long id);
Page<Transaction> findAll(PageRequest pagination);
List<Transaction> findRecentN(int n);
List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency);
long countAll();
long countAllAfter(long transactionId);
long countAllByAccounts(Set<Long> accountIds);
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
Optional<Transaction> findEarliest();
Optional<Transaction> findLatest();
CreditAndDebitAccounts findLinkedAccounts(long transactionId);
List<Attachment> findAttachments(long transactionId);
List<String> findTags(long transactionId);
List<String> findAllTags();
void deleteTag(String name);
long countTagUsages(String name);
void delete(long transactionId);
void update(
long id,
LocalDateTime utcTimestamp,
BigDecimal amount,
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<TransactionLineItem> lineItems,
List<Attachment> existingAttachments,
List<Path> newAttachmentPaths
);
}

View File

@ -0,0 +1,16 @@
package com.andrewlalis.perfin.data;
import com.andrewlalis.perfin.model.TransactionVendor;
import java.util.List;
import java.util.Optional;
public interface TransactionVendorRepository extends Repository, AutoCloseable {
Optional<TransactionVendor> findById(long id);
Optional<TransactionVendor> findByName(String name);
List<TransactionVendor> findAll();
long insert(String name, String description);
long insert(String name);
void update(long id, String name, String description);
void deleteById(long id);
}

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

@ -1,7 +1,6 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.AccountEntry;
@ -12,11 +11,12 @@ import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
import java.util.Optional;
public record JdbcAccountEntryRepository(Connection conn) implements AccountEntryRepository {
@Override
public long insert(LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, AccountEntry.Type type, Currency currency) {
long entryId = DbUtil.insertOne(
return DbUtil.insertOne(
conn,
"""
INSERT INTO account_entry (timestamp, account_id, transaction_id, amount, type, currency)
@ -30,10 +30,16 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
currency.getCurrencyCode()
)
);
// Insert an entry into the account's history.
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
historyRepo.recordAccountEntry(timestamp, accountId, entryId);
return entryId;
}
@Override
public Optional<AccountEntry> findById(long id) {
return DbUtil.findById(
conn,
"SELECT * FROM account_entry WHERE id = ?",
id,
JdbcAccountEntryRepository::parse
);
}
@Override
@ -46,6 +52,20 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr
);
}
@Override
public List<AccountEntry> findAllByAccountIdBetween(long accountId, LocalDateTime utcMin, LocalDateTime utcMax) {
return DbUtil.findAll(
conn,
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC",
List.of(
accountId,
DbUtil.timestampFromUtcLDT(utcMin),
DbUtil.timestampFromUtcLDT(utcMax)
),
JdbcAccountEntryRepository::parse
);
}
@Override
public void close() throws Exception {
conn.close();

View File

@ -1,120 +0,0 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import com.andrewlalis.perfin.model.history.AccountHistoryItemType;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.List;
public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository {
@Override
public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) {
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY);
DbUtil.insertOne(
conn,
"INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)",
List.of(itemId, entryId)
);
}
@Override
public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) {
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD);
DbUtil.insertOne(
conn,
"INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)",
List.of(itemId, recordId)
);
}
@Override
public void recordText(LocalDateTime timestamp, long accountId, String text) {
long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT);
DbUtil.insertOne(
conn,
"INSERT INTO account_history_item_text (item_id, description) VALUES (?, ?)",
List.of(itemId, text)
);
}
@Override
public List<AccountHistoryItem> findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count) {
return DbUtil.findAll(
conn,
"SELECT * FROM account_history_item WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT " + count,
List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)),
JdbcAccountHistoryItemRepository::parseHistoryItem
);
}
@Override
public String getTextItem(long itemId) {
return DbUtil.findOne(
conn,
"SELECT description FROM account_history_item_text WHERE item_id = ?",
List.of(itemId),
rs -> rs.getString(1)
).orElse(null);
}
@Override
public AccountEntry getAccountEntryItem(long itemId) {
return DbUtil.findOne(
conn,
"""
SELECT *
FROM account_entry
LEFT JOIN account_history_item_account_entry h ON h.entry_id = account_entry.id
WHERE h.item_id = ?""",
List.of(itemId),
JdbcAccountEntryRepository::parse
).orElse(null);
}
@Override
public BalanceRecord getBalanceRecordItem(long itemId) {
return DbUtil.findOne(
conn,
"""
SELECT *
FROM balance_record
LEFT JOIN account_history_item_balance_record h ON h.record_id = balance_record.id
WHERE h.item_id = ?""",
List.of(itemId),
JdbcBalanceRecordRepository::parse
).orElse(null);
}
@Override
public void close() throws Exception {
conn.close();
}
public static AccountHistoryItem parseHistoryItem(ResultSet rs) throws SQLException {
return new AccountHistoryItem(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getLong("account_id"),
AccountHistoryItemType.valueOf(rs.getString("type"))
);
}
private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
return DbUtil.insertOne(
conn,
"INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)",
List.of(
DbUtil.timestampFromUtcLDT(timestamp),
accountId,
type.name()
)
);
}
}

View File

@ -1,42 +1,48 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.EntityNotFoundException;
import com.andrewlalis.perfin.data.*;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Account;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.AccountType;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
public record JdbcAccountRepository(Connection conn, Path contentDir) implements AccountRepository {
private static final Logger log = LoggerFactory.getLogger(JdbcAccountRepository.class);
@Override
public long insert(AccountType type, String accountNumber, String name, Currency currency) {
public long insert(AccountType type, String accountNumber, String name, Currency currency, String description) {
return DbUtil.doTransaction(conn, () -> {
long accountId = DbUtil.insertOne(
conn,
"INSERT INTO account (created_at, account_type, account_number, name, currency) VALUES (?, ?, ?, ?, ?)",
List.of(
DbUtil.timestampFromUtcNow(),
type.name(),
accountNumber,
name,
currency.getCurrencyCode()
)
"INSERT INTO account (created_at, account_type, account_number, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)",
DbUtil.timestampFromUtcNow(),
type.name(),
accountNumber,
name,
currency.getCurrencyCode(),
description
);
// If it's a credit card account, preemptively create a credit card properties record.
if (type == AccountType.CREDIT_CARD) {
saveCreditCardProperties(new CreditCardProperties(accountId, null));
}
// Insert a history item indicating the creation of the account.
var historyRepo = new JdbcAccountHistoryItemRepository(conn);
historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile.");
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, "Account added to your Perfin profile.");
return accountId;
});
}
@ -51,10 +57,46 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
return DbUtil.findAll(
conn,
"""
SELECT DISTINCT ON (account.id) account.*, ahi.timestamp AS _
SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
FROM account
LEFT OUTER JOIN account_history_item ahi ON ahi.account_id = account.id
ORDER BY ahi.timestamp DESC, account.created_at DESC""",
LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
WHERE NOT account.archived
ORDER BY hi.timestamp DESC, account.created_at DESC""",
JdbcAccountRepository::parseAccount
);
}
@Override
public List<Account> findTopNOrderedByRecentHistory(int n) {
return DbUtil.findAll(
conn,
"""
SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
FROM account
LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
WHERE NOT account.archived
ORDER BY hi.timestamp DESC, account.created_at DESC
LIMIT\s""" + n,
JdbcAccountRepository::parseAccount
);
}
@Override
public List<Account> findTopNRecentlyActive(int n, int daysSinceLastActive) {
LocalDateTime cutoff = DateUtil.nowAsUTC().minusDays(daysSinceLastActive);
return DbUtil.findAll(
conn,
"""
SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _
FROM account
LEFT OUTER JOIN history_account ha ON ha.account_id = account.id
LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id
WHERE NOT account.archived AND hi.timestamp >= ?
ORDER BY hi.timestamp DESC, account.created_at DESC
LIMIT\s""" + n,
List.of(DbUtil.timestampFromUtcLDT(cutoff)),
JdbcAccountRepository::parseAccount
);
}
@ -75,61 +117,95 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
}
@Override
public void updateName(long id, String name) {
DbUtil.updateOne(conn, "UPDATE account SET name = ? WHERE id = ?", List.of(name, id));
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 BigDecimal deriveBalance(long accountId, Instant timestamp) {
public void saveCreditCardProperties(CreditCardProperties properties) {
AccountType accountType = getAccountType(properties.accountId());
if (accountType != AccountType.CREDIT_CARD) return;
CreditCardProperties existingProperties = DbUtil.findOne(
conn,
"SELECT * FROM credit_card_account_properties WHERE account_id = ?",
List.of(properties.accountId()),
JdbcAccountRepository::parseCreditCardProperties
).orElse(null);
if (existingProperties != null) {
DbUtil.updateOne(
conn,
"UPDATE credit_card_account_properties SET credit_limit = ? WHERE account_id = ?",
properties.creditLimit(), properties.accountId()
);
} else {
DbUtil.updateOne(
conn,
"INSERT INTO credit_card_account_properties (account_id, credit_limit) VALUES (?, ?)",
properties.accountId(), properties.creditLimit()
);
}
}
@Override
public BigDecimal deriveCashBalance(long accountId, Instant timestamp) {
// First find the account itself, since its properties influence the balance.
Account account = findById(accountId).orElse(null);
if (account == null) throw new EntityNotFoundException(Account.class, accountId);
LocalDateTime utcTimestamp = timestamp.atZone(ZoneOffset.UTC).toLocalDateTime();
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
AccountEntryRepository accountEntryRepo = new JdbcAccountEntryRepository(conn);
// Find the most recent balance record before timestamp.
Optional<BalanceRecord> closestPastRecord = DbUtil.findOne(
conn,
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
List.of(accountId, DbUtil.timestampFromInstant(timestamp)),
JdbcBalanceRecordRepository::parse
);
Optional<BalanceRecord> closestPastRecord = balanceRecordRepo.findClosestBefore(account.id, BalanceRecordType.CASH, utcTimestamp);
if (closestPastRecord.isPresent()) {
// Then find any entries on the account since that balance record and the timestamp.
List<AccountEntry> entriesAfterRecord = DbUtil.findAll(
conn,
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC",
List.of(
accountId,
DbUtil.timestampFromUtcLDT(closestPastRecord.get().getTimestamp()),
DbUtil.timestampFromInstant(timestamp)
),
JdbcAccountEntryRepository::parse
List<AccountEntry> entriesBetweenRecentRecordAndNow = accountEntryRepo.findAllByAccountIdBetween(
account.id,
closestPastRecord.get().getTimestamp(),
utcTimestamp
);
return computeBalanceWithEntriesAfter(account, closestPastRecord.get(), entriesAfterRecord);
return computeBalanceWithEntries(account.getType(), closestPastRecord.get(), entriesBetweenRecentRecordAndNow);
} else {
// There is no balance record present before the given timestamp. Try and find the closest one after.
Optional<BalanceRecord> closestFutureRecord = DbUtil.findOne(
conn,
"SELECT * FROM balance_record WHERE account_id = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
List.of(accountId, DbUtil.timestampFromInstant(timestamp)),
JdbcBalanceRecordRepository::parse
);
if (closestFutureRecord.isEmpty()) {
throw new IllegalStateException("No balance record exists for account " + accountId);
Optional<BalanceRecord> closestFutureRecord = balanceRecordRepo.findClosestAfter(account.id, BalanceRecordType.CASH, utcTimestamp);
if (closestFutureRecord.isPresent()) {
// Now find any entries on the account from the timestamp until that balance record.
List<AccountEntry> entriesBetweenNowAndFutureRecord = accountEntryRepo.findAllByAccountIdBetween(
account.id,
utcTimestamp,
closestFutureRecord.get().getTimestamp()
);
return computeBalanceWithEntries(account.getType(), closestFutureRecord.get(), entriesBetweenNowAndFutureRecord);
} else {
// No balance records exist for the account! Assume balance of 0 when the account was created.
log.warn("No balance record exists for account {}! Assuming balance was 0 at account creation.", account.getShortName());
BalanceRecord placeholder = new BalanceRecord(-1, account.getCreatedAt(), account.id, BalanceRecordType.CASH, BigDecimal.ZERO, account.getCurrency());
List<AccountEntry> entriesSinceAccountCreated = accountEntryRepo.findAllByAccountIdBetween(account.id, account.getCreatedAt(), utcTimestamp);
return computeBalanceWithEntries(account.getType(), placeholder, entriesSinceAccountCreated);
}
// Now find any entries on the account from the timestamp until that balance record.
List<AccountEntry> entriesBeforeRecord = DbUtil.findAll(
conn,
"SELECT * FROM account_entry WHERE account_id = ? AND timestamp <= ? AND timestamp >= ? ORDER BY timestamp DESC",
List.of(
accountId,
DbUtil.timestampFromUtcLDT(closestFutureRecord.get().getTimestamp()),
DbUtil.timestampFromInstant(timestamp)
),
JdbcAccountEntryRepository::parse
);
return computeBalanceWithEntriesBefore(account, closestFutureRecord.get(), entriesBeforeRecord);
}
}
@Override
public BigDecimal getNearestAssetValue(long accountId, Instant timestamp) {
LocalDateTime utcTimestamp = timestamp.atZone(ZoneOffset.UTC).toLocalDateTime();
BalanceRecordRepository balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
Optional<BalanceRecord> mostRecentRecord = balanceRecordRepo.findClosestBefore(accountId, BalanceRecordType.ASSETS, utcTimestamp);
if (mostRecentRecord.isEmpty()) return BigDecimal.ZERO;
return mostRecentRecord.get().getBalance();
}
@Override
public Set<Currency> findAllUsedCurrencies() {
return new HashSet<>(DbUtil.findAll(
@ -140,30 +216,102 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
}
@Override
public void update(Account account) {
DbUtil.updateOne(
conn,
"UPDATE account SET name = ?, account_number = ?, currency = ?, account_type = ? WHERE id = ?",
List.of(
account.getName(),
account.getAccountNumber(),
account.getCurrency().getCurrencyCode(),
account.getType().name(),
account.id
public List<Timestamped> findEventsBefore(long accountId, LocalDateTime utcTimestamp, int maxResults) {
var entryRepo = new JdbcAccountEntryRepository(conn);
var historyRepo = new JdbcHistoryRepository(conn);
var balanceRecordRepo = new JdbcBalanceRecordRepository(conn, contentDir);
String query = """
SELECT id, type
FROM (
SELECT id, timestamp, 'ACCOUNT_ENTRY' AS type, account_id
FROM account_entry
UNION ALL
SELECT id, timestamp, 'HISTORY_ITEM' AS type, account_id
FROM history_item
LEFT JOIN history_account ha ON history_item.history_id = ha.history_id
UNION ALL
SELECT id, timestamp, 'BALANCE_RECORD' AS type, account_id
FROM balance_record
)
);
WHERE account_id = ? AND timestamp < ?
ORDER BY timestamp DESC
LIMIT\s""" + maxResults;
try (var stmt = conn.prepareStatement(query)) {
stmt.setLong(1, accountId);
stmt.setTimestamp(2, DbUtil.timestampFromUtcLDT(utcTimestamp));
ResultSet rs = stmt.executeQuery();
List<Timestamped> entities = new ArrayList<>();
while (rs.next()) {
long id = rs.getLong(1);
String type = rs.getString(2);
Timestamped entity = switch (type) {
case "HISTORY_ITEM" -> historyRepo.getItem(id).orElse(null);
case "ACCOUNT_ENTRY" -> entryRepo.findById(id).orElse(null);
case "BALANCE_RECORD" -> balanceRecordRepo.findById(id).orElse(null);
default -> null;
};
if (entity == null) {
log.warn("Failed to find entity with id {} and type {}.", id, type);
} else {
entities.add(entity);
}
}
return entities;
} catch (SQLException e) {
log.error("Failed to find account events.", e);
return Collections.emptyList();
}
}
@Override
public void update(long accountId, AccountType type, String accountNumber, String name, Currency currency, String description) {
DbUtil.doTransaction(conn, () -> {
Account account = findById(accountId).orElse(null);
if (account == null) return;
List<String> updateMessages = new ArrayList<>();
if (account.getType() != type) {
DbUtil.updateOne(conn, "UPDATE account SET account_type = ? WHERE id = ?", type.name(), accountId);
updateMessages.add(String.format("Updated account type from %s to %s.", account.getType(), type));
}
if (!account.getAccountNumber().equals(accountNumber)) {
DbUtil.updateOne(conn, "UPDATE account SET account_number = ? WHERE id = ?", accountNumber, accountId);
updateMessages.add(String.format("Updated account number from %s to %s.", account.getAccountNumber(), accountNumber));
}
if (!account.getName().equals(name)) {
DbUtil.updateOne(conn, "UPDATE account SET name = ? WHERE id = ?", name, accountId);
updateMessages.add(String.format("Updated account name from \"%s\" to \"%s\".", account.getName(), name));
}
if (account.getCurrency() != currency) {
DbUtil.updateOne(conn, "UPDATE account SET currency = ? WHERE id = ?", currency.getCurrencyCode(), accountId);
updateMessages.add(String.format("Updated account currency from %s to %s.", account.getCurrency(), currency));
}
if (!Objects.equals(account.getDescription(), description)) {
DbUtil.updateOne(conn, "UPDATE account SET description = ? WHERE id = ?", description, accountId);
updateMessages.add("Updated account's description.");
}
if (!updateMessages.isEmpty()) {
var historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, String.join("\n", updateMessages));
}
});
}
@Override
public void delete(Account account) {
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", List.of(account.id));
DbUtil.doTransaction(conn, () -> {
DbUtil.update(conn, "DELETE FROM credit_card_account_properties WHERE account_id = ?", account.id);
DbUtil.updateOne(conn, "DELETE FROM account WHERE id = ?", account.id);
});
}
@Override
public void archive(long accountId) {
DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId));
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been archived.");
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, "Account has been archived.");
});
}
@ -171,7 +319,9 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
public void unarchive(long accountId) {
DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId));
new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been unarchived.");
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForAccount(accountId);
historyRepo.addTextItem(historyId, "Account has been unarchived.");
});
}
@ -183,7 +333,14 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
String accountNumber = rs.getString("account_number");
String name = rs.getString("name");
Currency currency = Currency.getInstance(rs.getString("currency"));
return new Account(id, createdAt, archived, type, accountNumber, name, currency);
String description = rs.getString("description");
return new Account(id, createdAt, archived, type, accountNumber, name, currency, description);
}
public static CreditCardProperties parseCreditCardProperties(ResultSet rs) throws SQLException {
long accountId = rs.getLong("account_id");
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
return new CreditCardProperties(accountId, creditLimit);
}
@Override
@ -191,19 +348,27 @@ public record JdbcAccountRepository(Connection conn) implements AccountRepositor
conn.close();
}
private BigDecimal computeBalanceWithEntriesAfter(Account account, BalanceRecord balanceRecord, List<AccountEntry> entriesAfterRecord) {
private BigDecimal computeBalanceWithEntries(AccountType accountType, BalanceRecord balanceRecord, List<AccountEntry> entries) {
List<AccountEntry> entriesBeforeRecord = entries.stream()
.filter(entry -> entry.getTimestamp().isBefore(balanceRecord.getTimestamp()))
.toList();
List<AccountEntry> entriesAfterRecord = entries.stream()
.filter(entry -> entry.getTimestamp().isAfter(balanceRecord.getTimestamp()))
.toList();
BigDecimal balance = balanceRecord.getBalance();
for (AccountEntry entry : entriesBeforeRecord) {
balance = balance.subtract(entry.getEffectiveValue(accountType));
}
for (AccountEntry entry : entriesAfterRecord) {
balance = balance.add(entry.getEffectiveValue(account.getType()));
balance = balance.add(entry.getEffectiveValue(accountType));
}
return balance;
}
private BigDecimal computeBalanceWithEntriesBefore(Account account, BalanceRecord balanceRecord, List<AccountEntry> entriesBeforeRecord) {
BigDecimal balance = balanceRecord.getBalance();
for (AccountEntry entry : entriesBeforeRecord) {
balance = balance.subtract(entry.getEffectiveValue(account.getType()));
}
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

@ -0,0 +1,271 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AnalyticsRepository;
import com.andrewlalis.perfin.data.TimestampRange;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.MoneyValue;
import com.andrewlalis.perfin.model.TransactionCategory;
import com.andrewlalis.perfin.model.TransactionVendor;
import javafx.scene.paint.Color;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;
public record JdbcAnalyticsRepository(Connection conn) implements AnalyticsRepository {
@Override
public List<Pair<TransactionCategory, BigDecimal>> getSpendByCategory(TimestampRange range, Currency currency) {
return getTransactionAmountByCategoryAndType(range, currency, AccountEntry.Type.CREDIT);
}
@Override
public List<Pair<TransactionCategory, BigDecimal>> getSpendByRootCategory(TimestampRange range, Currency currency) {
return groupByRootCategory(getSpendByCategory(range, currency));
}
@Override
public List<Pair<TransactionCategory, BigDecimal>> getIncomeByCategory(TimestampRange range, Currency currency) {
return getTransactionAmountByCategoryAndType(range, currency, AccountEntry.Type.DEBIT);
}
@Override
public List<Pair<TransactionCategory, BigDecimal>> getIncomeByRootCategory(TimestampRange range, Currency currency) {
return groupByRootCategory(getIncomeByCategory(range, currency));
}
@Override
public List<Pair<TransactionVendor, BigDecimal>> getSpendByVendor(TimestampRange range, Currency currency) {
return DbUtil.findAll(
conn,
"""
SELECT
SUM(transaction.amount) AS total,
tv.id, tv.name, tv.description
FROM transaction
LEFT JOIN transaction_vendor tv ON tv.id = transaction.vendor_id
LEFT JOIN account_entry ae ON ae.transaction_id = transaction.id
WHERE
transaction.currency = ? AND
transaction.timestamp >= ? AND
transaction.timestamp <= ? AND
ae.type = 'CREDIT' 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
)
GROUP BY tv.id
ORDER BY total DESC""",
List.of(currency.getCurrencyCode(), range.start(), range.end()),
rs -> {
BigDecimal total = rs.getBigDecimal(1);
long vendorId = rs.getLong(2);
if (rs.wasNull()) return new Pair<>(null, total);
String name = rs.getString(3);
String description = rs.getString(4);
return new Pair<>(new TransactionVendor(vendorId, name, description), total);
}
);
}
@Override
public List<MoneyValue> getVendorSpend(TimestampRange range, long vendorId) {
return DbUtil.findAll(
conn,
"""
SELECT
SUM(transaction.amount) AS total,
transaction.currency AS currency,
FROM transaction
WHERE
transaction.vendor_id = ? AND
transaction.timestamp >= ? AND
transaction.timestamp <= ? AND
'!exclude' NOT IN (
SELECT tt.name
FROM transaction_tag tt
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
WHERE ttj.transaction_id = transaction.id
) AND
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id) = 1 AND
(SELECT COUNT(ae.id) FROM account_entry ae WHERE ae.transaction_id = transaction.id AND ae.type = 'CREDIT') = 1
GROUP BY transaction.currency
ORDER BY total DESC""",
List.of(vendorId, range.start(), range.end()),
rs -> {
BigDecimal total = rs.getBigDecimal(1);
String currencyCode = rs.getString(2);
return new MoneyValue(total, Currency.getInstance(currencyCode));
}
);
}
@Override
public void close() throws Exception {
conn.close();
}
private List<Pair<TransactionCategory, BigDecimal>> getTransactionAmountByCategoryAndType(TimestampRange range, Currency currency, AccountEntry.Type type) {
// First find totals for each category, using only transactions without any line items (should be most).
List<Pair<TransactionCategory, BigDecimal>> totalsBeforeLineItems = DbUtil.findAll(
conn,
"""
SELECT
SUM(transaction.amount) AS total,
tc.id, tc.parent_id, tc.name, tc.color
FROM transaction
LEFT JOIN transaction_category tc ON tc.id = transaction.category_id
LEFT JOIN account_entry ae ON ae.transaction_id = transaction.id
WHERE
transaction.currency = ? AND
ae.type = ? AND
transaction.timestamp >= ? AND
transaction.timestamp <= ? AND
'!exclude' NOT IN (
SELECT tt.name
FROM transaction_tag tt
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
WHERE ttj.transaction_id = transaction.id
) AND
(
SELECT COUNT(tli.id) = 0
FROM transaction_line_item tli
WHERE tli.transaction_id = transaction.id
)
GROUP BY tc.id
ORDER BY total DESC;""",
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
this::parseAmountAndCategory
);
// Then augment the data for any transactions which do have line items.
List<Pair<TransactionCategory, BigDecimal>> totalsFromLineItemsOnly = DbUtil.findAll(
conn,
"""
SELECT SUM(tli.value_per_item * tli.quantity) AS s, tc.*
FROM transaction_line_item tli
LEFT JOIN transaction_category tc ON tc.id = tli.category_id
LEFT JOIN transaction t ON t.id = tli.transaction_id
LEFT JOIN account_entry ae ON ae.transaction_id = t.id
WHERE
t.currency = ? AND
ae.type = ? AND
t.timestamp >= ? AND
t.timestamp <= ? AND
'!exclude' NOT IN (
SELECT tt.name
FROM transaction_tag tt
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
WHERE ttj.transaction_id = t.id
)
GROUP BY tli.category_id
ORDER BY s DESC""",
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
this::parseAmountAndCategory
);
// Finally add data for any remaining value in transactions with line items, which wasn't accounted for in line items.
List<Pair<TransactionCategory, BigDecimal>> totalsFromLeftoverTransactions = DbUtil.findAll(
conn,
"""
SELECT SUM(s), c_id, c_parent_id, c_name, c_color
FROM (
SELECT transaction.amount - SUM(tli.value_per_item * tli.quantity) AS s,
tc.id AS c_id, tc.parent_id AS c_parent_id, tc.name AS c_name, tc.color AS c_color
FROM transaction
LEFT JOIN transaction_line_item tli ON tli.transaction_id = transaction.id
LEFT JOIN transaction_category tc ON tc.id = transaction.category_id
LEFT JOIN account_entry ae ON ae.transaction_id = transaction.id
WHERE
transaction.currency = ? AND
ae.type = ? AND
transaction.timestamp >= ? AND
transaction.timestamp <= ? AND
'!exclude' NOT IN (
SELECT tt.name
FROM transaction_tag tt
LEFT JOIN transaction_tag_join ttj ON tt.id = ttj.tag_id
WHERE ttj.transaction_id = transaction.id
) AND
(
SELECT COUNT(tli.id) > 0
FROM transaction_line_item tli
WHERE tli.transaction_id = transaction.id
)
GROUP BY transaction.id
)
GROUP BY c_id""",
List.of(currency.getCurrencyCode(), type.name(), range.start(), range.end()),
this::parseAmountAndCategory
);
return combineCategorizedAmounts(List.of(
totalsBeforeLineItems,
totalsFromLineItemsOnly,
totalsFromLeftoverTransactions
));
}
private List<Pair<TransactionCategory, BigDecimal>> groupByRootCategory(List<Pair<TransactionCategory, BigDecimal>> spendByCategory) {
List<Pair<TransactionCategory, BigDecimal>> result = new ArrayList<>();
Map<TransactionCategory, BigDecimal> rootCategorySpend = new HashMap<>();
var categoryRepo = new JdbcTransactionCategoryRepository(conn);
BigDecimal uncategorizedSpend = BigDecimal.ZERO;
for (var spend : spendByCategory) {
if (spend.first() == null) {
uncategorizedSpend = uncategorizedSpend.add(spend.second());
} else {
TransactionCategory rootCategory = categoryRepo.findRoot(spend.first().id);
if (rootCategory != null) {
BigDecimal categoryTotal = rootCategorySpend.getOrDefault(rootCategory, BigDecimal.ZERO);
rootCategorySpend.put(rootCategory, categoryTotal.add(spend.second()));
}
}
}
for (var entry : rootCategorySpend.entrySet()) {
result.add(new Pair<>(entry.getKey(), entry.getValue()));
}
if (uncategorizedSpend.compareTo(BigDecimal.ZERO) > 0) {
result.add(new Pair<>(null, uncategorizedSpend));
}
result.sort((p1, p2) -> p2.second().compareTo(p1.second()));
return result;
}
private Pair<TransactionCategory, BigDecimal> parseAmountAndCategory(ResultSet rs) throws SQLException {
BigDecimal amount = rs.getBigDecimal(1);
long categoryId = rs.getLong(2);
if (rs.wasNull()) {
return new Pair<>(null, amount);
}
Long parentId = rs.getLong(3);
if (rs.wasNull()) parentId = null;
String name = rs.getString(4);
Color color = Color.valueOf("#" + rs.getString(5));
return new Pair<>(new TransactionCategory(categoryId, parentId, name, color), amount);
}
private List<Pair<TransactionCategory, BigDecimal>> combineCategorizedAmounts(List<List<Pair<TransactionCategory, BigDecimal>>> lists) {
BigDecimal uncategorizedAmount = BigDecimal.ZERO;
Map<TransactionCategory, BigDecimal> categorizedAmounts = new HashMap<>();
for (var list : lists) {
for (var p : list) {
if (p.first() == null) {
uncategorizedAmount = uncategorizedAmount.add(p.second());
} else {
BigDecimal value = categorizedAmounts.computeIfAbsent(p.first(), category -> BigDecimal.ZERO);
categorizedAmounts.put(p.first(), value.add(p.second()));
}
}
}
List<Pair<TransactionCategory, BigDecimal>> amountsByCategory = new ArrayList<>();
amountsByCategory.add(new Pair<>(null, uncategorizedAmount));
for (var entry : categorizedAmounts.entrySet()) {
amountsByCategory.add(new Pair<>(entry.getKey(), entry.getValue()));
}
amountsByCategory.sort((p1, p2) -> p2.second().compareTo(p1.second()));
return amountsByCategory;
}
}

View File

@ -5,6 +5,8 @@ import com.andrewlalis.perfin.data.ulid.UlidCreator;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Attachment;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.UncheckedIOException;
@ -18,6 +20,8 @@ import java.util.List;
import java.util.Optional;
public record JdbcAttachmentRepository(Connection conn, Path contentDir) implements AttachmentRepository {
private static final Logger log = LoggerFactory.getLogger(JdbcAttachmentRepository.class);
@Override
public Attachment insert(Path sourcePath) {
String filename = sourcePath.getFileName().toString();
@ -54,19 +58,37 @@ public record JdbcAttachmentRepository(Connection conn, Path contentDir) impleme
@Override
public void deleteById(long attachmentId) {
// First get it and try to delete the stored file.
var optionalAttachment = findById(attachmentId);
if (optionalAttachment.isPresent()) {
try {
Files.delete(optionalAttachment.get().getPath(contentDir));
} catch (IOException e) {
e.printStackTrace(System.err);
// TODO: Add some sort of persistent error logging.
}
DbUtil.updateOne(conn, "DELETE FROM attachment WHERE id = ?", List.of(attachmentId));
deleteFileOnDisk(optionalAttachment.get());
}
}
@Override
public void deleteAllOrphans() {
DbUtil.doTransaction(conn, () -> {
List<Attachment> orphans = DbUtil.findAll(
conn,
"""
SELECT * FROM attachment
WHERE
id NOT IN (SELECT attachment_id FROM transaction_attachment) AND
id NOT IN (SELECT attachment_id FROM balance_record_attachment)""",
JdbcAttachmentRepository::parseAttachment
);
for (Attachment orphan : orphans) {
deleteFileOnDisk(orphan);
DbUtil.updateOne(
conn,
"DELETE FROM attachment WHERE id = ?",
List.of(orphan.id)
);
log.debug("Deleted orphan attachment with id {} at {}.", orphan.id, orphan.getPath(contentDir));
}
});
}
@Override
public void close() throws Exception {
conn.close();
@ -81,4 +103,27 @@ public record JdbcAttachmentRepository(Connection conn, Path contentDir) impleme
rs.getString("content_type")
);
}
/**
* Internal method that's used when deleting attachments, to also remove the
* actual file contents from the Perfin profile's content directory. This
* will delete the file, and also remove the parent directory if it's empty.
* @param attachment The attachment to delete the file for.
*/
private void deleteFileOnDisk(Attachment attachment) {
Path filePath = attachment.getPath(contentDir);
if (Files.exists(filePath)) {
try {
Files.delete(filePath);
// Try and delete the parent directory if it's empty now.
try (var files = Files.list(filePath.getParent())) {
if (files.findAny().isEmpty()) {
Files.delete(filePath.getParent());
}
}
} catch (IOException e) {
log.warn("Failed to delete file {} for deleted attachment {}.", filePath, attachment.id);
}
}
}
}

View File

@ -1,11 +1,11 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.BalanceRecordRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.Attachment;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.BalanceRecordType;
import java.math.BigDecimal;
import java.nio.file.Path;
@ -15,15 +15,16 @@ import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.Currency;
import java.util.List;
import java.util.Optional;
public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) implements BalanceRecordRepository {
@Override
public long insert(LocalDateTime utcTimestamp, long accountId, BigDecimal balance, Currency currency, List<Path> attachments) {
public long insert(LocalDateTime utcTimestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency, List<Path> attachments) {
return DbUtil.doTransaction(conn, () -> {
long recordId = DbUtil.insertOne(
conn,
"INSERT INTO balance_record (timestamp, account_id, balance, currency) VALUES (?, ?, ?, ?)",
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, balance, currency.getCurrencyCode())
"INSERT INTO balance_record (timestamp, account_id, type, balance, currency) VALUES (?, ?, ?, ?, ?)",
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), accountId, type.name(), balance, currency.getCurrencyCode())
);
// Insert attachments.
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
@ -34,23 +35,65 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
stmt.executeUpdate();
}
}
// Add a history item entry.
AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn);
historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId);
return recordId;
});
}
@Override
public BalanceRecord findLatestByAccountId(long accountId) {
public BalanceRecord findLatestByAccountId(long accountId, BalanceRecordType type) {
return DbUtil.findOne(
conn,
"SELECT * FROM balance_record WHERE account_id = ? ORDER BY timestamp DESC LIMIT 1",
List.of(accountId),
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? ORDER BY timestamp DESC LIMIT 1",
List.of(accountId, type.name()),
JdbcBalanceRecordRepository::parse
).orElse(null);
}
@Override
public Optional<BalanceRecord> findById(long id) {
return DbUtil.findById(
conn,
"SELECT * FROM balance_record WHERE id = ?",
id,
JdbcBalanceRecordRepository::parse
);
}
@Override
public Optional<BalanceRecord> findClosestBefore(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
return DbUtil.findOne(
conn,
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
JdbcBalanceRecordRepository::parse
);
}
@Override
public Optional<BalanceRecord> findClosestAfter(long accountId, BalanceRecordType type, LocalDateTime utcTimestamp) {
return DbUtil.findOne(
conn,
"SELECT * FROM balance_record WHERE account_id = ? AND type = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1",
List.of(accountId, type.name(), DbUtil.timestampFromUtcLDT(utcTimestamp)),
JdbcBalanceRecordRepository::parse
);
}
@Override
public List<Attachment> findAttachments(long recordId) {
return DbUtil.findAll(
conn,
"""
SELECT *
FROM attachment
LEFT JOIN balance_record_attachment ba ON ba.attachment_id = attachment.id
WHERE ba.balance_record_id = ?
ORDER BY uploaded_at ASC, filename ASC""",
List.of(recordId),
JdbcAttachmentRepository::parseAttachment
);
}
@Override
public void deleteById(long id) {
DbUtil.updateOne(conn, "DELETE FROM balance_record WHERE id = ?", List.of(id));
@ -66,6 +109,7 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getLong("account_id"),
BalanceRecordType.valueOf(rs.getString("type").toUpperCase()),
rs.getBigDecimal("balance"),
Currency.getInstance(rs.getString("currency"))
);

View File

@ -36,7 +36,7 @@ public class JdbcDataSource implements DataSource {
@Override
public AccountRepository getAccountRepository() {
return new JdbcAccountRepository(getConnection());
return new JdbcAccountRepository(getConnection(), contentDir);
}
@Override
@ -49,13 +49,38 @@ public class JdbcDataSource implements DataSource {
return new JdbcTransactionRepository(getConnection(), contentDir);
}
@Override
public TransactionVendorRepository getTransactionVendorRepository() {
return new JdbcTransactionVendorRepository(getConnection());
}
@Override
public TransactionCategoryRepository getTransactionCategoryRepository() {
return new JdbcTransactionCategoryRepository(getConnection());
}
@Override
public TransactionLineItemRepository getTransactionLineItemRepository() {
return new JdbcTransactionLineItemRepository(getConnection());
}
@Override
public AttachmentRepository getAttachmentRepository() {
return new JdbcAttachmentRepository(getConnection(), contentDir);
}
@Override
public AccountHistoryItemRepository getAccountHistoryItemRepository() {
return new JdbcAccountHistoryItemRepository(getConnection());
public HistoryRepository getHistoryRepository() {
return new JdbcHistoryRepository(getConnection());
}
@Override
public SavedQueryRepository getSavedQueryRepository() {
return new FileSystemSavedQueryRepository(contentDir);
}
@Override
public AnalyticsRepository getAnalyticsRepository() {
return new JdbcAnalyticsRepository(getConnection());
}
}

View File

@ -1,11 +1,16 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.DataSourceFactory;
import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.migration.Migration;
import com.andrewlalis.perfin.data.impl.migration.Migrations;
import com.andrewlalis.perfin.data.util.DbUtil;
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.ArrayNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -14,16 +19,14 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.*;
import java.util.Arrays;
import java.util.List;
/**
* Component that's responsible for obtaining a JDBC data source for a profile.
*/
public class JdbcDataSourceFactory {
public class JdbcDataSourceFactory implements DataSourceFactory {
private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class);
/**
@ -31,8 +34,11 @@ public class JdbcDataSourceFactory {
* loaded with an old schema version, then we'll migrate to the latest. If
* the profile has a newer schema version, we'll exit and prompt the user
* to update their app.
* <p>
* This value should be one higher than the
* </p>
*/
public static final int SCHEMA_VERSION = 1;
public static final int SCHEMA_VERSION = 6;
public DataSource getDataSource(String profileName) throws ProfileLoadException {
final boolean dbExists = Files.exists(getDatabaseFile(profileName));
@ -56,7 +62,18 @@ public class JdbcDataSourceFactory {
throw new ProfileLoadException("Profile " + profileName + " has a database with an unsupported schema version.");
}
}
return new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
var dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
if (!testConnection(dataSource)) {
throw new ProfileLoadException("Unabled to connect to the profile's database.");
}
return dataSource;
}
public SchemaStatus getSchemaStatus(String profileName) throws IOException {
int existingSchemaVersion = getSchemaVersion(profileName);
if (existingSchemaVersion == SCHEMA_VERSION) return SchemaStatus.UP_TO_DATE;
if (existingSchemaVersion < SCHEMA_VERSION) return SchemaStatus.NEEDS_MIGRATION;
return SchemaStatus.INCOMPATIBLE;
}
private void createNewDatabase(String profileName) throws ProfileLoadException {
@ -69,6 +86,7 @@ public class JdbcDataSourceFactory {
if (in == null) throw new IOException("Could not load database schema SQL file.");
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
executeSqlScript(schemaStr, conn);
insertDefaultData(conn);
try {
writeCurrentSchemaVersion(profileName);
} catch (IOException e) {
@ -89,6 +107,66 @@ public class JdbcDataSourceFactory {
}
}
/**
* Inserts all default data into the database, using static content found in
* various locations on the classpath.
* @param conn The connection to use to insert data.
* @throws IOException If resources couldn't be read.
* @throws SQLException If SQL fails.
*/
public void insertDefaultData(Connection conn) throws IOException, SQLException {
insertDefaultCategories(conn);
insertDefaultTags(conn);
}
public void insertDefaultCategories(Connection conn) throws IOException, SQLException {
try (
var categoriesIn = JdbcDataSourceFactory.class.getResourceAsStream("/sql/data/default-categories.json");
var stmt = conn.prepareStatement(
"INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
)
) {
if (categoriesIn == null) throw new IOException("Couldn't load default categories file.");
ObjectMapper mapper = new ObjectMapper();
ArrayNode categoriesArray = mapper.readValue(categoriesIn, ArrayNode.class);
insertCategoriesRecursive(stmt, categoriesArray, null, "#FFFFFF");
}
}
private void insertCategoriesRecursive(PreparedStatement stmt, ArrayNode categoriesArray, Long parentId, String parentColorHex) throws SQLException {
for (JsonNode obj : categoriesArray) {
String name = obj.get("name").asText();
String colorHex = parentColorHex;
if (obj.hasNonNull("color")) colorHex = obj.get("color").asText(parentColorHex);
if (parentId == null) {
stmt.setNull(1, Types.BIGINT);
} else {
stmt.setLong(1, parentId);
}
stmt.setString(2, name);
stmt.setString(3, colorHex.substring(1));
int result = stmt.executeUpdate();
if (result != 1) throw new SQLException("Failed to insert category.");
long id = DbUtil.getGeneratedId(stmt);
if (obj.hasNonNull("children") && obj.get("children").isArray()) {
insertCategoriesRecursive(stmt, obj.withArray("children"), id, colorHex);
}
}
}
private void insertDefaultTags(Connection conn) throws SQLException {
final List<String> defaultTags = List.of(
"!exclude"
);
try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag (name) VALUES (?)")) {
for (var tag : defaultTags) {
stmt.setString(1, tag);
stmt.executeUpdate();
}
}
}
private boolean testConnection(JdbcDataSource dataSource) {
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
return stmt.execute("SELECT 1;");
@ -168,7 +246,7 @@ public class JdbcDataSourceFactory {
return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt");
}
private static int getSchemaVersion(String profileName) throws IOException {
public int getSchemaVersion(String profileName) throws IOException {
if (Files.exists(getSchemaVersionFile(profileName))) {
try {
return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip());

View File

@ -0,0 +1,135 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.HistoryRepository;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.history.HistoryItem;
import com.andrewlalis.perfin.model.history.HistoryTextItem;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public record JdbcHistoryRepository(Connection conn) implements HistoryRepository {
@Override
public long getOrCreateHistoryForAccount(long accountId) {
return getOrCreateHistoryForEntity(accountId, "history_account", "account_id");
}
@Override
public long getOrCreateHistoryForTransaction(long transactionId) {
return getOrCreateHistoryForEntity(transactionId, "history_transaction", "transaction_id");
}
private long getOrCreateHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
if (optionalHistoryId.isPresent()) return optionalHistoryId.get();
long historyId = DbUtil.insertOne(conn, "INSERT INTO history () VALUES ()");
String insertQuery = "INSERT INTO " + joinTableName + " (" + joinColumn + ", history_id) VALUES (?, ?)";
DbUtil.updateOne(conn, insertQuery, entityId, historyId);
return historyId;
}
@Override
public void deleteHistoryForAccount(long accountId) {
deleteHistoryForEntity(accountId, "history_account", "account_id");
}
@Override
public void deleteHistoryForTransaction(long transactionId) {
deleteHistoryForEntity(transactionId, "history_transaction", "transaction_id");
}
private void deleteHistoryForEntity(long entityId, String joinTableName, String joinColumn) {
String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?";
var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1));
if (optionalHistoryId.isPresent()) {
long historyId = optionalHistoryId.get();
DbUtil.updateOne(conn, "DELETE FROM history WHERE id = ?", historyId);
}
}
@Override
public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) {
long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.Type.TEXT.name());
DbUtil.updateOne(
conn,
"INSERT INTO history_item_text (id, description) VALUES (?, ?)",
itemId,
description
);
return new HistoryTextItem(itemId, historyId, utcTimestamp, description);
}
@Override
public Optional<HistoryItem> getItem(long id) {
return DbUtil.findById(
conn,
"SELECT * FROM history_item WHERE id = ?",
id,
JdbcHistoryRepository::parseItem
);
}
private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) {
return DbUtil.insertOne(
conn,
"INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)",
historyId,
DbUtil.timestampFromUtcLDT(timestamp),
type
);
}
@Override
public Page<HistoryItem> getItems(long historyId, PageRequest pagination) {
return DbUtil.findAll(
conn,
"SELECT * FROM history_item WHERE history_id = ?",
pagination,
List.of(historyId),
JdbcHistoryRepository::parseItem
);
}
@Override
public List<HistoryItem> getNItemsBefore(long historyId, int n, LocalDateTime timestamp) {
return DbUtil.findAll(
conn,
"""
SELECT *
FROM history_item
WHERE history_id = ? AND timestamp <= ?
ORDER BY timestamp DESC""",
List.of(historyId, DbUtil.timestampFromUtcLDT(timestamp)),
JdbcHistoryRepository::parseItem
);
}
@Override
public void close() throws Exception {
conn.close();
}
public static HistoryItem parseItem(ResultSet rs) throws SQLException {
long id = rs.getLong(1);
long historyId = rs.getLong(2);
LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3));
String type = rs.getString(4);
if (type.equalsIgnoreCase(HistoryItem.Type.TEXT.name())) {
String description = DbUtil.findOne(
rs.getStatement().getConnection(),
"SELECT description FROM history_item_text WHERE id = ?",
List.of(id),
r -> r.getString(1)
).orElseThrow();
return new HistoryTextItem(id, historyId, timestamp, description);
}
throw new SQLException("Unknown history item type: " + type);
}
}

View File

@ -0,0 +1,154 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.data.util.ColorUtil;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.TransactionCategory;
import javafx.scene.paint.Color;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository {
@Override
public Optional<TransactionCategory> findById(long id) {
return DbUtil.findById(
conn,
"SELECT * FROM transaction_category WHERE id = ?",
id,
JdbcTransactionCategoryRepository::parseCategory
);
}
@Override
public Optional<TransactionCategory> findByName(String name) {
return DbUtil.findOne(
conn,
"SELECT * FROM transaction_category WHERE name = ?",
List.of(name),
JdbcTransactionCategoryRepository::parseCategory
);
}
@Override
public List<TransactionCategory> findAllBaseCategories() {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
JdbcTransactionCategoryRepository::parseCategory
);
}
@Override
public List<TransactionCategory> findAll() {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction_category ORDER BY parent_id ASC, name ASC",
JdbcTransactionCategoryRepository::parseCategory
);
}
@Override
public TransactionCategory findRoot(long categoryId) {
TransactionCategory category = findById(categoryId).orElse(null);
if (category == null || category.getParentId() == null) return category;
return findRoot(category.getParentId());
}
@Override
public long insert(long parentId, String name, Color color) {
return DbUtil.insertOne(
conn,
"INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)",
List.of(parentId, name, ColorUtil.toHex(color))
);
}
@Override
public long insert(String name, Color color) {
return DbUtil.insertOne(
conn,
"INSERT INTO transaction_category (name, color) VALUES (?, ?)",
List.of(name, ColorUtil.toHex(color))
);
}
@Override
public void update(long id, String name, Color color) {
DbUtil.doTransaction(conn, () -> {
TransactionCategory category = findById(id).orElseThrow();
if (!category.getName().equals(name)) {
DbUtil.updateOne(
conn,
"UPDATE transaction_category SET name = ? WHERE id = ?",
name,
id
);
}
if (!category.getColor().equals(color)) {
DbUtil.updateOne(
conn,
"UPDATE transaction_category SET color = ? WHERE id = ?",
ColorUtil.toHex(color),
id
);
}
});
}
@Override
public void deleteById(long id) {
DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
}
@Override
public List<CategoryTreeNode> findTree() {
List<TransactionCategory> rootCategories = DbUtil.findAll(
conn,
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
JdbcTransactionCategoryRepository::parseCategory
);
List<CategoryTreeNode> rootNodes = new ArrayList<>(rootCategories.size());
for (var category : rootCategories) {
rootNodes.add(findTreeRecursive(category));
}
return rootNodes;
}
@Override
public CategoryTreeNode findTree(TransactionCategory root) {
return findTreeRecursive(root);
}
private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
List<TransactionCategory> childCategories = DbUtil.findAll(
conn,
"SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
List.of(root.id),
JdbcTransactionCategoryRepository::parseCategory
);
for (var childCategory : childCategories) {
node.children().add(findTreeRecursive(childCategory));
}
return node;
}
@Override
public void close() throws Exception {
conn.close();
}
public static TransactionCategory parseCategory(ResultSet rs) throws SQLException {
long id = rs.getLong("id");
Long parentId = rs.getLong("parent_id");
if (rs.wasNull()) parentId = null;
String name = rs.getString("name");
Color color = Color.valueOf("#" + rs.getString("color"));
return new TransactionCategory(id, parentId, name, color);
}
}

View File

@ -0,0 +1,79 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.TransactionLineItemRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
import com.andrewlalis.perfin.model.TransactionLineItem;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.Collections;
import java.util.List;
public record JdbcTransactionLineItemRepository(Connection conn) implements TransactionLineItemRepository {
@Override
public List<TransactionLineItem> findItems(long transactionId) {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction_line_item WHERE transaction_id = ? ORDER BY idx ASC",
List.of(transactionId),
JdbcTransactionLineItemRepository::parseItem
);
}
@Override
public List<TransactionLineItem> saveItems(long transactionId, List<TransactionLineItem> items) {
// First delete all existing line items since it's just easier that way.
DbUtil.update(conn, "DELETE FROM transaction_line_item WHERE transaction_id = ?", transactionId);
if (items.isEmpty()) return Collections.emptyList(); // Skip insertion logic if no items are present.
String query = """
INSERT INTO transaction_line_item (
transaction_id,
value_per_item,
quantity,
idx,
description,
category_id
) VALUES (?, ?, ?, ?, ?, ?)""";
try (var stmt = conn.prepareStatement(query)) {
for (int i = 0; i < items.size(); i++) {
TransactionLineItem item = items.get(i);
stmt.setLong(1, transactionId);
stmt.setBigDecimal(2, item.getValuePerItem());
stmt.setInt(3, item.getQuantity());
stmt.setInt(4, i);
stmt.setString(5, item.getDescription());
if (item.getCategoryId() == null) {
stmt.setNull(6, Types.BIGINT);
} else {
stmt.setLong(6, item.getCategoryId());
}
int rowCount = stmt.executeUpdate();
if (rowCount != 1) throw new SQLException("Failed to insert line item.");
}
return findItems(transactionId); // Simply re-fetch items afterward. Their properties may have changed.
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
@Override
public void close() throws Exception {
conn.close();
}
public static TransactionLineItem parseItem(ResultSet rs) throws SQLException {
long id = rs.getLong("id");
long transactionId = rs.getLong("transaction_id");
BigDecimal valuePerItem = rs.getBigDecimal("value_per_item");
int quantity = rs.getInt("quantity");
int idx = rs.getInt("idx");
String description = rs.getString("description");
Long categoryId = rs.getLong("category_id");
if (rs.wasNull()) categoryId = null;
return new TransactionLineItem(id, transactionId, valuePerItem, quantity, idx, description, categoryId);
}
}

View File

@ -1,18 +1,19 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.AccountEntryRepository;
import com.andrewlalis.perfin.data.AttachmentRepository;
import com.andrewlalis.perfin.data.TransactionRepository;
import com.andrewlalis.perfin.data.*;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.UncheckedSqlException;
import com.andrewlalis.perfin.model.*;
import javafx.scene.paint.Color;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.*;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@ -25,33 +26,109 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<TransactionLineItem> lineItems,
List<Path> attachments
) {
return DbUtil.doTransaction(conn, () -> {
// 1. Insert the transaction.
long txId = DbUtil.insertOne(
conn,
"INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)",
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description)
);
// 2. Insert linked account entries.
Long vendorId = null;
if (vendor != null && !vendor.isBlank()) {
vendorId = getOrCreateVendorId(vendor.strip());
}
Long categoryId = null;
if (category != null && !category.isBlank()) {
categoryId = getOrCreateCategoryId(category.strip());
}
// Insert the transaction, using a custom JDBC statement to deal with nullables.
long txId;
try (var stmt = conn.prepareStatement(
"INSERT INTO transaction (timestamp, amount, currency, description, vendor_id, category_id) VALUES (?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
)) {
stmt.setTimestamp(1, DbUtil.timestampFromUtcLDT(utcTimestamp));
stmt.setBigDecimal(2, amount);
stmt.setString(3, currency.getCurrencyCode());
if (description != null && !description.isBlank()) {
stmt.setString(4, description.strip());
} else {
stmt.setNull(4, Types.VARCHAR);
}
if (vendorId != null) {
stmt.setLong(5, vendorId);
} else {
stmt.setNull(5, Types.BIGINT);
}
if (categoryId != null) {
stmt.setLong(6, categoryId);
} else {
stmt.setNull(6, Types.BIGINT);
}
int result = stmt.executeUpdate();
if (result != 1) throw new UncheckedSqlException("Transaction insert returned " + result);
var rs = stmt.getGeneratedKeys();
if (!rs.next()) throw new UncheckedSqlException("Transaction insert didn't generate any keys.");
txId = rs.getLong(1);
}
// Insert linked account entries.
AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn);
linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency));
linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency));
// 3. Add attachments.
// Add attachments.
AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
try (var stmt = conn.prepareStatement("INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)")) {
for (var attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath);
// Insert the link-table entry.
DbUtil.setArgs(stmt, txId, attachment.id);
for (Path attachmentPath : attachments) {
Attachment attachment = attachmentRepo.insert(attachmentPath);
insertAttachmentLink(txId, attachment.id);
}
// Add tags.
for (String tag : tags) {
try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)")) {
long tagId = getOrCreateTagId(tag.toLowerCase().strip());
stmt.setLong(1, txId);
stmt.setLong(2, tagId);
stmt.executeUpdate();
}
}
// Add Line Items.
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
lineItemRepo.saveItems(txId, lineItems);
return txId;
});
}
private long getOrCreateVendorId(String name) {
var repo = new JdbcTransactionVendorRepository(conn);
TransactionVendor vendor = repo.findByName(name).orElse(null);
if (vendor != null) {
return vendor.id;
}
return repo.insert(name);
}
private long getOrCreateCategoryId(String name) {
var repo = new JdbcTransactionCategoryRepository(conn);
TransactionCategory category = repo.findByName(name).orElse(null);
if (category != null) {
return category.id;
}
return repo.insert(name, Color.WHITE);
}
private long getOrCreateTagId(String name) {
Optional<Long> optionalId = DbUtil.findOne(
conn,
"SELECT id FROM transaction_tag WHERE name = ?",
List.of(name),
rs -> rs.getLong(1)
);
return optionalId.orElseGet(() ->
DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name))
);
}
@Override
public Optional<Transaction> findById(long id) {
return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction);
@ -67,6 +144,25 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
);
}
@Override
public List<Transaction> findRecentN(int n) {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction ORDER BY timestamp DESC LIMIT " + n,
JdbcTransactionRepository::parseTransaction
);
}
@Override
public List<Transaction> findDuplicates(LocalDateTime utcTimestamp, BigDecimal amount, Currency currency) {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction WHERE timestamp = ? AND amount = ? AND currency = ? ORDER BY timestamp DESC",
List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode()),
JdbcTransactionRepository::parseTransaction
);
}
@Override
public long countAll() {
return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L);
@ -106,6 +202,26 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
return DbUtil.findAll(conn, query, pagination, JdbcTransactionRepository::parseTransaction);
}
@Override
public Optional<Transaction> findEarliest() {
return DbUtil.findOne(
conn,
"SELECT * FROM transaction ORDER BY timestamp ASC LIMIT 1",
Collections.emptyList(),
JdbcTransactionRepository::parseTransaction
);
}
@Override
public Optional<Transaction> findLatest() {
return DbUtil.findOne(
conn,
"SELECT * FROM transaction ORDER BY timestamp DESC LIMIT 1",
Collections.emptyList(),
JdbcTransactionRepository::parseTransaction
);
}
@Override
public CreditAndDebitAccounts findLinkedAccounts(long transactionId) {
Account creditAccount = DbUtil.findOne(
@ -148,9 +264,182 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
);
}
@Override
public List<String> findTags(long transactionId) {
return DbUtil.findAll(
conn,
"""
SELECT tt.name
FROM transaction_tag tt
LEFT JOIN transaction_tag_join ttj ON ttj.tag_id = tt.id
WHERE ttj.transaction_id = ?
ORDER BY tt.name ASC""",
List.of(transactionId),
rs -> rs.getString(1)
);
}
@Override
public List<String> findAllTags() {
return DbUtil.findAll(
conn,
"SELECT name FROM transaction_tag ORDER BY name ASC",
rs -> rs.getString(1)
);
}
@Override
public void deleteTag(String name) {
DbUtil.update(
conn,
"DELETE FROM transaction_tag WHERE name = ?",
name
);
}
@Override
public long countTagUsages(String name) {
return DbUtil.count(
conn,
"""
SELECT COUNT(transaction_id)
FROM transaction_tag_join
WHERE tag_id = (SELECT id FROM transaction_tag WHERE name = ?)""",
name
);
}
@Override
public void delete(long transactionId) {
DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId));
DbUtil.doTransaction(conn, () -> {
DbUtil.updateOne(conn, "DELETE FROM transaction WHERE id = ?", List.of(transactionId));
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(transactionId));
});
new JdbcAttachmentRepository(conn, contentDir).deleteAllOrphans();
}
@Override
public void update(
long id,
LocalDateTime utcTimestamp,
BigDecimal amount,
Currency currency,
String description,
CreditAndDebitAccounts linkedAccounts,
String vendor,
String category,
Set<String> tags,
List<TransactionLineItem> lineItems,
List<Attachment> existingAttachments,
List<Path> newAttachmentPaths
) {
DbUtil.doTransaction(conn, () -> {
var entryRepo = new JdbcAccountEntryRepository(conn);
var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir);
var vendorRepo = new JdbcTransactionVendorRepository(conn);
var categoryRepo = new JdbcTransactionCategoryRepository(conn);
Transaction tx = findById(id).orElseThrow();
CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id);
TransactionVendor currentVendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElseThrow();
String currentVendorName = currentVendor == null ? null : currentVendor.getName();
TransactionCategory currentCategory = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElseThrow();
String currentCategoryName = currentCategory == null ? null : currentCategory.getName();
Set<String> currentTags = new HashSet<>(findTags(id));
List<Attachment> currentAttachments = findAttachments(id);
List<String> updateMessages = new ArrayList<>();
if (!tx.getTimestamp().equals(utcTimestamp)) {
DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", DbUtil.timestampFromUtcLDT(utcTimestamp), id);
updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + ".");
}
BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP);
if (!tx.getAmount().equals(scaledAmount)) {
DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", scaledAmount, id);
updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + ".");
}
if (!tx.getCurrency().equals(currency)) {
DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", currency.getCurrencyCode(), id);
updateMessages.add("Updated currency to " + currency.getCurrencyCode() + ".");
}
if (!Objects.equals(tx.getDescription(), description)) {
DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", description, id);
updateMessages.add("Updated description.");
}
boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) ||
!tx.getCurrency().equals(currency) ||
!tx.getTimestamp().equals(utcTimestamp) ||
!currentLinkedAccounts.equals(linkedAccounts);
if (shouldUpdateAccountEntries) {
// Delete all entries and re-write them correctly.
DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", id);
linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency));
linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency));
updateMessages.add("Updated linked accounts.");
}
// Manage vendor change.
if (!Objects.equals(vendor, currentVendorName)) {
if (vendor == null || vendor.isBlank()) {
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = NULL WHERE id = ?", id);
} else {
long newVendorId = getOrCreateVendorId(vendor);
DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = ? WHERE id = ?", newVendorId, id);
}
updateMessages.add("Updated vendor name to \"" + vendor + "\".");
}
// Manage category change.
if (!Objects.equals(category, currentCategoryName)) {
if (category == null || category.isBlank()) {
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = NULL WHERE id = ?", id);
} else {
long newCategoryId = getOrCreateCategoryId(category);
DbUtil.updateOne(conn, "UPDATE transaction SET category_id = ? WHERE id = ?", newCategoryId, id);
}
updateMessages.add("Updated category name to \"" + category + "\".");
}
// Manage tags changes.
if (!currentTags.equals(tags)) {
Set<String> tagsAdded = new HashSet<>(tags);
tagsAdded.removeAll(currentTags);
Set<String> tagsRemoved = new HashSet<>(currentTags);
tagsRemoved.removeAll(tags);
for (var t : tagsRemoved) removeTag(id, t);
for (var t : tagsAdded) addTag(id, t);
if (!tagsAdded.isEmpty()) {
updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded));
}
if (!tagsRemoved.isEmpty()) {
updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved));
}
}
// Manage attachments changes.
List<Attachment> removedAttachments = new ArrayList<>(currentAttachments);
removedAttachments.removeAll(existingAttachments);
for (Attachment removedAttachment : removedAttachments) {
attachmentRepo.deleteById(removedAttachment.id);
updateMessages.add("Removed attachment \"" + removedAttachment.getFilename() + "\".");
}
for (Path attachmentPath : newAttachmentPaths) {
Attachment attachment = attachmentRepo.insert(attachmentPath);
insertAttachmentLink(tx.id, attachment.id);
updateMessages.add("Added attachment \"" + attachment.getFilename() + "\".");
}
// Manage line item changes.
TransactionLineItemRepository lineItemRepo = new JdbcTransactionLineItemRepository(conn);
List<TransactionLineItem> existingLineItems = lineItemRepo.findItems(tx.id);
if (!existingLineItems.equals(lineItems)) {
lineItemRepo.saveItems(tx.id, lineItems);
updateMessages.add("Updated line items.");
}
// Add a text history item to any linked accounts detailing the changes.
String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages);
HistoryRepository historyRepo = new JdbcHistoryRepository(conn);
long historyId = historyRepo.getOrCreateHistoryForTransaction(id);
historyRepo.addTextItem(historyId, updateMessageStr);
});
}
@Override
@ -158,13 +447,57 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem
conn.close();
}
private void insertAttachmentLink(long transactionId, long attachmentId) {
DbUtil.insertOne(
conn,
"INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)",
List.of(transactionId, attachmentId)
);
}
private long getTagId(String name) {
return DbUtil.findOne(
conn,
"SELECT id FROM transaction_tag WHERE name = ?",
List.of(name),
rs -> rs.getLong(1)
).orElse(-1L);
}
private void removeTag(long transactionId, String tag) {
long id = getTagId(tag);
if (id != -1) {
DbUtil.update(conn, "DELETE FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", transactionId, id);
}
}
private void addTag(long transactionId, String tag) {
long id = getOrCreateTagId(tag);
boolean exists = DbUtil.count(
conn,
"SELECT COUNT(tag_id) FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?",
transactionId,
id
) > 0;
if (!exists) {
DbUtil.insertOne(
conn,
"INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)",
transactionId,
id
);
}
}
public static Transaction parseTransaction(ResultSet rs) throws SQLException {
return new Transaction(
rs.getLong("id"),
DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")),
rs.getBigDecimal("amount"),
Currency.getInstance(rs.getString("currency")),
rs.getString("description")
rs.getString("description"),
rs.getObject("vendor_id", Long.class),
rs.getObject("category_id", Long.class)
);
}
}

View File

@ -0,0 +1,102 @@
package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.TransactionVendorRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.TransactionVendor;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository {
@Override
public Optional<TransactionVendor> findById(long id) {
return DbUtil.findById(
conn,
"SELECT * FROM transaction_vendor WHERE id = ?",
id,
JdbcTransactionVendorRepository::parseVendor
);
}
@Override
public Optional<TransactionVendor> findByName(String name) {
return DbUtil.findOne(
conn,
"SELECT * FROM transaction_vendor WHERE name = ?",
List.of(name),
JdbcTransactionVendorRepository::parseVendor
);
}
@Override
public List<TransactionVendor> findAll() {
return DbUtil.findAll(
conn,
"SELECT * FROM transaction_vendor ORDER BY name ASC",
JdbcTransactionVendorRepository::parseVendor
);
}
@Override
public long insert(String name, String description) {
return DbUtil.insertOne(
conn,
"INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
List.of(name, description)
);
}
@Override
public long insert(String name) {
return DbUtil.insertOne(
conn,
"INSERT INTO transaction_vendor (name) VALUES (?)",
List.of(name)
);
}
@Override
public void update(long id, String name, String description) {
DbUtil.doTransaction(conn, () -> {
TransactionVendor vendor = findById(id).orElseThrow();
if (!vendor.getName().equals(name)) {
DbUtil.updateOne(
conn,
"UPDATE transaction_vendor SET name = ? WHERE id = ?",
name,
id
);
}
if (!Objects.equals(vendor.getDescription(), description)) {
DbUtil.updateOne(
conn,
"UPDATE transaction_vendor SET description = ? WHERE id = ?",
description,
id
);
}
});
}
@Override
public void deleteById(long id) {
DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id));
}
@Override
public void close() throws Exception {
conn.close();
}
public static TransactionVendor parseVendor(ResultSet rs) throws SQLException {
return new TransactionVendor(
rs.getLong("id"),
rs.getString("name"),
rs.getString("description")
);
}
}

View File

@ -4,10 +4,23 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Utility class for defining and using all known migrations.
*/
public class Migrations {
/**
* Gets a list of migrations, as a map with the key being the version to
* migrate from. For example, a migration that takes us from version 42 to
* 43 would exist in the map with key 42.
* @return The map of all migrations.
*/
public static Map<Integer, Migration> getMigrations() {
final Map<Integer, Migration> migrations = new HashMap<>();
migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql"));
migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql"));
migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql"));
migrations.put(3, new PlainSQLMigration("/sql/migration/M003_AddLineItemCategoryAndAccountDescription.sql"));
migrations.put(4, new PlainSQLMigration("/sql/migration/M004_AddBrokerageValueRecords.sql"));
migrations.put(5, new PlainSQLMigration("/sql/migration/M005_AddCreditCardLimit.sql"));
return migrations;
}
@ -25,4 +38,14 @@ public class Migrations {
}
return selectedMigration;
}
public static Map<Integer, String> getSchemaVersionCompatibility() {
final Map<Integer, String> compatibilities = new HashMap<>();
compatibilities.put(1, "1.4.0");
return compatibilities;
}
public static String getLatestCompatibleVersion(int schemaVersion) {
return getSchemaVersionCompatibility().get(schemaVersion);
}
}

View File

@ -0,0 +1,28 @@
package com.andrewlalis.perfin.data.search;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import java.util.List;
/**
* An entity searcher will search for entities matching a list of filters.
* @param <T> The entity type to search over.
*/
public interface EntitySearcher<T> {
/**
* Gets a page of results that match the given filters.
* @param pageRequest The page request.
* @param filters The filters to apply.
* @return A page of results.
*/
Page<T> search(PageRequest pageRequest, List<SearchFilter> filters);
/**
* Gets the number of results that would be returned for a given set of
* filters.
* @param filters The filters to apply.
* @return The number of entities that match.
*/
long resultCount(List<SearchFilter> filters);
}

View File

@ -0,0 +1,118 @@
package com.andrewlalis.perfin.data.search;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.data.util.ResultSetMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public class JdbcEntitySearcher<T> implements EntitySearcher<T> {
private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class);
private final Connection conn;
private final String countExpression;
private final String selectExpression;
private final ResultSetMapper<T> resultSetMapper;
public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper<T> resultSetMapper) {
this.conn = conn;
this.countExpression = countExpression;
this.selectExpression = selectExpression;
this.resultSetMapper = resultSetMapper;
}
private Pair<String, List<Pair<Integer, Object>>> buildSearchQuery(List<SearchFilter> filters) {
if (filters.isEmpty()) return new Pair<>("", Collections.emptyList());
StringBuilder sb = new StringBuilder();
List<Pair<Integer, Object>> args = new ArrayList<>();
for (var filter : filters) {
args.addAll(filter.args());
for (var joinClause : filter.joinClauses()) {
sb.append(joinClause).append('\n');
}
}
sb.append("WHERE\n");
for (int i = 0; i < filters.size(); i++) {
sb.append(filters.get(i).whereClause());
if (i < filters.size() - 1) {
sb.append(" AND");
}
sb.append('\n');
}
return new Pair<>(sb.toString(), args);
}
private void applyArgs(PreparedStatement stmt, List<Pair<Integer, Object>> args) throws SQLException {
for (int i = 1; i <= args.size(); i++) {
Pair<Integer, Object> arg = args.get(i - 1);
if (arg.second() == null) {
stmt.setNull(i, arg.first());
} else {
stmt.setObject(i, arg.second(), arg.first());
}
}
}
@Override
public Page<T> search(PageRequest pageRequest, List<SearchFilter> filters) {
var baseQueryAndArgs = buildSearchQuery(filters);
StringBuilder sqlBuilder = new StringBuilder(selectExpression);
if (baseQueryAndArgs.first() != null && !baseQueryAndArgs.first().isBlank()) {
sqlBuilder.append('\n').append(baseQueryAndArgs.first());
}
String pagingSql = pageRequest.toSQL();
if (pagingSql != null && !pagingSql.isBlank()) {
sqlBuilder.append('\n').append(pagingSql);
}
String sql = sqlBuilder.toString();
logger.debug(
"Searching with query:\n{}\nWith arguments: {}",
sql,
baseQueryAndArgs.second().stream()
.map(Pair::second)
.map(Object::toString)
.collect(Collectors.joining(", "))
);
try (var stmt = conn.prepareStatement(sql)) {
applyArgs(stmt, baseQueryAndArgs.second());
ResultSet rs = stmt.executeQuery();
List<T> results = new ArrayList<>(pageRequest.size());
while (rs.next() && results.size() < pageRequest.size()) {
results.add(resultSetMapper.map(rs));
}
return new Page<>(results, pageRequest);
} catch (SQLException e) {
logger.error("Search failed.", e);
return new Page<>(Collections.emptyList(), pageRequest);
}
}
@Override
public long resultCount(List<SearchFilter> filters) {
var baseQueryAndArgs = buildSearchQuery(filters);
String sql = countExpression + "\n" + baseQueryAndArgs.first();
try (var stmt = conn.prepareStatement(sql)) {
applyArgs(stmt, baseQueryAndArgs.second());
ResultSet rs = stmt.executeQuery();
if (!rs.next()) throw new SQLException("No count result.");
return rs.getLong(1);
} catch (SQLException e) {
logger.error("Failed to get search result count.", e);
return 0L;
}
}
public static class Builder {
}
}

View File

@ -0,0 +1,218 @@
package com.andrewlalis.perfin.data.search;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.model.*;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
public class JdbcTransactionSearcher extends JdbcEntitySearcher<Transaction> {
public JdbcTransactionSearcher(Connection conn) {
super(
conn,
"SELECT COUNT(transaction.id) FROM transaction",
"SELECT transaction.* FROM transaction",
JdbcTransactionSearcher::parseResultSet
);
}
private static Transaction parseResultSet(ResultSet rs) throws SQLException {
long id = rs.getLong(1);
LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2));
BigDecimal amount = rs.getBigDecimal(3);
Currency currency = Currency.getInstance(rs.getString(4));
String description = rs.getString(5);
Long vendorId = rs.getLong(6);
if (rs.wasNull()) vendorId = null;
Long categoryId = rs.getLong(7);
if (rs.wasNull()) categoryId = null;
return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId);
}
public static class FilterBuilder {
private final List<SearchFilter> filters = new ArrayList<>();
private final Set<String> joinTables = new HashSet<>();
public List<SearchFilter> build() {
return filters;
}
public FilterBuilder byAccounts(Collection<Account> accounts, boolean exclude) {
if (accounts.isEmpty()) return this;
var builder = new SearchFilter.Builder();
addAccountEntryJoin(builder);
String idsString = accounts.stream()
.map(a -> Long.toString(a.id)).distinct()
.collect(Collectors.joining(","));
addInClause(builder, "account_entry.account_id", idsString, exclude);
filters.add(builder.build());
return this;
}
public FilterBuilder byAccountTypes(Collection<AccountType> types, boolean exclude) {
if (types.isEmpty()) return this;
var builder = new SearchFilter.Builder();
addAccountJoin(builder);
String typesString = types.stream()
.map(t -> "'" + t.name() + "'").distinct()
.collect(Collectors.joining(","));
addInClause(builder, "account.account_type", typesString, exclude);
filters.add(builder.build());
return this;
}
public FilterBuilder byCategories(Collection<TransactionCategory> categories, boolean exclude) {
if (categories.isEmpty()) return this;
var builder = new SearchFilter.Builder();
Set<Long> ids = Profile.getCurrent().dataSource().mapRepo(TransactionCategoryRepository.class, repo -> {
Set<Long> categoryIds = new HashSet<>();
for (var category : categories) {
var treeNode = repo.findTree(category);
categoryIds.addAll(treeNode.allIds());
}
return categoryIds;
});
String idsString = ids.stream()
.map(id -> Long.toString(id)).distinct()
.collect(Collectors.joining(","));
addInClause(builder, "transaction.category_id", idsString, exclude);
filters.add(builder.build());
return this;
}
public FilterBuilder byVendors(Collection<TransactionVendor> vendors, boolean exclude) {
if (vendors.isEmpty()) return this;
var builder = new SearchFilter.Builder();
String idsString = vendors.stream()
.map(v -> Long.toString(v.id)).distinct()
.collect(Collectors.joining(","));
addInClause(builder, "transaction.vendor_id", idsString, exclude);
filters.add(builder.build());
return this;
}
public FilterBuilder byTags(Collection<TransactionTag> tags, boolean exclude) {
if (tags.isEmpty()) return this;
var builder = new SearchFilter.Builder();
addTagJoin(builder);
var tagIdsString = tags.stream()
.map(t -> Long.toString(t.id)).distinct()
.collect(Collectors.joining(","));
addInClause(builder, "transaction_tag_join.tag_id", tagIdsString, exclude);
filters.add(builder.build());
return this;
}
public FilterBuilder byAmountGreaterThan(BigDecimal amount) {
var builder = new SearchFilter.Builder();
builder.where("transaction.amount > ?");
builder.withArg(Types.NUMERIC, amount);
filters.add(builder.build());
return this;
}
public FilterBuilder byAmountLessThan(BigDecimal amount) {
var builder = new SearchFilter.Builder();
builder.where("transaction.amount < ?");
builder.withArg(Types.NUMERIC, amount);
filters.add(builder.build());
return this;
}
public FilterBuilder byAmountEqualTo(BigDecimal amount) {
var builder = new SearchFilter.Builder();
builder.where("transaction.amount = ?");
builder.withArg(Types.NUMERIC, amount);
filters.add(builder.build());
return this;
}
public FilterBuilder byEntryType(AccountEntry.Type type) {
var builder = new SearchFilter.Builder();
addAccountEntryJoin(builder);
builder.where("account_entry.type = ?");
builder.withArg(Types.VARCHAR, type.name());
filters.add(builder.build());
return this;
}
public FilterBuilder byHasAttachments(boolean hasAttachments) {
var builder = new SearchFilter.Builder();
String subQuery = "(SELECT COUNT(attachment_id) FROM transaction_attachment WHERE transaction_id = transaction.id)";
if (hasAttachments) {
builder.where(subQuery + " > 0");
} else {
builder.where(subQuery + " = 0");
}
filters.add(builder.build());
return this;
}
public FilterBuilder byHasLineItems(boolean hasLineItems) {
var builder = new SearchFilter.Builder();
String subQuery = "(SELECT COUNT(id) FROM transaction_line_item WHERE transaction_id = transaction.id)";
if (hasLineItems) {
builder.where(subQuery + " > 0");
} else {
builder.where(subQuery + " = 0");
}
filters.add(builder.build());
return this;
}
public FilterBuilder byCurrencies(Collection<Currency> currencies, boolean exclude) {
if (currencies.isEmpty()) return this;
var builder = new SearchFilter.Builder();
String currenciesString = currencies.stream()
.map(c -> "'" + c.getCurrencyCode() + "'").distinct()
.collect(Collectors.joining(","));
addInClause(builder, "transaction.currency", currenciesString, exclude);
filters.add(builder.build());
return this;
}
private void addAccountEntryJoin(SearchFilter.Builder builder) {
if (!joinTables.contains("account_entry")) {
builder.withJoin("LEFT JOIN account_entry ON account_entry.transaction_id = transaction.id");
joinTables.add("account_entry");
}
}
private void addAccountJoin(SearchFilter.Builder builder) {
addAccountEntryJoin(builder);
if (!joinTables.contains("account")) {
builder.withJoin("LEFT JOIN account ON account.id = account_entry.account_id");
joinTables.add("account");
}
}
private void addCategoryJoin(SearchFilter.Builder builder) {
if (!joinTables.contains("transaction_category")) {
builder.withJoin("LEFT JOIN transaction_category ON transaction_category.id = transaction.category_id");
joinTables.add("transaction_category");
}
}
private void addTagJoin(SearchFilter.Builder builder) {
if (!joinTables.contains("transaction_tag_join")) {
builder.withJoin("LEFT JOIN transaction_tag_join ON transaction_tag_join.transaction_id = transaction.id");
joinTables.add("transaction_tag_join");
}
}
private void addInClause(SearchFilter.Builder builder, String valueExpr, String inExpr, boolean exclude) {
if (exclude) {
builder.where(valueExpr + " NOT IN (" + inExpr + ")");
} else {
builder.where(valueExpr + " IN (" + inExpr + ")");
}
}
}
}

View File

@ -0,0 +1,61 @@
package com.andrewlalis.perfin.data.search;
import com.andrewlalis.perfin.data.util.DbUtil;
import com.andrewlalis.perfin.data.util.Pair;
import java.sql.Types;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public interface SearchFilter {
String whereClause();
List<Pair<Integer, Object>> args();
default List<String> joinClauses() {
return Collections.emptyList();
}
record Impl(String whereClause, List<Pair<Integer, Object>> args, List<String> joinClauses) implements SearchFilter {}
class Builder {
private String whereClause;
private List<Pair<Integer, Object>> args = new ArrayList<>();
private List<String> joinClauses = new ArrayList<>();
public Builder where(String clause) {
this.whereClause = clause;
return this;
}
public Builder withArg(int sqlType, Object value) {
args.add(new Pair<>(sqlType, value));
return this;
}
public Builder withArg(int value) {
return withArg(Types.INTEGER, value);
}
public Builder withArg(long value) {
return withArg(Types.BIGINT, value);
}
public Builder withArg(String value) {
return withArg(Types.VARCHAR, value);
}
public Builder withArg(LocalDateTime utcTimestamp) {
return withArg(Types.TIMESTAMP, DbUtil.timestampFromUtcLDT(utcTimestamp));
}
public Builder withJoin(String joinClause) {
joinClauses.add(joinClause);
return this;
}
public SearchFilter build() {
return new Impl(whereClause, args, joinClauses);
}
}
}

View File

@ -24,6 +24,7 @@
package com.andrewlalis.perfin.data.ulid;
import java.io.Serial;
import java.io.Serializable;
import java.time.Instant;
import java.util.Arrays;
@ -50,6 +51,7 @@ import java.util.concurrent.ThreadLocalRandom;
*/
public final class Ulid implements Serializable, Comparable<Ulid> {
@Serial
private static final long serialVersionUID = 2625269413446854731L;
private final long msb; // most significant bits
@ -209,7 +211,7 @@ public final class Ulid implements Serializable, Comparable<Ulid> {
* pseudo-random generator should use {@link UlidCreator#getUlid()}.
*
* @return a ULID
* @see {@link ThreadLocalRandom}
* @see ThreadLocalRandom
* @since 5.1.0
*/
public static Ulid fast() {
@ -236,7 +238,7 @@ public final class Ulid implements Serializable, Comparable<Ulid> {
* @since 5.2.0
*/
public static Ulid min(long time) {
return new Ulid((time << 16) | 0x0000L, 0x0000000000000000L);
return new Ulid((time << 16), 0x0000000000000000L);
}
/**

View File

@ -0,0 +1,14 @@
package com.andrewlalis.perfin.data.util;
import javafx.scene.paint.Color;
public class ColorUtil {
public static String toHex(Color color) {
return formatColorDouble(color.getRed()) + formatColorDouble(color.getGreen()) + formatColorDouble(color.getBlue());
}
private static String formatColorDouble(double val) {
String in = Integer.toHexString((int) Math.round(val * 255));
return in.length() == 1 ? "0" + in : in;
}
}

View File

@ -5,6 +5,7 @@ import com.andrewlalis.perfin.model.MoneyValue;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.List;
import java.util.Locale;
public class CurrencyUtil {
@ -26,4 +27,14 @@ public class CurrencyUtil {
BigDecimal displayValue = money.amount().setScale(money.currency().getDefaultFractionDigits(), RoundingMode.HALF_UP);
return displayValue.toString();
}
public static String formatMoneyValues(List<MoneyValue> values) {
StringBuilder sb = new StringBuilder();
final int len = values.size();
for (int i = 0; i < len; i++) {
sb.append(formatMoneyWithCurrencyPrefix(values.get(i)));
if (i < len - 1) sb.append(", ");
}
return sb.toString();
}
}

View File

@ -18,6 +18,12 @@ public class DateUtil {
.format(DEFAULT_DATETIME_FORMAT_WITH_ZONE);
}
public static String formatUTCAsLocal(LocalDateTime utcTimestamp) {
return utcTimestamp.atOffset(ZoneOffset.UTC)
.atZoneSameInstant(ZoneId.systemDefault())
.format(DEFAULT_DATETIME_FORMAT);
}
public static LocalDateTime localToUTC(LocalDateTime localTime, ZoneId localZone) {
return localTime.atZone(localZone).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
}

View File

@ -28,7 +28,22 @@ public final class DbUtil {
}
public static void setArgs(PreparedStatement stmt, Object... args) {
setArgs(stmt, List.of(args));
for (int i = 0; i < args.length; i++) {
try {
stmt.setObject(i + 1, args[i]);
} catch (SQLException e) {
throw new UncheckedSqlException("Failed to set parameter " + (i + 1) + " to " + args[i], e);
}
}
}
public static long getGeneratedId(PreparedStatement stmt) {
try (ResultSet rs = stmt.getGeneratedKeys()) {
if (!rs.next()) throw new SQLException("No generated keys available.");
return rs.getLong(1);
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
@ -58,6 +73,17 @@ public final class DbUtil {
return findAll(conn, query, pagination, Collections.emptyList(), mapper);
}
public static long count(Connection conn, String query, Object... args) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);
var rs = stmt.executeQuery();
if (!rs.next()) throw new UncheckedSqlException("No count result available.");
return rs.getLong(1);
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);
@ -82,6 +108,10 @@ public final class DbUtil {
}
}
public static int update(Connection conn, String query, Object... args) {
return update(conn, query, List.of(args));
}
public static void updateOne(Connection conn, String query, List<Object> args) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);
@ -92,14 +122,27 @@ public final class DbUtil {
}
}
public static void updateOne(Connection conn, String query, Object... args) {
try (var stmt = conn.prepareStatement(query)) {
setArgs(stmt, args);
int updateCount = stmt.executeUpdate();
if (updateCount != 1) throw new UncheckedSqlException("Update count is " + updateCount + "; expected 1.");
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
}
public static long insertOne(Connection conn, String query, List<Object> args) {
Object[] argsArray = args.toArray();
return insertOne(conn, query, argsArray);
}
public static long insertOne(Connection conn, String query, Object... args) {
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
setArgs(stmt, args);
int result = stmt.executeUpdate();
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
var rs = stmt.getGeneratedKeys();
rs.next();
return rs.getLong(1);
return getGeneratedId(stmt);
} catch (SQLException e) {
throw new UncheckedSqlException(e);
}
@ -132,7 +175,9 @@ public final class DbUtil {
public static <T> T doTransaction(Connection conn, SQLSupplier<T> supplier) {
try {
conn.setAutoCommit(false);
return supplier.offer();
T result = supplier.offer();
conn.commit();
return result;
} catch (Exception e) {
try {
conn.rollback();

View File

@ -1,5 +1,6 @@
package com.andrewlalis.perfin.data.util;
import com.andrewlalis.perfin.model.Profile;
import javafx.stage.FileChooser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -10,6 +11,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
@ -85,4 +87,37 @@ public class FileUtil {
);
return fileChooser;
}
public static byte[] getHash(Path path) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[4096];
try (var in = Files.newInputStream(path)) {
int count = in.read(buffer);
while (count != -1) {
md.update(buffer, 0, count);
count = in.read(buffer);
}
}
return md.digest();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static void copyResourceFile(String resource, Path dest) throws IOException {
try (
var in = Profile.class.getResourceAsStream(resource);
var out = Files.newOutputStream(dest)
) {
if (in == null) throw new IOException("Could not load resource " + resource);
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

@ -8,15 +8,18 @@ import java.util.Currency;
* credit-card, etc.).
*/
public class Account extends IdEntity {
public static final int DESCRIPTION_MAX_LENGTH = 255;
private final LocalDateTime createdAt;
private final boolean archived;
private AccountType type;
private String accountNumber;
private String name;
private Currency currency;
private final AccountType type;
private final String accountNumber;
private final String name;
private final Currency currency;
private final String description;
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency) {
public Account(long id, LocalDateTime createdAt, boolean archived, AccountType type, String accountNumber, String name, Currency currency, String description) {
super(id);
this.createdAt = createdAt;
this.archived = archived;
@ -24,6 +27,7 @@ public class Account extends IdEntity {
this.accountNumber = accountNumber;
this.name = name;
this.currency = currency;
this.description = description;
}
public AccountType getType() {
@ -39,6 +43,16 @@ public class Account extends IdEntity {
return "..." + accountNumber.substring(accountNumber.length() - suffixLength);
}
public String getAccountNumberGrouped(int groupSize, char separator) {
StringBuilder sb = new StringBuilder();
int idx = 0;
while (idx < accountNumber.length()) {
sb.append(accountNumber.charAt(idx++));
if (idx % groupSize == 0 && idx < accountNumber.length()) sb.append(separator);
}
return sb.toString();
}
public String getShortName() {
String numberSuffix = getAccountNumberSuffix();
return name + " (" + numberSuffix + ")";
@ -52,20 +66,8 @@ public class Account extends IdEntity {
return currency;
}
public void setType(AccountType type) {
this.type = type;
}
public void setAccountNumber(String accountNumber) {
this.accountNumber = accountNumber;
}
public void setName(String name) {
this.name = name;
}
public void setCurrency(Currency currency) {
this.currency = currency;
public String getDescription() {
return description;
}
public LocalDateTime getCreatedAt() {

View File

@ -30,7 +30,7 @@ import java.util.Currency;
* all those extra accounts would be a burden to casual users.
* </p>
*/
public class AccountEntry extends IdEntity {
public class AccountEntry extends IdEntity implements Timestamped {
public enum Type {
CREDIT,
DEBIT
@ -87,9 +87,10 @@ public class AccountEntry extends IdEntity {
* @return The effective value of this entry, either positive or negative.
*/
public BigDecimal getEffectiveValue(AccountType accountType) {
return switch (accountType) {
case CHECKING, SAVINGS -> type == Type.DEBIT ? amount : amount.negate();
case CREDIT_CARD -> type == Type.DEBIT ? amount.negate() : amount;
};
if (accountType.areDebitsPositive()) {
return type == Type.DEBIT ? amount : amount.negate();
} else {
return type == Type.DEBIT ? amount.negate() : amount;
}
}
}

View File

@ -4,28 +4,25 @@ package com.andrewlalis.perfin.model;
* Represents the different possible account types in Perfin.
*/
public enum AccountType {
CHECKING("Checking"),
SAVINGS("Savings"),
CREDIT_CARD("Credit Card");
CHECKING("Checking", true),
SAVINGS("Savings", true),
CREDIT_CARD("Credit Card", false),
BROKERAGE("Brokerage", true);
private final String name;
private final boolean debitsPositive;
AccountType(String name) {
AccountType(String name, boolean debitsPositive) {
this.name = name;
this.debitsPositive = debitsPositive;
}
public boolean areDebitsPositive() {
return debitsPositive;
}
@Override
public String toString() {
return name;
}
public static AccountType parse(String s) {
s = s.strip().toUpperCase();
return switch (s) {
case "CHECKING" -> CHECKING;
case "SAVINGS" -> SAVINGS;
case "CREDIT CARD", "CREDITCARD" -> CREDIT_CARD;
default -> throw new IllegalArgumentException("Invalid AccountType string: " + s);
};
}
}

View File

@ -5,20 +5,20 @@ import java.time.LocalDateTime;
import java.util.Currency;
/**
* A recording of an account's real reported balance at a given point in time,
* used as a sanity check for ensuring that an account's entries add up to the
* correct balance.
* A recording of an account's real reported balance at a given point in time.
*/
public class BalanceRecord extends IdEntity {
public class BalanceRecord extends IdEntity implements Timestamped {
private final LocalDateTime timestamp;
private final long accountId;
private final BalanceRecordType type;
private final BigDecimal balance;
private final Currency currency;
public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BigDecimal balance, Currency currency) {
public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BalanceRecordType type, BigDecimal balance, Currency currency) {
super(id);
this.timestamp = timestamp;
this.accountId = accountId;
this.type = type;
this.balance = balance;
this.currency = currency;
}
@ -31,6 +31,10 @@ public class BalanceRecord extends IdEntity {
return accountId;
}
public BalanceRecordType getType() {
return type;
}
public BigDecimal getBalance() {
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;
/**
* A simple pair of accounts representing the two possible linked accounts for a
* {@link Transaction}.
* @param creditAccount The account linked as the account to which the
* transaction amount is credited.
* @param debitAccount The account linked as the account from which the
* transaction amount is debited.
*/
public record CreditAndDebitAccounts(Account creditAccount, Account debitAccount) {
public boolean hasCredit() {
return creditAccount != null;

View File

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

View File

@ -2,19 +2,14 @@ package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.PerfinApp;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
import com.andrewlalis.perfin.data.util.FileUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import java.util.*;
import java.util.function.Consumer;
/**
@ -33,37 +28,43 @@ import java.util.function.Consumer;
* class maintains a static <em>current</em> profile that can be loaded and
* unloaded.
* </p>
*
* @param name The name of the profile.
* @param settings The profile's settings.
* @param dataSource The profile's data source.
*/
public class Profile {
public record Profile(String name, Properties settings, DataSource dataSource) {
private static final Logger log = LoggerFactory.getLogger(Profile.class);
private static Profile current;
private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>();
private static final Set<WeakReference<Consumer<Profile>>> currentProfileListeners = new HashSet<>();
private final String name;
private final Properties settings;
private final DataSource dataSource;
private Profile(String name, Properties settings, DataSource dataSource) {
this.name = name;
this.settings = settings;
this.dataSource = dataSource;
public void setSettingAndSave(String settingName, String value) {
String previous = settings.getProperty(settingName);
if (Objects.equals(previous, value)) return; // Value is already set.
settings.setProperty(settingName, value);
try (var out = Files.newOutputStream(getSettingsFile(name))) {
settings.store(out, null);
} catch (IOException e) {
log.error("Failed to save settings.", e);
}
}
public String getName() {
public Optional<String> getSetting(String settingName) {
return Optional.ofNullable(settings.getProperty(settingName));
}
@Override
public String toString() {
return name;
}
public Properties getSettings() {
return settings;
}
public DataSource getDataSource() {
return dataSource;
public static Path getProfilesDir() {
return PerfinApp.APP_DIR.resolve("profiles");
}
public static Path getDir(String name) {
return PerfinApp.APP_DIR.resolve(name);
return getProfilesDir().resolve(name);
}
public static Path getContentDir(String name) {
@ -78,89 +79,23 @@ public class Profile {
return current;
}
public static void setCurrent(Profile profile) {
current = profile;
for (var ref : currentProfileListeners) {
Consumer<Profile> consumer = ref.get();
if (consumer != null) {
consumer.accept(profile);
}
}
currentProfileListeners.removeIf(ref -> ref.get() == null);
log.debug("Current profile set to {}.", current.name());
}
public static void whenLoaded(Consumer<Profile> consumer) {
if (current != null) {
consumer.accept(current);
} else {
profileLoadListeners.add(consumer);
}
}
public static List<String> getAvailableProfiles() {
try (var files = Files.list(PerfinApp.APP_DIR)) {
return files.filter(Files::isDirectory)
.map(path -> path.getFileName().toString())
.sorted().toList();
} catch (IOException e) {
log.error("Failed to get a list of available profiles.", e);
return Collections.emptyList();
}
}
public static String getLastProfile() {
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
if (Files.exists(lastProfileFile)) {
try {
String s = Files.readString(lastProfileFile).strip().toLowerCase();
if (!s.isBlank()) return s;
} catch (IOException e) {
log.error("Failed to read " + lastProfileFile, e);
}
}
return "default";
}
public static void saveLastProfile(String name) {
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
try {
Files.writeString(lastProfileFile, name);
} catch (IOException e) {
log.error("Failed to write " + lastProfileFile, e);
}
}
public static void loadLast() throws ProfileLoadException {
load(getLastProfile());
}
public static void load(String name) throws ProfileLoadException {
if (Files.notExists(getDir(name))) {
try {
initProfileDir(name);
} catch (IOException e) {
FileUtil.deleteIfPossible(getDir(name));
throw new ProfileLoadException("Failed to initialize new profile directory.", e);
}
}
Properties settings = new Properties();
try (var in = Files.newInputStream(getSettingsFile(name))) {
settings.load(in);
} catch (IOException e) {
throw new ProfileLoadException("Failed to load profile settings.", e);
}
current = new Profile(name, settings, new JdbcDataSourceFactory().getDataSource(name));
saveLastProfile(current.getName());
for (var c : profileLoadListeners) {
c.accept(current);
}
}
private static void initProfileDir(String name) throws IOException {
Files.createDirectory(getDir(name));
copyResourceFile("/text/profileDirReadme.txt", getDir(name).resolve("README.txt"));
copyResourceFile("/text/defaultProfileSettings.properties", getSettingsFile(name));
Files.createDirectory(getContentDir(name));
copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt"));
}
private static void copyResourceFile(String resource, Path dest) throws IOException {
try (
var in = Profile.class.getResourceAsStream(resource);
var out = Files.newOutputStream(dest)
) {
if (in == null) throw new IOException("Could not load resource " + resource);
in.transferTo(out);
}
currentProfileListeners.add(new WeakReference<>(consumer));
}
public static boolean validateName(String name) {
@ -168,9 +103,4 @@ public class Profile {
name.matches("\\w+") &&
name.toLowerCase().equals(name);
}
@Override
public String toString() {
return name;
}
}

View File

@ -0,0 +1,85 @@
package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.PerfinApp;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Helper class with static methods for managing backups of profiles.
*/
public class ProfileBackups {
private static final Logger log = LoggerFactory.getLogger(ProfileBackups.class);
public static Path getBackupDir(String profileName) {
return PerfinApp.APP_DIR.resolve("backups").resolve(profileName);
}
public static Path makeBackup(String name) throws IOException {
log.info("Making backup of profile \"{}\".", name);
final Path profileDir = Profile.getDir(name);
LocalDateTime now = LocalDateTime.now();
Files.createDirectories(getBackupDir(name));
Path backupFile = getBackupDir(name).resolve(String.format(
"%04d-%02d-%02d_%02d-%02d-%02d.zip",
now.getYear(), now.getMonthValue(), now.getDayOfMonth(),
now.getHour(), now.getMinute(), now.getSecond()
));
try (var out = new ZipOutputStream(Files.newOutputStream(backupFile))) {
Files.walkFileTree(profileDir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Path relativeFile = profileDir.relativize(file);
out.putNextEntry(new ZipEntry(relativeFile.toString()));
byte[] bytes = Files.readAllBytes(file);
out.write(bytes, 0, bytes.length);
out.closeEntry();
return FileVisitResult.CONTINUE;
}
});
}
return backupFile;
}
public static LocalDateTime getLastBackupTimestamp(String name) {
if (Files.notExists(getBackupDir(name))) return null;
try (var files = Files.list(getBackupDir(name))) {
return files.map(ProfileBackups::getTimestampFromBackup)
.max(LocalDateTime::compareTo)
.orElse(null);
} catch (IOException e) {
log.error("Failed to list files in profile " + name, e);
return null;
}
}
public static void cleanOldBackups(String name) {
final LocalDateTime cutoff = LocalDateTime.now().minusDays(30);
try (var files = Files.list(getBackupDir(name))) {
var filesToDelete = files.filter(path -> {
LocalDateTime timestamp = getTimestampFromBackup(path);
return timestamp.isBefore(cutoff);
}).toList();
for (var file : filesToDelete) {
Files.delete(file);
}
} catch (IOException e) {
log.error("Failed to cleanup backups.", e);
}
}
private static LocalDateTime getTimestampFromBackup(Path backupFile) {
String text = backupFile.getFileName().toString().substring(0, "0000-00-00_00-00-00".length());
return LocalDateTime.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
}
}

View File

@ -0,0 +1,134 @@
package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.PerfinApp;
import com.andrewlalis.perfin.control.Popups;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.DataSourceFactory;
import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.migration.Migrations;
import com.andrewlalis.perfin.data.util.FileUtil;
import javafx.stage.Window;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
/**
* Component responsible for loading a profile from storage, as well as some
* other basic tasks concerning the set of stored profiles.
*/
public class ProfileLoader {
private static final Logger log = LoggerFactory.getLogger(ProfileLoader.class);
private final Window window;
private final DataSourceFactory dataSourceFactory;
public ProfileLoader(Window window, DataSourceFactory dataSourceFactory) {
this.window = window;
this.dataSourceFactory = dataSourceFactory;
}
public Profile load(String name) throws ProfileLoadException {
if (Files.notExists(Profile.getDir(name))) {
try {
initProfileDir(name);
} catch (IOException e) {
FileUtil.deleteIfPossible(Profile.getDir(name));
throw new ProfileLoadException("Failed to initialize new profile directory.", e);
}
}
Properties settings = new Properties();
try (var in = Files.newInputStream(Profile.getSettingsFile(name))) {
settings.load(in);
} catch (IOException e) {
throw new ProfileLoadException("Failed to load profile settings.", e);
}
// Try to check the profile's schema version and migrate if needed.
try {
DataSourceFactory.SchemaStatus status = dataSourceFactory.getSchemaStatus(name);
if (status == DataSourceFactory.SchemaStatus.NEEDS_MIGRATION) {
boolean confirm = Popups.confirm(window, "The profile \"" + name + "\" has an outdated data schema and needs to be migrated to the latest version. Is this okay?");
if (!confirm) {
int existingSchemaVersion = dataSourceFactory.getSchemaVersion(name);
String compatibleVersion = Migrations.getLatestCompatibleVersion(existingSchemaVersion);
Popups.message(
window,
"The profile \"" + name + "\" is using schema version " + existingSchemaVersion + ", which is compatible with Perfin version " + compatibleVersion + ". Consider downgrading Perfin to access this profile safely."
);
throw new ProfileLoadException("User rejected the migration.");
}
} else if (status == DataSourceFactory.SchemaStatus.INCOMPATIBLE) {
Popups.error(window, "The profile \"" + name + "\" has a data schema that's incompatible with this app. Update Perfin to access this profile safely.");
throw new ProfileLoadException("Incompatible schema version.");
}
} catch (IOException e) {
throw new ProfileLoadException("Failed to get profile's schema status.", e);
}
// Check for a recent backup and make one if not present.
LocalDateTime lastBackup = ProfileBackups.getLastBackupTimestamp(name);
if (lastBackup == null || lastBackup.isBefore(LocalDateTime.now().minusDays(1))) {
try {
ProfileBackups.makeBackup(name);
ProfileBackups.cleanOldBackups(name);
} catch (IOException e) {
log.error("Failed to create backup for profile " + name + ".", e);
}
}
DataSource dataSource = dataSourceFactory.getDataSource(name);
return new Profile(name, settings, dataSource);
}
public static List<String> getAvailableProfiles() {
try (var files = Files.list(Profile.getProfilesDir())) {
return files.filter(Files::isDirectory)
.filter(p -> !p.getFileName().toString().startsWith("."))
.map(path -> path.getFileName().toString())
.sorted().toList();
} catch (IOException e) {
log.error("Failed to get a list of available profiles.", e);
return Collections.emptyList();
}
}
public static String getLastProfile() {
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
if (Files.exists(lastProfileFile)) {
try {
String s = Files.readString(lastProfileFile).strip().toLowerCase();
if (!s.isBlank()) return s;
} catch (IOException e) {
log.error("Failed to read " + lastProfileFile, e);
}
}
return "default";
}
public static void saveLastProfile(String name) {
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
try {
Files.writeString(lastProfileFile, name);
} catch (IOException e) {
log.error("Failed to write " + lastProfileFile, e);
}
}
@Deprecated
private static void initProfileDir(String name) throws IOException {
Files.createDirectory(Profile.getDir(name));
copyResourceFile("/text/profileDirReadme.txt", Profile.getDir(name).resolve("README.txt"));
copyResourceFile("/text/defaultProfileSettings.properties", Profile.getSettingsFile(name));
Files.createDirectory(Profile.getContentDir(name));
copyResourceFile("/text/contentDirReadme.txt", Profile.getContentDir(name).resolve("README.txt"));
}
}

View File

@ -0,0 +1,26 @@
package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.data.util.DbUtil;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
public interface Timestamped {
/**
* Gets the timestamp at which the entity was created, in UTC timezone.
* @return The UTC timestamp at which this entity was created.
*/
LocalDateTime getTimestamp();
record Stub(long id, LocalDateTime timestamp) implements Timestamped {
@Override
public LocalDateTime getTimestamp() {
return timestamp;
}
public static Stub fromResultSet(ResultSet rs) throws SQLException {
return new Stub(rs.getLong(1), DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2)));
}
}
}

View File

@ -1,5 +1,7 @@
package com.andrewlalis.perfin.model;
import com.andrewlalis.perfin.data.util.DateUtil;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Currency;
@ -10,18 +12,22 @@ import java.util.Currency;
* actual positive/negative effect is determined by the associated account
* entries that apply this transaction's amount to one or more accounts.
*/
public class Transaction extends IdEntity {
public class Transaction extends IdEntity implements Timestamped {
private final LocalDateTime timestamp;
private final BigDecimal amount;
private final Currency currency;
private final String description;
private final Long vendorId;
private final Long categoryId;
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) {
super(id);
this.timestamp = timestamp;
this.amount = amount;
this.currency = currency;
this.description = description;
this.vendorId = vendorId;
this.categoryId = categoryId;
}
public LocalDateTime getTimestamp() {
@ -40,7 +46,27 @@ public class Transaction extends IdEntity {
return description;
}
public Long getVendorId() {
return vendorId;
}
public Long getCategoryId() {
return categoryId;
}
public MoneyValue getMoneyAmount() {
return new MoneyValue(amount, currency);
}
@Override
public String toString() {
return String.format(
"Transaction (id=%d, timestamp=%s, amount=%s, currency=%s, description=%s)",
id,
timestamp.format(DateUtil.DEFAULT_DATETIME_FORMAT),
amount.toPlainString(),
currency.getCurrencyCode(),
description
);
}
}

View File

@ -0,0 +1,35 @@
package com.andrewlalis.perfin.model;
import javafx.scene.paint.Color;
public class TransactionCategory extends IdEntity {
public static final int NAME_MAX_LENGTH = 63;
private final Long parentId;
private final String name;
private final Color color;
public TransactionCategory(long id, Long parentId, String name, Color color) {
super(id);
this.parentId = parentId;
this.name = name;
this.color = color;
}
public Long getParentId() {
return parentId;
}
public String getName() {
return name;
}
public Color getColor() {
return color;
}
@Override
public String toString() {
return name;
}
}

View File

@ -0,0 +1,71 @@
package com.andrewlalis.perfin.model;
import java.math.BigDecimal;
/**
* A line item that comprises part of a transaction. Its total value (value per
* item * quantity) is part of the transaction's total value. It can be used to
* record some transactions, like purchases and invoices, in more granular
* detail.
*/
public class TransactionLineItem extends IdEntity {
public static final int DESCRIPTION_MAX_LENGTH = 255;
private final long transactionId;
private final BigDecimal valuePerItem;
private final int quantity;
private final int idx;
private final String description;
private final Long categoryId;
public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description, Long categoryId) {
super(id);
this.transactionId = transactionId;
this.valuePerItem = valuePerItem;
this.quantity = quantity;
this.idx = idx;
this.description = description;
this.categoryId = categoryId;
}
public long getTransactionId() {
return transactionId;
}
public BigDecimal getValuePerItem() {
return valuePerItem;
}
public int getQuantity() {
return quantity;
}
public int getIdx() {
return idx;
}
public String getDescription() {
return description;
}
public Long getCategoryId() {
return categoryId;
}
public BigDecimal getTotalValue() {
return valuePerItem.multiply(new BigDecimal(quantity));
}
@Override
public String toString() {
return String.format(
"TransactionLineItem(id=%d, transactionId=%d, valuePerItem=%s, quantity=%d, idx=%d, description=\"%s\")",
id,
transactionId,
valuePerItem.toPlainString(),
quantity,
idx,
description
);
}
}

View File

@ -0,0 +1,24 @@
package com.andrewlalis.perfin.model;
/**
* A tag that can be applied to a transaction to add some user-defined semantic
* meaning to it.
*/
public class TransactionTag extends IdEntity {
public static final int NAME_MAX_LENGTH = 63;
private final String name;
public TransactionTag(long id, String name) {
super(id);
this.name = name;
}
public String getName() {
return name;
}
@Override
public String toString() {
return name;
}
}

View File

@ -0,0 +1,32 @@
package com.andrewlalis.perfin.model;
/**
* A vendor is a business establishment that can be linked to a transaction, to
* denote the business that the transaction took place with.
*/
public class TransactionVendor extends IdEntity {
public static final int NAME_MAX_LENGTH = 255;
public static final int DESCRIPTION_MAX_LENGTH = 255;
private final String name;
private final String description;
public TransactionVendor(long id, String name, String description) {
super(id);
this.name = name;
this.description = description;
}
public String getName() {
return name;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return name;
}
}

View File

@ -1,36 +0,0 @@
package com.andrewlalis.perfin.model.history;
import com.andrewlalis.perfin.model.IdEntity;
import java.time.LocalDateTime;
/**
* The base class representing account history items, a read-only record of an
* account's data and changes over time. The type of history item determines
* what exactly it means, and could be something like an account entry, balance
* record, or modifications to the account's properties.
*/
public class AccountHistoryItem extends IdEntity {
private final LocalDateTime timestamp;
private final long accountId;
private final AccountHistoryItemType type;
public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) {
super(id);
this.timestamp = timestamp;
this.accountId = accountId;
this.type = type;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public long getAccountId() {
return accountId;
}
public AccountHistoryItemType getType() {
return type;
}
}

View File

@ -1,7 +0,0 @@
package com.andrewlalis.perfin.model.history;
public enum AccountHistoryItemType {
TEXT,
ACCOUNT_ENTRY,
BALANCE_RECORD
}

View File

@ -0,0 +1,39 @@
package com.andrewlalis.perfin.model.history;
import com.andrewlalis.perfin.model.IdEntity;
import com.andrewlalis.perfin.model.Timestamped;
import java.time.LocalDateTime;
/**
* Represents a single polymorphic history item. The item's "type" attribute
* tells where to find additional type-specific data.
*/
public abstract class HistoryItem extends IdEntity implements Timestamped {
public enum Type {
TEXT
}
private final long historyId;
private final LocalDateTime timestamp;
private final Type type;
public HistoryItem(long id, long historyId, LocalDateTime timestamp, Type type) {
super(id);
this.historyId = historyId;
this.timestamp = timestamp;
this.type = type;
}
public long getHistoryId() {
return historyId;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public Type getType() {
return type;
}
}

View File

@ -0,0 +1,16 @@
package com.andrewlalis.perfin.model.history;
import java.time.LocalDateTime;
public class HistoryTextItem extends HistoryItem {
private final String description;
public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) {
super(id, historyId, timestamp, HistoryItem.Type.TEXT);
this.description = description;
}
public String getDescription() {
return description;
}
}

View File

@ -1,45 +0,0 @@
package com.andrewlalis.perfin.view;
import com.andrewlalis.perfin.model.Account;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.util.Callback;
public class AccountComboBoxCellFactory implements Callback<ListView<Account>, ListCell<Account>> {
private final String emptyCellText;
public AccountComboBoxCellFactory(String emptyCellText) {
this.emptyCellText = emptyCellText;
}
public AccountComboBoxCellFactory() {
this("None");
}
public static class AccountListCell extends ListCell<Account> {
private final Label label = new Label();
private final String emptyCellText;
public AccountListCell(String emptyCellText) {
this.emptyCellText = emptyCellText;
label.setStyle("-fx-text-fill: black;");
}
@Override
protected void updateItem(Account item, boolean empty) {
super.updateItem(item, empty);
if (item == null || empty) {
label.setText(emptyCellText);
} else {
label.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
}
setGraphic(label);
}
}
@Override
public ListCell<Account> call(ListView<Account> param) {
return new AccountListCell(emptyCellText);
}
}

View File

@ -1,8 +1,10 @@
package com.andrewlalis.perfin.view;
import javafx.beans.WeakListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import java.lang.ref.WeakReference;
import java.util.List;
@ -86,4 +88,9 @@ public class BindingUtil {
return false;
}
}
public static void bindManagedAndVisible(Node node, ObservableValue<? extends Boolean> value) {
node.managedProperty().bind(node.visibleProperty());
node.visibleProperty().bind(value);
}
}

View File

@ -34,7 +34,7 @@ public class ImageCache {
"S-" + smooth;
}
public static Image getLogo64() {
return instance.get("/images/perfin-logo_64.png", 64, 64, true, true);
public static Image getLogo256() {
return instance.get("/images/perfin-logo_256.png", 256, 256, true, true);
}
}

View File

@ -1,5 +1,6 @@
package com.andrewlalis.perfin.view;
import javafx.scene.Scene;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.Window;
@ -15,7 +16,11 @@ public class ProfilesStage extends Stage {
setTitle("Profiles");
setAlwaysOnTop(false);
initModality(Modality.APPLICATION_MODAL);
setScene(SceneUtil.load("/profiles-view.fxml"));
Scene scene = SceneUtil.load("/profiles-view.fxml");
scene.getStylesheets().addAll(
ProfilesStage.class.getResource("/style/base.css").toExternalForm()
);
setScene(scene);
}
public static void open(Window owner) {

View File

@ -9,6 +9,7 @@ import javafx.stage.Stage;
import javafx.stage.StageStyle;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
/**
@ -17,16 +18,18 @@ import java.util.function.Consumer;
*/
public class StartupSplashScreen extends Stage implements Consumer<String> {
private final List<ThrowableConsumer<Consumer<String>>> tasks;
private final boolean delayTasks;
private boolean startupSuccessful = false;
private final TextArea textArea = new TextArea();
public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks) {
public StartupSplashScreen(List<ThrowableConsumer<Consumer<String>>> tasks, boolean delayTasks) {
this.tasks = tasks;
this.delayTasks = delayTasks;
setTitle("Starting Perfin...");
setResizable(false);
initStyle(StageStyle.UNDECORATED);
getIcons().add(ImageCache.getLogo64());
getIcons().add(ImageCache.getLogo256());
setScene(buildScene());
setOnShowing(event -> runTasks());
@ -53,36 +56,57 @@ public class StartupSplashScreen extends Stage implements Consumer<String> {
textArea.setFocusTraversable(false);
Scene scene = new Scene(root, 400.0, 200.0);
scene.getStylesheets().add(StartupSplashScreen.class.getResource("/style/startup-splash-screen.css").toExternalForm());
scene.getStylesheets().addAll(
StartupSplashScreen.class.getResource("/style/base.css").toExternalForm(),
StartupSplashScreen.class.getResource("/style/startup-splash-screen.css").toExternalForm()
);
return scene;
}
/**
* Runs all tasks sequentially, invoking each one on the JavaFX main thread,
* and quitting if there's any exception thrown.
*/
private void runTasks() {
Thread.ofVirtual().start(() -> {
if (delayTasks) sleepOrThrowRE(1000);
for (var task : tasks) {
try {
task.accept(this);
Thread.sleep(100);
CompletableFuture<Void> future = new CompletableFuture<>();
Platform.runLater(() -> {
try {
task.accept(this);
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
future.join();
if (delayTasks) sleepOrThrowRE(500);
} catch (Exception e) {
accept("Startup failed: " + e.getMessage());
e.printStackTrace(System.err);
try {
Thread.sleep(5000);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
sleepOrThrowRE(5000);
Platform.runLater(this::close);
return;
}
}
accept("Startup successful!");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (delayTasks) sleepOrThrowRE(1000);
startupSuccessful = true;
Platform.runLater(this::close);
});
}
/**
* Helper method to sleep the current thread or throw a runtime exception.
* @param ms The number of milliseconds to sleep for.
*/
private static void sleepOrThrowRE(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -1,36 +0,0 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.control.TransactionsViewController;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.AccountEntry;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import javafx.scene.control.Hyperlink;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import static com.andrewlalis.perfin.PerfinApp.router;
public class AccountHistoryAccountEntryTile extends AccountHistoryItemTile {
public AccountHistoryAccountEntryTile(AccountHistoryItem item, AccountHistoryItemRepository repo) {
super(item);
AccountEntry entry = repo.getAccountEntryItem(item.id);
if (entry == null) {
setCenter(new TextFlow(new Text("Deleted account entry because of deleted transaction.")));
return;
}
Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue()));
Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId());
transactionLink.setOnAction(event -> router.navigate(
"transactions",
new TransactionsViewController.RouteContext(entry.getTransactionId())
));
var text = new TextFlow(
transactionLink,
new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of "),
amountText
);
setCenter(text);
}
}

View File

@ -1,40 +0,0 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.control.AccountViewController;
import com.andrewlalis.perfin.control.Popups;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.CurrencyUtil;
import com.andrewlalis.perfin.model.BalanceRecord;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import javafx.application.Platform;
import javafx.scene.control.Hyperlink;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile {
public AccountHistoryBalanceRecordTile(AccountHistoryItem item, AccountHistoryItemRepository repo, AccountViewController controller) {
super(item);
BalanceRecord balanceRecord = repo.getBalanceRecordItem(item.id);
if (balanceRecord == null) {
setCenter(new TextFlow(new Text("Deleted balance record was added.")));
return;
}
Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount()));
var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText);
setCenter(text);
Hyperlink deleteLink = new Hyperlink("Delete this balance record");
deleteLink.setOnAction(event -> {
boolean confirm = Popups.confirm("Are you sure you want to delete this balance record? It will be removed permanently, and cannot be undone.");
if (confirm) {
Profile.getCurrent().getDataSource().useBalanceRecordRepository(balanceRecordRepo -> {
balanceRecordRepo.deleteById(balanceRecord.id);
Platform.runLater(controller::reloadHistory);
});
}
});
setBottom(deleteLink);
}
}

View File

@ -1,37 +0,0 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.control.AccountViewController;
import com.andrewlalis.perfin.data.AccountHistoryItemRepository;
import com.andrewlalis.perfin.data.util.DateUtil;
import com.andrewlalis.perfin.model.history.AccountHistoryItem;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
/**
* A tile that shows a brief bit of information about an account history item.
*/
public abstract class AccountHistoryItemTile extends BorderPane {
public AccountHistoryItemTile(AccountHistoryItem item) {
setStyle("""
-fx-border-color: lightgray;
-fx-border-radius: 5px;
-fx-padding: 5px;
""");
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp()));
timestampLabel.setStyle("-fx-font-size: small;");
setTop(timestampLabel);
}
public static AccountHistoryItemTile forItem(
AccountHistoryItem item,
AccountHistoryItemRepository repo,
AccountViewController controller
) {
return switch (item.getType()) {
case TEXT -> new AccountHistoryTextTile(item, repo);
case ACCOUNT_ENTRY -> new AccountHistoryAccountEntryTile(item, repo);
case BALANCE_RECORD -> new AccountHistoryBalanceRecordTile(item, repo, controller);
};
}
}

Some files were not shown because too many files have changed in this diff Show More