Compare commits

...

15 Commits

Author SHA1 Message Date
bdf200211b Change app ID to com.outlay.app, add AppStream metadata, fix toast visibility
- Change app ID from io.github.outlay to com.outlay.app across all files
- Add AppStream metainfo with full feature description, 16 screenshots, and v0.1.0 release
- Update desktop file with expanded metadata (StartupNotify, SingleMainWindow)
- Add summary and description fields to GSchema keys
- Move toast overlay outside ScrolledWindow so notifications stay visible in viewport
- Embed tray icon as ARGB pixmap data for reliable system tray display
- Register hicolor icon theme path for taskbar icon on Wayland
- Remove unused icon variants (old naming, web favicons, SVG, ICO, shadow)
- Add screenshots to data/screenshots/
- Update build script with metainfo and screenshot bundling
2026-03-03 22:15:59 +02:00
f46a86134a Bust icon cache in README 2026-03-03 21:35:50 +02:00
a6626bfbe3 Add emojis to README section headers 2026-03-03 21:33:56 +02:00
c6677979fe Fix icon exports to have transparent background instead of checkerboard 2026-03-03 21:31:05 +02:00
3c5f96dc49 Add project README with full feature documentation 2026-03-03 21:26:07 +02:00
10a76e3003 Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
- Implement subscriptions view with bidirectional recurring transaction sync
- Add cascade delete/pause/resume between subscriptions and recurring
- Fix foreign key constraints when deleting recurring transactions
- Add cross-view instant refresh via callback pattern
- Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation
- Smooth budget sparklines using shared monotone_subdivide function
- Add vertical spacing to budget rows
- Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage
- Add calendar, credit cards, forecast, goals, insights, and wishlist views
- Add date picker, numpad, quick-add, category combo, and edit dialog components
- Add import/export for CSV, JSON, OFX, QIF formats
- Add NLP transaction parsing, OCR receipt scanning, expression evaluator
- Add notification support, Sankey chart, tray icon
- Add demo data seeder with full DB wipe
- Expand database schema with subscriptions, goals, credit cards, and more
2026-03-03 21:18:37 +02:00
773dae4684 Add feature batch 2 implementation plan (21 tasks)
Detailed step-by-step plan covering schema migration, core modules
(NLP, Sankey, PDF import), shared utilities, new views (Insights,
Credit Cards), and modifications to existing views.
2026-03-03 16:30:49 +02:00
524672a42e Add feature batch 2 design doc (16 features)
Covers custom budget cycles, bulk operations, monthly recap,
natural language entry, Sankey diagram, what-if sandbox, tags UI,
splits UI, templates UI, auto-categorization rules UI, searchable
category picker, PDF import, spending streaks, goal projections,
credit card tracking, and anomaly alerts.
2026-03-03 16:24:55 +02:00
e0a668600e Add AppImage build script 2026-03-02 00:59:27 +02:00
0c4ae673a8 Add desktop integration files and window state persistence 2026-03-02 00:58:21 +02:00
ed5a5e231f Add settings view with theme, categories, export, and backup 2026-03-02 00:57:05 +02:00
2fff781a53 Add full state backup and restore with ZIP archives 2026-03-02 00:53:17 +02:00
341e31ed3b Add PDF monthly report generation 2026-03-02 00:51:31 +02:00
e53301421e Add JSON export for full data dump 2026-03-02 00:43:23 +02:00
987ab925ef Add CSV export for transactions with date filtering 2026-03-02 00:41:34 +02:00
10103 changed files with 112784 additions and 1185 deletions

9
.gitignore vendored
View File

@@ -10,6 +10,12 @@ outlay-gtk/target/
**/*.rs.bk
*.pdb
# AppImage build artifacts
AppDir/
*.AppImage
linuxdeploy-*
linuxdeploy-plugin-*
# IDE
.idea/
.vscode/
@@ -17,6 +23,9 @@ outlay-gtk/target/
*.swo
*~
# Trash
.trash/
# OS
.DS_Store
Thumbs.db

617
Cargo.lock generated
View File

@@ -8,6 +8,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "adobe-cmap-parser"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3"
dependencies = [
"pom 1.1.0",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -46,6 +55,40 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -92,12 +135,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -130,12 +167,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -173,7 +204,7 @@ version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"cairo-sys-rs",
"glib",
"libc",
@@ -263,12 +294,6 @@ dependencies = [
"cc",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.7"
@@ -279,6 +304,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const_fn"
version = "0.4.11"
@@ -537,12 +571,79 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "euclid"
version = "0.20.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad"
dependencies = [
"num-traits",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -556,13 +657,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fdeflate"
version = "0.3.7"
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "field-offset"
@@ -649,6 +747,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -759,7 +870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1c422344482708cb32db843cf3f55f27918cd24fec7b505bde895a1e8702c34"
dependencies = [
"derive_more",
"lopdf",
"lopdf 0.26.0",
"printpdf",
"rusttype",
]
@@ -827,7 +938,7 @@ version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16877c6e619447e0bcb6de326a42a8bd02b36328cfeeda210135425e576efa3d"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"futures-channel",
"futures-core",
"futures-executor",
@@ -1024,6 +1135,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
@@ -1261,20 +1378,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.24.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"jpeg-decoder",
"num-traits",
"png",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@@ -1354,12 +1457,6 @@ dependencies = [
"libc",
]
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]]
name = "js-sys"
version = "0.3.91"
@@ -1370,6 +1467,19 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "ksni"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b29c089f14ce24c5b25d9bdcb265413b5e0c3df0871823e0d96bd83bc52a24"
dependencies = [
"futures-util",
"pastey",
"serde",
"tokio",
"zbus",
]
[[package]]
name = "libadwaita"
version = "0.9.1"
@@ -1424,6 +1534,12 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -1450,10 +1566,28 @@ dependencies = [
"linked-hash-map",
"log",
"lzw",
"pom",
"pom 3.4.0",
"time 0.2.27",
]
[[package]]
name = "lopdf"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff"
dependencies = [
"encoding_rs",
"flate2",
"indexmap",
"itoa 1.0.17",
"log",
"md-5",
"nom",
"rangemap",
"time 0.3.47",
"weezl",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -1487,6 +1621,16 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.0"
@@ -1508,6 +1652,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1529,6 +1679,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-conv"
version = "0.2.0"
@@ -1565,6 +1725,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "outlay-core"
version = "0.1.0"
@@ -1572,6 +1742,8 @@ dependencies = [
"chrono",
"csv",
"genpdf",
"pdf-extract",
"rand 0.8.5",
"reqwest",
"rusqlite",
"serde",
@@ -1585,12 +1757,13 @@ dependencies = [
name = "outlay-gtk"
version = "0.1.0"
dependencies = [
"cairo-rs",
"chrono",
"gdk4",
"gtk4",
"ksni",
"libadwaita",
"outlay-core",
"plotters",
"tokio",
]
@@ -1618,6 +1791,18 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pastey"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
[[package]]
name = "pbkdf2"
version = "0.12.2"
@@ -1628,6 +1813,21 @@ dependencies = [
"hmac",
]
[[package]]
name = "pdf-extract"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575"
dependencies = [
"adobe-cmap-parser",
"encoding_rs",
"euclid",
"lopdf 0.34.0",
"postscript",
"type1-encoding-parser",
"unicode-normalization",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1653,46 +1853,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plotters"
version = "0.3.7"
name = "pom"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-bitmap",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-bitmap"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405"
dependencies = [
"image",
"plotters-backend",
]
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6"
[[package]]
name = "pom"
@@ -1703,6 +1867,12 @@ dependencies = [
"bstr",
]
[[package]]
name = "postscript"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1734,7 +1904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2472a184bcb128d0e3db65b59ebd11d010259a5e14fd9d048cba8f2c9302d4"
dependencies = [
"js-sys",
"lopdf",
"lopdf 0.26.0",
"rusttype",
"time 0.2.27",
]
@@ -1793,7 +1963,7 @@ dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -1834,14 +2004,35 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
@@ -1851,7 +2042,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
@@ -1863,6 +2063,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rangemap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
[[package]]
name = "regex-automata"
version = "0.4.14"
@@ -1939,7 +2145,7 @@ version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -1972,6 +2178,19 @@ dependencies = [
"semver 1.0.27",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
@@ -2094,7 +2313,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -2175,6 +2394,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
@@ -2216,6 +2446,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
@@ -2383,7 +2623,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -2417,6 +2657,19 @@ version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
[[package]]
name = "tempfile"
version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -2467,7 +2720,7 @@ dependencies = [
"libc",
"standback",
"stdweb",
"time-macros",
"time-macros 0.1.1",
"version_check",
"winapi",
]
@@ -2479,10 +2732,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa 1.0.17",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros 0.2.27",
]
[[package]]
@@ -2501,6 +2756,16 @@ dependencies = [
"time-macros-impl",
]
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "time-macros-impl"
version = "0.1.2"
@@ -2549,8 +2814,10 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -2660,7 +2927,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"bytes",
"futures-util",
"http",
@@ -2691,9 +2958,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -2709,18 +2988,47 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "type1-encoding-parser"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b"
dependencies = [
"pom 1.1.0",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
dependencies = [
"memoffset",
"tempfile",
"winapi",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2745,6 +3053,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
dependencies = [
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -2885,6 +3204,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -3261,6 +3586,62 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
dependencies = [
"async-broadcast",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.40"
@@ -3430,3 +3811,43 @@ dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"winnow",
]

View File

@@ -1,6 +1,7 @@
[workspace]
members = ["outlay-core", "outlay-gtk"]
resolver = "2"
default-members = ["outlay-gtk"]
[workspace.package]
version = "0.1.0"

709
README.md Normal file
View File

@@ -0,0 +1,709 @@
<p align="center">
<img src="outlay-gtk/data/icons/com.outlay.Outlay-256.png?v=2" alt="Outlay" width="128" height="128">
</p>
<h1 align="center">Outlay</h1>
<p align="center">
<strong>Personal finance for people, not corporations.</strong><br>
Track expenses, plan budgets, and take direct control of your money.
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/License-CC0_1.0-brightgreen?style=flat-square" alt="License: CC0"></a>
<a href="#"><img src="https://img.shields.io/badge/Language-Rust-orange?style=flat-square&logo=rust" alt="Rust"></a>
<a href="#"><img src="https://img.shields.io/badge/Toolkit-GTK4-4a86cf?style=flat-square&logo=gnome" alt="GTK4"></a>
<a href="#"><img src="https://img.shields.io/badge/UI-libadwaita_1.8-4a86cf?style=flat-square&logo=gnome" alt="libadwaita"></a>
<a href="#"><img src="https://img.shields.io/badge/Database-SQLite-003B57?style=flat-square&logo=sqlite" alt="SQLite"></a>
<a href="#"><img src="https://img.shields.io/badge/Platform-Linux-FCC624?style=flat-square&logo=linux&logoColor=black" alt="Linux"></a>
<a href="#"><img src="https://img.shields.io/badge/Display-Wayland-yellow?style=flat-square" alt="Wayland"></a>
<a href="#"><img src="https://img.shields.io/badge/Packaging-AppImage-blue?style=flat-square" alt="AppImage"></a>
<a href="#"><img src="https://img.shields.io/badge/Tracking-Zero-success?style=flat-square" alt="No Tracking"></a>
<a href="#"><img src="https://img.shields.io/badge/Ads-None-success?style=flat-square" alt="No Ads"></a>
</p>
---
Outlay is a desktop finance app built with Rust and GTK4. It runs entirely on your machine, stores everything locally in SQLite, and never phones home. No accounts, no cloud sync, no subscriptions, no data harvesting. Your financial data stays yours - no middlemen, no landlords between you and your own information.
It exists because tracking your spending shouldn't require handing your most intimate data to some company's servers. Every feature is available to everyone equally - no premium tiers, no artificial scarcity, no paywalls.
Released into the public domain under CC0. Take it, use it, change it, share it. It belongs to the commons.
---
## Table of Contents
- [Features at a Glance](#-features-at-a-glance)
- [Installation](#-installation)
- [Building from Source](#%EF%B8%8F-building-from-source)
- [Views and Functionality](#-views-and-functionality)
- [Import and Export](#-import-and-export)
- [Multi-Currency Support](#-multi-currency-support)
- [Notifications](#-notifications)
- [System Tray](#-system-tray)
- [Keyboard and Workflow](#%EF%B8%8F-keyboard-and-workflow)
- [Architecture](#-architecture)
- [Data Storage](#-data-storage)
- [Packaging](#-packaging)
- [Contributing](#-contributing)
- [License](#-license)
---
## 🧭 Overview
Outlay is a native Linux application for personal income and expense tracking. It is built from scratch in Rust using GTK4 and libadwaita 1.8 for a modern GNOME-native experience. Everything runs locally - the database is a single SQLite file on your disk, exchange rates are cached after fetching, and there is no telemetry or network activity beyond currency conversion lookups.
The goal is simple: give people a tool that is genuinely useful for managing their own money, without any of the usual strings attached. No venture capital, no growth metrics, no engagement loops. Just a straightforward app that does what it says.
---
## ✨ Features at a Glance
| Area | What it does |
|---|---|
| **Transaction logging** | Quick expense/income entry with categories, notes, payees, tags, and inline math |
| **History browsing** | Day-grouped transaction list with search, category filters, batch operations |
| **Charts** | Donut charts, bar charts, and smooth trend lines with monthly navigation |
| **Budgets** | Per-category monthly budgets with progress bars, rollover, pace tracking, sparklines |
| **Recurring** | Automated transaction generation for daily/weekly/biweekly/monthly/yearly patterns |
| **Subscriptions** | Lifecycle tracking with monthly/yearly cost summaries, linked to recurring |
| **Savings goals** | Target amounts with deadlines, progress tracking, color-coded visualization |
| **Credit cards** | Balance and utilization tracking, statement cycle management |
| **Wishlist** | Want-to-buy items with priority, pricing, and purchase status |
| **Forecast** | Cash flow projections based on recurring patterns and historical averages |
| **Insights** | Spending anomaly detection, monthly recaps, year-over-year comparisons |
| **Achievements** | 16+ unlockable badges for financial milestones and healthy habits |
| **Streaks** | No-spend and on-budget streak tracking with personal bests |
| **Calendar** | Spending heatmap with per-day drill-down |
| **Multi-currency** | 40+ currencies with automatic exchange rate fetching and caching |
| **Import/Export** | CSV, JSON, OFX, QIF, PDF - bring your data in, take your data out |
| **Backup** | Full-state `.outlay` archive files for portability and safekeeping |
| **System tray** | Minimize to tray, quick-add popup, persistent background operation |
| **Notifications** | Budget alerts, bill reminders, weekly digests, achievement unlocks |
| **OCR** | Extract amounts from receipt images via Tesseract |
| **NLP** | Natural language parsing for quick transaction entry |
---
## 📦 Installation
### AppImage (Recommended)
Download the latest AppImage from the releases page, make it executable, and run:
```bash
chmod +x Outlay-*.AppImage
./Outlay-*.AppImage
```
No installation required. It carries everything it needs. Works on any modern Linux distribution.
### From Source
See [Building from Source](#-building-from-source) below.
---
## 🛠️ Building from Source
### Dependencies
You will need:
- Rust toolchain (stable, 2024 edition)
- GTK4 development libraries (4.10+)
- libadwaita development libraries (1.8+)
- SQLite3 development headers
- pkg-config
On Debian/Ubuntu:
```bash
sudo apt install libgtk-4-dev libadwaita-1-dev libsqlite3-dev pkg-config
```
On Fedora:
```bash
sudo dnf install gtk4-devel libadwaita-devel sqlite-devel pkg-config
```
On Arch:
```bash
sudo pacman -S gtk4 libadwaita sqlite pkg-config
```
### Build and Run
```bash
git clone https://git.lashman.live/lashman/outlay.git
cd outlay
cargo build --release
./target/release/outlay-gtk
```
### Demo Data
To populate the app with two years of realistic sample data for testing:
```bash
cargo run -p outlay-core --bin seed-demo
```
This wipes the existing database and creates a fresh one filled with transactions, budgets, recurring items, subscriptions, and goals across various categories.
---
## 🖥 Views and Functionality
### 📝 Log View
The default view. A quick-entry form for logging transactions.
- Toggle between expense and income modes
- Amount field with auto-focus and inline arithmetic (type `12.50+3` and it evaluates to `15.50`)
- Currency selector with 40+ options
- Category dropdown filtered by transaction type
- Date picker defaulting to today
- Optional note and payee fields
- Toast confirmation on save
The form is designed for speed. Open the app, type a number, pick a category, save. Three seconds and you are done. No friction, no unnecessary steps - because tracking expenses should be easier than not tracking them.
### 📜 History
Browse all your transactions grouped by day.
- Month navigation with previous/next controls
- Full-text search across notes, payees, and categories
- Category filter chips for quick narrowing
- Tag-based filtering
- Day headers with date labels ("Today", "Yesterday", "Feb 27")
- Per-transaction display: category icon, name, amount (color-coded green/red), note
- Daily subtotals showing income, expenses, and net
- Selection mode for batch delete operations
- Inline edit and delete per transaction
- Spending anomaly banners when unusual patterns are detected
Every transaction you have ever logged is here, organized chronologically. Nothing hidden, nothing summarized away. Your complete financial record, accessible and transparent.
### 📊 Charts
Visual breakdowns of where your money goes and comes from.
- **Donut chart**: Category breakdown for the selected month, color-coded, with interactive segments
- **Bar chart**: Side-by-side monthly income vs expense comparison over time
- **Net trend line**: Smooth 12-month running balance curve using monotone Hermite interpolation (no jagged lines, no Bezier overshoot)
- Month and range selector for all chart types
- Theme-aware rendering (adapts to light and dark modes)
- Touch-friendly interactions
The charts use Cairo for rendering directly onto GTK DrawingAreas. The smoothing algorithm is Fritsch-Carlson monotone cubic Hermite interpolation, which guarantees the curve passes through every data point without overshooting between them.
### 💰 Budgets
Set spending limits per category and track progress through the month.
- Per-category budget amounts with visual progress bars
- Color coding: green (on track), yellow (75%+), red (over budget)
- Monthly overview card showing total budgeted vs total spent
- Sparkline mini-charts showing 4-month spending trend per category
- Budget rollover support (carry unused amounts to next month)
- Daily pace indicator: your actual daily spend vs what you can afford per day
- "Safe to spend" calculation
- Copy budgets from previous month for quick setup
- Three budget cycle modes:
- **Calendar**: Standard month (1st to last day)
- **Payday**: Custom start date (e.g. the 15th)
- **Rolling**: 30-day sliding window
- Budget threshold notifications at 75%, 90%, and 100%
Budgets are a tool for self-awareness, not punishment. The pace indicator in particular is useful - it tells you "at your current rate, here is where you will end up" so you can make informed choices about the rest of the month.
### 🔄 Recurring Transactions
Automate regular income and expenses.
- Define transactions that repeat: daily, weekly, biweekly, monthly, or yearly
- Set start and optional end dates
- Mark items as bills with configurable reminder days
- Pause and resume with optional resume date
- Active and paused sections displayed separately
- Linked to subscriptions for bidirectional sync (pause one, the other pauses too)
- Auto-generation on app launch catches up on any missed occurrences
- Toast notification: "Added N recurring transaction(s)"
When you open Outlay after being away for a week, it quietly generates all the recurring transactions that would have occurred. No manual intervention needed. The system respects end dates, pause states, and frequency rules.
### 🔔 Subscriptions
Track your subscription services separately from raw recurring transactions.
- Active and paused subscription lists
- Monthly cost summary across all active subscriptions
- Yearly cost projection
- Per-subscription details: name, amount, frequency, currency, next due date
- Add, edit, delete, and pause/resume controls
- Bidirectional link with recurring transactions:
- Pausing a subscription pauses its linked recurring transaction
- Deleting either cascades properly (no orphaned records, no constraint errors)
- Status changes propagate instantly between views
The subscription view gives you a clear answer to "how much am I paying per month for services?" - a number that tends to creep up quietly if nobody is watching.
### 🎯 Savings Goals
Set targets and track your progress toward them.
- Create goals with a name, target amount, currency, and optional deadline
- Track current savings against the target with progress bars
- Color and icon customization per goal
- Active and completed goal sections
- Mark goals as complete when reached
- Achievement unlocks tied to goal completion milestones
Goals work as a simple ledger - you set a target, and manually update your saved amount as you make progress. This keeps it flexible enough for any kind of saving, whether it is a vacation fund, an emergency cushion, or pooling resources with others for a shared purpose.
### 💳 Credit Cards
Keep tabs on credit card balances and utilization.
- Track multiple cards with individual limits and balances
- Statement close day and payment due day per card
- Utilization percentage calculation and display
- Color-coded card entries
- Active/inactive toggle
- Total balance and total limit summary cards
This is a straightforward tracking tool - enter your balances manually to maintain awareness of where you stand. No bank API integration, no sharing credentials with third parties.
### 🛒 Wishlist
A place for things you want but have not bought yet.
- Item name, estimated cost, optional category and URL
- Priority levels for ordering
- Purchase status toggle
- Total wishlist value calculation
The wishlist is a cooling-off buffer. Writing something down instead of buying it immediately is one of the simplest ways to reduce impulse spending. If you still want it in a week, it will be here waiting. No algorithmic nudges pushing you to buy.
### 🔮 Forecast
Project your future cash flow based on existing patterns.
- Projections based on recurring transaction schedules
- 3-month average spending calculations
- Income projections from recurring income sources
- Estimated balance for upcoming months (3, 6, and 12 month horizons)
- Monthly net forecast: expected income minus expected expenses
- Visual comparison of projected income vs expenses
The forecast makes no assumptions about growth or lifestyle inflation. It simply extends your current patterns forward so you can see where things are heading and adjust if needed.
### 🏆 Insights and Achievements
Analytics and light gamification to encourage healthy financial habits.
**Streaks:**
- No-spend streak: consecutive days without logging an expense
- Budget streak: consecutive months finishing under budget
- Current vs personal best tracking
**Achievements (16+ badges):**
- First Transaction
- 7-Day and 30-Day No-Spend streaks
- 1, 3, 6, and 12 Months Under Budget
- First Goal Completed, 5 Goals Completed
- 100, 500, and 1000 Transactions logged
- First Recurring Transaction set up
- Big Saver (50%+ savings rate in a month)
- Category Master (10+ categories used)
- Streak Champion (14+ day no-spend streak)
- Year Under Budget
**Monthly Recap:**
- Category-by-category spending breakdown with percentages
- Total income, expenses, and net for the month
- Transaction count
- Month-over-month and year-over-year change indicators
- Year and month navigation
**Anomaly Detection:**
- Automatic flagging of unusual spending patterns
- Category-specific insights ("You spent 200% of your average in Groceries this month")
- Interactive links to filtered history for investigation
The achievements are meant to be encouraging without being manipulative. There are no daily login rewards, no streaks designed to create anxiety, and no leaderboards. Just quiet recognition when you hit a milestone that matters to you.
### 📅 Calendar Heatmap
A visual grid showing daily spending intensity.
- Month calendar layout with day-of-week headers
- Each cell colored by spending amount (darker green = higher spending)
- Click any day to see its transactions
- Daily totals calculated automatically
A quick way to spot patterns - maybe you spend more on weekends, or there is a consistent spike mid-month. The heatmap makes these rhythms visible at a glance.
### ⚙️ Settings
Configure Outlay to work the way you need.
- **Theme**: System, Light, or Dark mode
- **Base currency**: Set your home currency for conversions and reports
- **Categories**: Add, edit, reorder, and manage expense/income categories with icons and colors
- **Notifications**: Toggle recurring transaction alerts, budget warnings, and bill reminders
- **Backup**: Create and restore full-state `.outlay` backup archives
- **Export**: Generate PDF reports, CSV spreadsheets, and JSON data dumps
- **Import**: Bring in data from CSV, JSON, OFX, QIF, and PDF bank statements
---
## 📤 Import and Export
Outlay believes your data should flow freely. No lock-in, no proprietary formats, no friction for leaving.
### Export Formats
| Format | Description |
|--------|-------------|
| **CSV** | Standard comma-separated values. Opens in any spreadsheet. Date, category, amount, currency, note, type. |
| **JSON** | Complete structured export of the entire database. Includes schema version and all tables. Ideal for programmatic access or full migration. |
| **OFX** | Open Financial Exchange. Bank-compatible format for importing into other financial tools. |
| **QIF** | Quicken Interchange Format. Legacy compatibility with older financial software. |
| **PDF** | Formatted monthly financial report with summary, category breakdown, top expenses, and budget comparison. |
### Import Formats
| Format | Description |
|--------|-------------|
| **CSV** | Configurable column mapping. Auto-detects categories by name, creates missing ones as needed. |
| **JSON** | Restore from a previous JSON export. Merge into existing data or replace entirely. Schema validation included. |
| **OFX** | Parse bank statements in OFX format. Multi-currency support. |
| **QIF** | Import from Quicken or other apps that export QIF. |
| **PDF** | Extract transactions from PDF bank statements using text parsing. Multi-page support. |
### Full Backup
The `.outlay` backup format is a ZIP archive containing:
- `outlay.db` - Complete SQLite database
- `settings.json` - All application settings
- `meta.json` - App version, export date, schema version
Create a backup, copy it to another machine, restore it, and you have an exact replica of your entire financial history. Your data is portable because it should be - you should never feel trapped by the tools you use.
---
## 🌍 Multi-Currency Support
Outlay supports 40+ currencies with automatic exchange rate conversion.
<details>
<summary>Supported currencies</summary>
| Code | Currency |
|------|----------|
| USD | US Dollar |
| EUR | Euro |
| GBP | British Pound |
| JPY | Japanese Yen |
| CAD | Canadian Dollar |
| AUD | Australian Dollar |
| CHF | Swiss Franc |
| CNY | Chinese Yuan |
| INR | Indian Rupee |
| BRL | Brazilian Real |
| MXN | Mexican Peso |
| ARS | Argentine Peso |
| CLP | Chilean Peso |
| COP | Colombian Peso |
| PEN | Peruvian Sol |
| UYU | Uruguayan Peso |
| SGD | Singapore Dollar |
| HKD | Hong Kong Dollar |
| KRW | South Korean Won |
| THB | Thai Baht |
| TWD | New Taiwan Dollar |
| PHP | Philippine Peso |
| MYR | Malaysian Ringgit |
| IDR | Indonesian Rupiah |
| VND | Vietnamese Dong |
| PKR | Pakistani Rupee |
| BDT | Bangladeshi Taka |
| SEK | Swedish Krona |
| NOK | Norwegian Krone |
| DKK | Danish Krone |
| PLN | Polish Zloty |
| CZK | Czech Koruna |
| HUF | Hungarian Forint |
| RON | Romanian Leu |
| BGN | Bulgarian Lev |
| HRK | Croatian Kuna |
| RUB | Russian Ruble |
| ZAR | South African Rand |
| EGP | Egyptian Pound |
| NGN | Nigerian Naira |
| KES | Kenyan Shilling |
| TRY | Turkish Lira |
| ILS | Israeli Shekel |
| SAR | Saudi Riyal |
| AED | UAE Dirham |
| QAR | Qatari Riyal |
</details>
**How it works:**
- Set your base currency in Settings
- When logging a transaction in a different currency, the exchange rate is fetched automatically
- Primary rate source: [fawazahmed0/exchange-api](https://github.com/fawazahmed0/exchange-api) (free, open, no API key required, no rate limits)
- Fallback source: [Frankfurter](https://www.frankfurter.app/) (also free and open)
- Rates are cached in the database for 24 hours
- Every transaction stores the exchange rate at the time of entry, preserving historical accuracy
- Reports and charts convert everything to your base currency
The exchange rate sources are free and open services run by volunteers. No corporate API keys, no billing tiers, no rate limiting designed to push you toward a paid plan.
---
## 🔔 Notifications
Outlay uses native GNOME desktop notifications to keep you informed without being intrusive.
- **Budget alerts**: Warnings at 75%, 90%, and 100%+ of your budget threshold per category
- **Bill reminders**: Configurable advance notice (e.g. 3 days before due) for bills marked in recurring transactions
- **Recurring generation**: Confirmation when recurring transactions are auto-generated on launch
- **Weekly digest**: Summary of spending if 7+ days since last digest
- **Backup reminder**: Gentle nudge if 30+ days since last backup and you have 50+ transactions
- **Achievement unlocks**: Toast notification when you earn a new badge
- **Spending anomalies**: Alert on startup if unusual spending patterns are detected
Notifications are de-duplicated (you will not get the same budget warning twice) and can be toggled per type in Settings. They exist to help you stay aware, not to create anxiety or generate "engagement."
---
## 🗔 System Tray
Outlay integrates with the freedesktop.org system tray specification via DBus.
- Minimize to tray instead of quitting
- Tray menu options:
- **Show Outlay** - Restore the main window
- **Quick Add** - Popup for rapid transaction entry without opening the full app
- **Log Expense** - Open directly to expense entry
- **Log Income** - Open directly to income entry
- **Quit** - Actually close the application
The Quick Add popup is designed for those moments when you just bought something and want to log it in five seconds without switching context.
---
## ⌨️ Keyboard and Workflow
The interface follows standard GTK4 and GNOME conventions.
- Amount field auto-focuses on the Log view for immediate typing
- Inline arithmetic in the amount field: type `24.99+tax` or `100/3` and the result is calculated
- Expression evaluator supports `+`, `-`, `*`, `/`, and parentheses
- Standard keyboard navigation throughout (Tab, Enter, Escape)
- Adaptive layout: sidebar collapses to a stack on narrow windows
---
## 🏗 Architecture
Outlay is structured as a Cargo workspace with two crates:
```
outlay/
outlay-core/ # Library crate - all business logic
src/
lib.rs # Module declarations
models.rs # Data structures and enums
db.rs # SQLite operations, migrations, queries
exchange.rs # Currency conversion service
recurring.rs # Recurring transaction generator
notifications.rs # Alert and reminder system
backup.rs # Full-state backup/restore
nlp.rs # Natural language transaction parsing
ocr.rs # Receipt image scanning
expr.rs # Arithmetic expression evaluator
sankey.rs # Sankey diagram layout engine
seed.rs # Demo data generator
export_csv.rs # CSV export
export_json.rs # JSON export
export_ofx.rs # OFX export
export_qif.rs # QIF export
export_pdf.rs # PDF report generation
import_csv.rs # CSV import
import_json.rs # JSON import
import_ofx.rs # OFX import
import_qif.rs # QIF import
import_pdf.rs # PDF statement import
bin/
seed_demo.rs # Demo data seeder binary
outlay-gtk/ # Binary crate - GTK4 user interface
src/
main.rs # Application entry, initialization, notifications
window.rs # Main window scaffold, navigation, view wiring
log_view.rs # Transaction entry form
history_view.rs # Transaction browsing and filtering
charts_view.rs # Financial charts and visualization
budgets_view.rs # Budget management and progress
recurring_view.rs # Recurring transaction management
subscriptions_view.rs # Subscription tracking
goals_view.rs # Savings goal management
credit_cards_view.rs # Credit card tracking
wishlist_view.rs # Wishlist management
forecast_view.rs # Cash flow projections
insights_view.rs # Analytics, achievements, streaks
calendar_view.rs # Spending heatmap
settings_view.rs # Configuration interface
quick_add.rs # Tray popup for rapid entry
edit_dialog.rs # Transaction editing dialog
date_picker.rs # Calendar date selector
category_combo.rs # Filterable category dropdown
numpad.rs # On-screen calculator keypad
month_nav.rs # Month navigation controls
sparkline.rs # Mini trend line widget
icon_theme.rs # Tabler icon integration
tray.rs # System tray integration
style.css # Application stylesheet
data/
fonts/ # Bundled typefaces
icons/ # App icon (all sizes) + action icons
build.rs # Build-time resource compilation
scripts/
build-appimage.sh # AppImage packaging script
docs/
plans/ # Design documentation
```
### Key Technical Decisions
- **Raw GTK4 bindings** instead of a framework like Relm4. More verbose, but no hidden abstractions.
- **SQLite with WAL mode** for reliable concurrent reads during UI updates.
- **Foreign keys enforced** at the database level. Cascade deletes are explicit in application code to maintain control over what gets removed.
- **Schema migrations** built into the database module. The app upgrades the database automatically on launch.
- **Exchange rates cached locally** to minimize network requests. Only fetched when needed, only re-fetched after 24 hours.
- **All chart rendering via Cairo** on GTK DrawingAreas. No web views, no JavaScript, no Electron.
- **Monotone Hermite interpolation** for chart curves. Guarantees smooth lines that pass through every data point without overshooting.
### Dependencies
The core dependencies, all freely licensed:
| Crate | Purpose |
|-------|---------|
| `gtk4` | GUI toolkit |
| `libadwaita` | GNOME UI patterns |
| `rusqlite` | SQLite bindings (bundled) |
| `cairo-rs` | 2D rendering for charts |
| `reqwest` | HTTP client for exchange rates |
| `tokio` | Async runtime |
| `serde` / `serde_json` | Serialization |
| `chrono` | Date and time handling |
| `genpdf` | PDF report generation |
| `pdf-extract` | PDF statement parsing |
| `csv` | CSV reading/writing |
| `zip` | Backup archive creation |
| `ksni` | System tray (freedesktop DBus) |
| `rand` | Demo data generation |
| `thiserror` | Error type definitions |
---
## 💾 Data Storage
All data lives in a single SQLite file:
```
~/.local/share/outlay/outlay.db
```
The database contains 19 tables covering transactions, categories, budgets, recurring items, subscriptions, goals, credit cards, wishlist items, tags, splits, templates, rules, streaks, achievements, attachments, exchange rates, notifications, and settings.
**There is no cloud component.** Your financial history never leaves your machine unless you explicitly export or back it up. This is by design - financial data is deeply personal, and keeping it local is the most straightforward way to ensure it stays under your control.
Schema migrations happen automatically. When a new version adds tables or columns, the app detects the current schema version and applies the necessary changes on launch.
---
## 📦 Packaging
### AppImage
The recommended distribution format. Built on Ubuntu 22.04 for broad glibc compatibility.
The AppImage bundles:
- GTK4 and libadwaita libraries
- GLib schemas and Adwaita theme
- Bundled fonts (JetBrains Mono for the UI, Liberation Sans for PDF reports)
- All icon assets
- Optional: Tesseract OCR engine and language data
Build it yourself:
```bash
./scripts/build-appimage.sh
```
The resulting AppImage is a single file that runs on any modern Linux distribution without installation. Download it, make it executable, run it. When a new version comes out, replace the file. No package managers, no repositories, no gatekeepers.
### Environment Variables
The AppImage sets:
- `GDK_BACKEND=wayland,x11` - Wayland-first with X11 fallback
- Font configuration for bundled typefaces
- GSettings schema paths for desktop integration
---
## 🤝 Contributing
Outlay is public domain software. There are no contributor license agreements, no copyright assignment forms, and no corporate ownership to navigate. If you want to help, help. If you want to fork it and take it in a different direction, do that. The code belongs to everyone equally.
Some ways to contribute:
- **Report bugs**: Open an issue describing what happened and how to reproduce it
- **Suggest features**: Ideas are welcome, especially ones that help people manage money without being patronizing about it
- **Submit patches**: Fork, branch, make your changes, open a pull request
- **Translate**: The UI strings could use localization for non-English speakers
- **Test**: Run it on different distributions, window managers, and screen sizes
- **Share**: Tell someone who might find it useful
There is no formal governance structure. Good ideas get merged. Decisions are made by the people doing the work.
---
## ⚖️ License
<p align="center">
<a href="https://creativecommons.org/publicdomain/zero/1.0/">
<img src="https://img.shields.io/badge/License-CC0_1.0_Universal-brightgreen?style=for-the-badge" alt="CC0">
</a>
</p>
Outlay is released under the **CC0 1.0 Universal** public domain dedication.
To the extent possible under law, the author has waived all copyright and related or neighboring rights to this work. You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
This is not "open source with conditions." This is public domain. It belongs to no one and everyone simultaneously. Use it however you see fit.
See [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) for the full legal text.
---
<p align="center">
<sub>
Built with care for people who want to understand where their money goes.<br>
No tracking. No ads. No subscriptions. No data collection. No corporate interests.<br>
Just software that belongs to the commons.
</sub>
</p>

BIN
data/screenshots/01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
data/screenshots/02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
data/screenshots/03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

BIN
data/screenshots/04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
data/screenshots/05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
data/screenshots/06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
data/screenshots/07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
data/screenshots/08.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
data/screenshots/09.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
data/screenshots/10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
data/screenshots/11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

BIN
data/screenshots/12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
data/screenshots/13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
data/screenshots/14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
data/screenshots/15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
data/screenshots/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1,234 @@
# Feature Batch 2 Design - 16 Features
Date: 2026-03-03
## Overview
16 features sourced from Reddit community research on personal finance app wishlists, filtered against Outlay's existing feature set.
## Features
### 5. Custom Budget Cycle
Users can choose how budget periods are defined:
- **Calendar month** (default, current behavior): 1st to last day of month
- **Payday offset**: budget runs from day X to day X-1 of next month (e.g. 15th to 14th)
- **Rolling N-day**: budget period is a fixed window of N days from a configurable start date
New settings: `budget_cycle_mode` (calendar/payday/rolling), `budget_cycle_start_day` (1-31), `budget_cycle_days` (e.g. 30).
Affects: Budgets view period labels ("Mar 15 - Apr 14"), navigation step size, all budget queries, safe-to-spend calculation, and budget threshold notifications. Charts and History remain calendar-month based to avoid confusion.
Cycle configuration accessible via a gear icon popover next to the month nav in Budgets view.
### 6. Bulk Transaction Operations
Selection mode in History view:
- "Select" button in header bar activates selection mode
- Checkboxes appear on each transaction row
- Long-press also enters selection mode
- Action bar: "Delete (N)", "Recategorize", "Tag", "Select All", "Cancel"
- Delete shows confirmation dialog with count
- Recategorize opens a category picker, applies to all selected
- Tag opens a multi-select tag picker, applies to all selected
### 7. Monthly/Yearly Recap
Part of the new Insights view (see below). Toggle between "This Month" and "This Year".
Monthly recap: total income, expenses, net, transaction count. Per-category rows showing amount, percentage of total, and change vs previous month with directional arrows.
Yearly recap: month-by-month summary with sparklines, year-over-year comparison.
"Share as PDF" button reuses existing PDF export.
### 8. Natural Language Entry
Dedicated smart entry bar at the top of Log view and in Quick Add popup.
`adw::EntryRow` titled "Quick entry" with placeholder "e.g. Coffee 4.50 at Starbucks".
Parser (`outlay-core/src/nlp.rs`):
- `parse_transaction(input, categories) -> Option<ParsedTransaction>`
- Extracts amount (required), category (fuzzy match), note, payee (after "at"/"from"/"to")
- Patterns: "Coffee 4.50", "Lunch 12.50 at Subway", "4.50 groceries milk", "$25 gas"
Preview row shows parsed result below the entry. Press Enter to save. Existing detailed form remains below for manual entry.
### 9. Sankey Diagram
New section in Charts view after existing charts. Title: "Money Flow".
Cairo-drawn Sankey layout:
- Left nodes: income categories
- Right nodes: expense categories
- Center node: "Available" (net)
- Flow widths proportional to amounts
- Income flows colored green, expense flows use category colors
- Hover shows amount labels
Layout computation in `outlay-core/src/sankey.rs`:
- `compute_sankey_layout(income_sources, expense_categories) -> SankeyLayout`
- Returns node positions and bezier curve control points
Month navigation synced with other charts.
### 10. What-If / Sandbox Mode
Toggle button in Budgets view header bar.
When active:
- Header tinted amber, label shows "Sandbox Mode"
- All budget amounts become inline-editable
- Changes stored in temporary `HashMap<i64, f64>`, NOT written to DB
- Progress bars and safe-to-spend recalculate live
- Modified rows get a visual indicator
- "Apply Changes" button writes sandbox values to DB
- "Discard" button exits sandbox, reverts to real data
### 11. Tags UI
Database tables already exist (`tags`, `transaction_tags`). Need GTK surfaces:
- History view: tag chips in FlowBox alongside category chips (different visual style - outlined vs filled). Clicking a tag chip filters to transactions with that tag.
- Log view: tags already have an entry field. Ensure it works with `get_or_create_tag()` and `set_transaction_tags()`.
- Edit dialog: show and edit tags on existing transactions.
- Bulk operations: "Tag" action in selection mode.
### 12. Transaction Splits UI
Database tables already exist (`transaction_splits`). Need GTK surfaces:
- Log view / Edit dialog: "Split" toggle button next to category row
- When enabled: category row replaced by split list
- Each split row: category dropdown + amount entry + optional note
- "Add Split" button for more rows
- Validation: split total must equal transaction amount ("Remaining: X.XX" label)
- On save: `insert_splits()` called after transaction insert
- Edit dialog: if `has_splits()`, pre-fills split rows
### 13. Transaction Templates UI
Database tables already exist (`transaction_templates`). Need GTK surfaces:
- Log view: "Templates" icon button in header opens a popover
- Lists saved templates as `adw::ActionRow` items (name, amount, category icon, type badge)
- Tapping a template fills the log form
- "Save as Template" button at bottom of form when amount + category are filled
- Opens dialog for template name, saves via `insert_template()`
### 14. Auto-Categorization Rules UI
Database tables already exist (`categorization_rules`). Need GTK surface in Settings:
- New "Categorization Rules" `adw::PreferencesGroup` between Categories and Import/Export
- Each rule: `adw::ActionRow` showing field (Note/Payee), pattern, target category
- "Add Rule" button opens dialog: field dropdown, pattern entry, category dropdown, priority spinner
- Delete button per row
- Rules applied during import and optionally during manual entry
### 15. Searchable Category Picker
Replace all `adw::ComboRow` category dropdowns with a searchable version:
- Custom `gtk::FilterListModel` backed by `gtk::StringFilter`
- Search entry at top of dropdown popup
- Typing filters category list in real-time
- Shared utility: `fn make_searchable_category_combo(...) -> adw::ComboRow`
- Used in: log view, edit dialog, budget dialog, quick add, split rows, rules dialog
### 16. PDF Bank Statement Import
New "PDF" import button in Settings alongside existing importers.
Two-phase extraction (`outlay-core/src/import_pdf.rs`):
1. Text extraction via `pdf-extract` crate - look for tabular date/description/amount patterns
2. If no extractable text found, fall back to existing Tesseract OCR pipeline
Preview dialog shows extracted transactions in an editable list. Each row: date, description, amount, category (auto-matched via categorization rules, fallback "Uncategorized"). Import All / Import Selected buttons. Merge/replace mode toggle.
New dependency: `pdf-extract` crate.
### 17. Spending Streaks / Gamification
Dedicated Insights view (new sidebar item) with three sections:
**Streaks:**
- No-spend streak: consecutive days with zero expenses, flame icon, "Best: X days"
- Under-budget streak: consecutive months staying under total budget cap
- Savings streak: consecutive months with goal contributions
**Achievements:**
- Grid of achievement badges, earned ones colored, unearned dimmed
- Starter set: "First Transaction", "7-Day No-Spend", "30-Day No-Spend", "Month Under Budget", "3 Months Under Budget", "First Goal Completed", "100 Transactions", "Budget Streak 6mo"
- Checked and awarded on app launch
New tables: `streaks`, `achievements`.
### 18. Financial Goal Projections
Enhance existing Goals view:
- Each active goal row gets subtitle: "At current rate, reachable by [date]"
- Based on average monthly contributions over last 3 months
- States: "On track - X months ahead", "Behind schedule - need X.XX/month", "Start contributing to see projection"
- Expandable section per goal: mini line chart showing saved amount over time with projected line to target
### 19. Credit Card Billing Cycle Tracking
Dedicated Credit Cards view (new sidebar item).
**Summary card:** total balance, total limit, utilization bar, next due date.
**Card list:** each card is `adw::ExpanderRow`:
- Collapsed: name, balance, due countdown, utilization mini-bar
- Expanded: statement close date, minimum payment, payment history (last 3 months)
- Actions: "Record Payment" (creates expense transaction, reduces balance), Edit, Delete
New table: `credit_cards` (name, credit_limit, statement_close_day, due_day, min_payment_pct, current_balance, currency, color, active).
### 24. Spending Anomaly Alerts in UI
Surface existing `detect_anomalies()` in two places:
- **History view:** `adw::Banner` at top when anomalies exist for the displayed month: "N spending insights for this month". Tapping navigates to Insights view.
- **Insights view:** Anomaly Alerts section renders each anomaly as `adw::ActionRow` with warning/info icons. Clickable to navigate to category in History.
- **Startup:** toast on first launch of the day if current month has insights.
## Schema Migration (v8 -> v9)
New table: `credit_cards` - see feature #19 above.
New table: `streaks` - see feature #17 above.
New table: `achievements` - see feature #17 above.
New settings: `budget_cycle_mode`, `budget_cycle_start_day`, `budget_cycle_days`.
## New Files
- `outlay-core/src/nlp.rs` - natural language transaction parser
- `outlay-core/src/import_pdf.rs` - PDF statement import
- `outlay-core/src/sankey.rs` - Sankey layout computation
- `outlay-gtk/src/insights_view.rs` - Insights view (streaks, achievements, recap, anomalies)
- `outlay-gtk/src/credit_cards_view.rs` - Credit Cards view
## New Dependencies
- `pdf-extract` crate for PDF text extraction
## Modified Files
- `outlay-core/src/db.rs` - migration v9, new CRUD methods for credit cards / streaks / achievements / recap queries / budget period computation
- `outlay-core/src/models.rs` - new structs (CreditCard, Achievement, Streak, ParsedTransaction, SankeyLayout, etc.)
- `outlay-core/src/lib.rs` - register new modules
- `outlay-gtk/src/main.rs` - register new views, startup achievement checks
- `outlay-gtk/src/window.rs` - add Insights and Credit Cards to sidebar navigation
- `outlay-gtk/src/history_view.rs` - bulk operations, tag chips, anomaly banner
- `outlay-gtk/src/log_view.rs` - NL entry bar, templates button, split toggle
- `outlay-gtk/src/edit_dialog.rs` - splits editing, tags editing
- `outlay-gtk/src/budgets_view.rs` - custom cycle, what-if sandbox
- `outlay-gtk/src/charts_view.rs` - Sankey diagram section
- `outlay-gtk/src/goals_view.rs` - projection subtitles and mini charts
- `outlay-gtk/src/settings_view.rs` - categorization rules section, PDF import button
- `outlay-gtk/src/quick_add.rs` - NL entry bar, searchable category
- `outlay-gtk/Cargo.toml` - add pdf-extract dependency

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,10 @@ name = "outlay-core"
version.workspace = true
edition.workspace = true
[[bin]]
name = "seed-demo"
path = "src/bin/seed_demo.rs"
[dependencies]
rusqlite = { version = "0.38", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
@@ -14,3 +18,5 @@ reqwest = { version = "0.13", features = ["json"] }
tokio = { version = "1", features = ["rt", "macros"] }
zip = "2"
thiserror = "2"
pdf-extract = "0.7"
rand = "0.8"

View File

@@ -0,0 +1,251 @@
use crate::db::Database;
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::path::Path;
use zip::write::SimpleFileOptions;
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupMeta {
pub app_version: String,
pub schema_version: i32,
pub export_date: String,
pub transaction_count: i64,
pub category_count: i64,
}
#[derive(Debug)]
pub enum BackupError {
Db(rusqlite::Error),
Io(std::io::Error),
Zip(zip::result::ZipError),
Json(serde_json::Error),
InvalidBackup(String),
}
impl From<rusqlite::Error> for BackupError {
fn from(e: rusqlite::Error) -> Self {
BackupError::Db(e)
}
}
impl From<std::io::Error> for BackupError {
fn from(e: std::io::Error) -> Self {
BackupError::Io(e)
}
}
impl From<zip::result::ZipError> for BackupError {
fn from(e: zip::result::ZipError) -> Self {
BackupError::Zip(e)
}
}
impl From<serde_json::Error> for BackupError {
fn from(e: serde_json::Error) -> Self {
BackupError::Json(e)
}
}
impl std::fmt::Display for BackupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BackupError::Db(e) => write!(f, "Database error: {}", e),
BackupError::Io(e) => write!(f, "IO error: {}", e),
BackupError::Zip(e) => write!(f, "ZIP error: {}", e),
BackupError::Json(e) => write!(f, "JSON error: {}", e),
BackupError::InvalidBackup(msg) => write!(f, "Invalid backup: {}", msg),
}
}
}
pub fn create_backup(db: &Database, output_path: &Path) -> Result<BackupMeta, BackupError> {
let temp_db_path = output_path.with_extension("tmp.db");
// Use VACUUM INTO for a clean, consistent database copy
db.conn.execute(
"VACUUM INTO ?1",
rusqlite::params![temp_db_path.to_str().unwrap()],
)?;
// Read the vacuumed database
let db_bytes = std::fs::read(&temp_db_path)?;
std::fs::remove_file(&temp_db_path)?;
// Gather metadata
let txn_count: i64 = db.conn.query_row(
"SELECT COUNT(*) FROM transactions",
[],
|row| row.get(0),
)?;
let cat_count: i64 = db.conn.query_row(
"SELECT COUNT(*) FROM categories",
[],
|row| row.get(0),
)?;
let schema_version = db.schema_version()?;
let meta = BackupMeta {
app_version: env!("CARGO_PKG_VERSION").to_string(),
schema_version,
export_date: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
transaction_count: txn_count,
category_count: cat_count,
};
let meta_json = serde_json::to_string_pretty(&meta)?;
// Create ZIP archive
let file = std::fs::File::create(output_path)?;
let mut zip = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("outlay.db", options)?;
zip.write_all(&db_bytes)?;
zip.start_file("meta.json", options)?;
zip.write_all(meta_json.as_bytes())?;
zip.finish()?;
Ok(meta)
}
pub fn read_backup_meta(backup_path: &Path) -> Result<BackupMeta, BackupError> {
let file = std::fs::File::open(backup_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut meta_file = archive.by_name("meta.json").map_err(|_| {
BackupError::InvalidBackup("meta.json not found in backup".to_string())
})?;
let mut contents = String::new();
meta_file.read_to_string(&mut contents)?;
let meta: BackupMeta = serde_json::from_str(&contents)?;
Ok(meta)
}
pub fn restore_backup(backup_path: &Path, db_path: &Path) -> Result<BackupMeta, BackupError> {
let file = std::fs::File::open(backup_path)?;
let mut archive = zip::ZipArchive::new(file)?;
// Validate - must contain meta.json and outlay.db
let meta = {
let mut meta_file = archive.by_name("meta.json").map_err(|_| {
BackupError::InvalidBackup("meta.json not found in backup".to_string())
})?;
let mut contents = String::new();
meta_file.read_to_string(&mut contents)?;
let meta: BackupMeta = serde_json::from_str(&contents)?;
meta
};
// Extract database
let mut db_file = archive.by_name("outlay.db").map_err(|_| {
BackupError::InvalidBackup("outlay.db not found in backup".to_string())
})?;
let mut db_bytes = Vec::new();
db_file.read_to_end(&mut db_bytes)?;
// Write the restored database
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(db_path, &db_bytes)?;
Ok(meta)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{NewTransaction, TransactionType};
use chrono::NaiveDate;
fn setup_db_with_data(name: &str) -> (Database, std::path::PathBuf, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("outlay_backup_{}", name));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db_path = dir.join("test.db");
let db = Database::open(&db_path).unwrap();
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let txn = NewTransaction {
amount: 42.0,
transaction_type: TransactionType::Expense,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Test transaction".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn).unwrap();
(db, db_path, dir)
}
#[test]
fn test_backup_creates_valid_zip() {
let (db, _db_path, dir) = setup_db_with_data("zip");
let backup_path = dir.join("test_backup.outlay");
let meta = create_backup(&db, &backup_path).unwrap();
assert!(backup_path.exists());
assert_eq!(meta.transaction_count, 1);
assert!(meta.category_count > 0);
assert_eq!(meta.app_version, env!("CARGO_PKG_VERSION"));
// Verify ZIP contents
let file = std::fs::File::open(&backup_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
assert!(archive.by_name("outlay.db").is_ok());
assert!(archive.by_name("meta.json").is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_backup_meta_contains_version() {
let (db, _db_path, dir) = setup_db_with_data("meta");
let backup_path = dir.join("test_meta.outlay");
create_backup(&db, &backup_path).unwrap();
let meta = read_backup_meta(&backup_path).unwrap();
assert_eq!(meta.app_version, env!("CARGO_PKG_VERSION"));
assert!(meta.schema_version > 0);
assert!(!meta.export_date.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_restore_from_backup() {
let (db, _db_path, dir) = setup_db_with_data("restore");
let backup_path = dir.join("test_restore.outlay");
create_backup(&db, &backup_path).unwrap();
// Restore to a new location
let restore_path = dir.join("restored.db");
let meta = restore_backup(&backup_path, &restore_path).unwrap();
assert_eq!(meta.transaction_count, 1);
// Open restored DB and verify data
let restored_db = Database::open(&restore_path).unwrap();
let txns = restored_db.list_all_transactions(None, None).unwrap();
assert_eq!(txns.len(), 1);
assert_eq!(txns[0].amount, 42.0);
assert_eq!(txns[0].note.as_deref(), Some("Test transaction"));
drop(restored_db);
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@@ -0,0 +1,33 @@
use std::path::PathBuf;
fn main() {
let data_dir: PathBuf = dirs_next().join("outlay");
let db_path = data_dir.join("outlay.db");
println!("Database path: {}", db_path.display());
if db_path.exists() {
println!("Removing existing database for a clean seed...");
std::fs::remove_file(&db_path).expect("Failed to remove existing database");
}
let db = outlay_core::db::Database::open(&db_path)
.expect("Failed to open database");
println!("Seeding demo data (2 years of realistic usage)...");
outlay_core::seed::seed_demo_data(&db)
.expect("Failed to seed demo data");
println!("Done! Restart Outlay to see the demo data.");
}
fn dirs_next() -> PathBuf {
if let Ok(dir) = std::env::var("XDG_DATA_HOME") {
PathBuf::from(dir)
} else if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(".local").join("share")
} else {
PathBuf::from(".")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
use crate::db::Database;
use chrono::NaiveDate;
use csv::Writer;
use std::io::Write;
#[derive(Debug)]
pub enum ExportError {
Db(rusqlite::Error),
Csv(csv::Error),
}
impl From<rusqlite::Error> for ExportError {
fn from(e: rusqlite::Error) -> Self {
ExportError::Db(e)
}
}
impl From<csv::Error> for ExportError {
fn from(e: csv::Error) -> Self {
ExportError::Csv(e)
}
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExportError::Db(e) => write!(f, "Database error: {}", e),
ExportError::Csv(e) => write!(f, "CSV error: {}", e),
}
}
}
pub fn export_transactions_csv<W: Write>(
db: &Database,
writer: W,
from: Option<NaiveDate>,
to: Option<NaiveDate>,
) -> Result<usize, ExportError> {
let transactions = db.list_all_transactions(from, to)?;
let mut wtr = Writer::from_writer(writer);
wtr.write_record(["Date", "Type", "Category", "Amount", "Currency", "Exchange Rate", "Note", "Payee"])?;
for txn in &transactions {
let cat_name = db
.get_category(txn.category_id)
.map(|c| c.name)
.unwrap_or_else(|_| "Unknown".to_string());
wtr.write_record(&[
txn.date.format("%Y-%m-%d").to_string(),
txn.transaction_type.as_str().to_string(),
cat_name,
format!("{:.2}", txn.amount),
txn.currency.clone(),
format!("{:.4}", txn.exchange_rate),
txn.note.clone().unwrap_or_default(),
txn.payee.clone().unwrap_or_default(),
])?;
}
wtr.flush().map_err(|e| ExportError::Csv(e.into()))?;
Ok(transactions.len())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{NewTransaction, TransactionType};
fn setup_db() -> Database {
Database::open_in_memory().unwrap()
}
#[test]
fn test_csv_export_format() {
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let cat = &cats[0];
let txn = NewTransaction {
amount: 42.50,
transaction_type: TransactionType::Expense,
category_id: cat.id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Lunch".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn).unwrap();
let mut buf = Vec::new();
let count = export_transactions_csv(&db, &mut buf, None, None).unwrap();
assert_eq!(count, 1);
let output = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = output.trim().lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "Date,Type,Category,Amount,Currency,Exchange Rate,Note,Payee");
assert!(lines[1].contains("2026-03-01"));
assert!(lines[1].contains("expense"));
assert!(lines[1].contains("42.50"));
assert!(lines[1].contains("Lunch"));
}
#[test]
fn test_csv_export_empty() {
let db = setup_db();
let mut buf = Vec::new();
let count = export_transactions_csv(&db, &mut buf, None, None).unwrap();
assert_eq!(count, 0);
let output = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = output.trim().lines().collect();
assert_eq!(lines.len(), 1); // header only
}
#[test]
fn test_csv_export_filtered_by_date() {
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let cat_id = cats[0].id;
for day in 1..=5 {
let txn = NewTransaction {
amount: 10.0 * day as f64,
transaction_type: TransactionType::Expense,
category_id: cat_id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: None,
date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn).unwrap();
}
// Filter from Jan 2 to Jan 4
let mut buf = Vec::new();
let count = export_transactions_csv(
&db,
&mut buf,
Some(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()),
Some(NaiveDate::from_ymd_opt(2026, 1, 4).unwrap()),
)
.unwrap();
assert_eq!(count, 3);
}
#[test]
fn test_csv_export_multiple_types() {
let db = setup_db();
let expense_cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let income_cats = db.list_categories(Some(TransactionType::Income)).unwrap();
let txn1 = NewTransaction {
amount: 50.0,
transaction_type: TransactionType::Expense,
category_id: expense_cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: None,
date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
recurring_id: None,
payee: None,
};
let txn2 = NewTransaction {
amount: 1000.0,
transaction_type: TransactionType::Income,
category_id: income_cats[0].id,
currency: "EUR".to_string(),
exchange_rate: 0.92,
note: Some("Salary".to_string()),
date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn1).unwrap();
db.insert_transaction(&txn2).unwrap();
let mut buf = Vec::new();
let count = export_transactions_csv(&db, &mut buf, None, None).unwrap();
assert_eq!(count, 2);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("expense"));
assert!(output.contains("income"));
assert!(output.contains("EUR"));
assert!(output.contains("0.9200"));
}
}

View File

@@ -0,0 +1,152 @@
use crate::db::Database;
use crate::models::{Budget, Category, RecurringTransaction, Transaction};
use serde::Serialize;
use std::io::Write;
#[derive(Debug, Serialize, serde::Deserialize)]
pub struct ExportData {
pub transactions: Vec<Transaction>,
pub categories: Vec<Category>,
pub budgets: Vec<Budget>,
pub recurring: Vec<RecurringTransaction>,
}
#[derive(Debug)]
pub enum ExportError {
Db(rusqlite::Error),
Json(serde_json::Error),
Io(std::io::Error),
}
impl From<rusqlite::Error> for ExportError {
fn from(e: rusqlite::Error) -> Self {
ExportError::Db(e)
}
}
impl From<serde_json::Error> for ExportError {
fn from(e: serde_json::Error) -> Self {
ExportError::Json(e)
}
}
impl From<std::io::Error> for ExportError {
fn from(e: std::io::Error) -> Self {
ExportError::Io(e)
}
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExportError::Db(e) => write!(f, "Database error: {}", e),
ExportError::Json(e) => write!(f, "JSON error: {}", e),
ExportError::Io(e) => write!(f, "IO error: {}", e),
}
}
}
pub fn export_json<W: Write>(db: &Database, writer: W) -> Result<ExportData, ExportError> {
let transactions = db.list_all_transactions(None, None)?;
let categories = db.list_categories(None)?;
let budgets = db.list_all_budgets()?;
let recurring = db.list_recurring(false)?;
let data = ExportData {
transactions,
categories,
budgets,
recurring,
};
serde_json::to_writer_pretty(writer, &data)?;
Ok(data)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{NewTransaction, TransactionType};
use chrono::NaiveDate;
fn setup_db() -> Database {
Database::open_in_memory().unwrap()
}
#[test]
fn test_json_export_produces_valid_json() {
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let txn = NewTransaction {
amount: 25.0,
transaction_type: TransactionType::Expense,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Test".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn).unwrap();
let mut buf = Vec::new();
let data = export_json(&db, &mut buf).unwrap();
assert_eq!(data.transactions.len(), 1);
assert!(!data.categories.is_empty());
assert!(data.budgets.is_empty());
assert!(data.recurring.is_empty());
// Verify it's valid JSON by parsing it back
let output = String::from_utf8(buf).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(parsed["transactions"].is_array());
assert!(parsed["categories"].is_array());
}
#[test]
fn test_json_export_includes_all_sections() {
let db = setup_db();
let mut buf = Vec::new();
let data = export_json(&db, &mut buf).unwrap();
// Even with no transactions, we should have default categories
assert!(!data.categories.is_empty());
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("\"transactions\""));
assert!(output.contains("\"categories\""));
assert!(output.contains("\"budgets\""));
assert!(output.contains("\"recurring\""));
}
#[test]
fn test_json_export_transaction_fields() {
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Income)).unwrap();
let txn = NewTransaction {
amount: 500.0,
transaction_type: TransactionType::Income,
category_id: cats[0].id,
currency: "EUR".to_string(),
exchange_rate: 0.92,
note: Some("Freelance".to_string()),
date: NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn).unwrap();
let mut buf = Vec::new();
export_json(&db, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("500.0"));
assert!(output.contains("EUR"));
assert!(output.contains("Freelance"));
}
}

View File

@@ -0,0 +1,351 @@
use crate::db::Database;
use crate::models::TransactionType;
use chrono::{Local, NaiveDate};
use std::io::Write;
#[derive(Debug)]
pub enum ExportError {
Db(rusqlite::Error),
Io(std::io::Error),
}
impl From<rusqlite::Error> for ExportError {
fn from(e: rusqlite::Error) -> Self {
ExportError::Db(e)
}
}
impl From<std::io::Error> for ExportError {
fn from(e: std::io::Error) -> Self {
ExportError::Io(e)
}
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExportError::Db(e) => write!(f, "Database error: {}", e),
ExportError::Io(e) => write!(f, "IO error: {}", e),
}
}
}
/// Escape special characters for OFX SGML content.
fn ofx_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Export transactions to OFX 1.6 SGML format.
///
/// This produces a bank statement download file compatible with
/// Quicken, GnuCash, and other personal finance applications.
pub fn export_ofx<W: Write>(
db: &Database,
writer: &mut W,
from: Option<NaiveDate>,
to: Option<NaiveDate>,
) -> Result<usize, ExportError> {
let transactions = db.list_all_transactions(from, to)?;
let now = Local::now();
let dtserver = now.format("%Y%m%d%H%M%S").to_string();
// Determine date range for the statement
let start_date = transactions
.first()
.map(|t| t.date)
.unwrap_or_else(|| now.date_naive());
let end_date = transactions
.last()
.map(|t| t.date)
.unwrap_or_else(|| now.date_naive());
let base_currency = db
.get_setting("base_currency")
.ok()
.flatten()
.unwrap_or_else(|| "USD".to_string());
// OFX SGML headers
writeln!(writer, "OFXHEADER:100")?;
writeln!(writer, "DATA:OFXSGML")?;
writeln!(writer, "VERSION:160")?;
writeln!(writer, "SECURITY:NONE")?;
writeln!(writer, "ENCODING:USASCII")?;
writeln!(writer, "CHARSET:1252")?;
writeln!(writer, "COMPRESSION:NONE")?;
writeln!(writer, "OLDFILEUID:NONE")?;
writeln!(writer, "NEWFILEUID:NONE")?;
writeln!(writer)?;
// OFX body
writeln!(writer, "<OFX>")?;
writeln!(writer, "<SIGNONMSGSRSV1>")?;
writeln!(writer, "<SONRS>")?;
writeln!(writer, "<STATUS>")?;
writeln!(writer, "<CODE>0")?;
writeln!(writer, "<SEVERITY>INFO")?;
writeln!(writer, "</STATUS>")?;
writeln!(writer, "<DTSERVER>{}", dtserver)?;
writeln!(writer, "<LANGUAGE>ENG")?;
writeln!(writer, "</SONRS>")?;
writeln!(writer, "</SIGNONMSGSRSV1>")?;
writeln!(writer, "<BANKMSGSRSV1>")?;
writeln!(writer, "<STMTTRNRS>")?;
writeln!(writer, "<TRNUID>0")?;
writeln!(writer, "<STATUS>")?;
writeln!(writer, "<CODE>0")?;
writeln!(writer, "<SEVERITY>INFO")?;
writeln!(writer, "</STATUS>")?;
writeln!(writer, "<STMTRS>")?;
writeln!(writer, "<CURDEF>{}", base_currency)?;
writeln!(writer, "<BANKACCTFROM>")?;
writeln!(writer, "<BANKID>0")?;
writeln!(writer, "<ACCTID>OUTLAY")?;
writeln!(writer, "<ACCTTYPE>CHECKING")?;
writeln!(writer, "</BANKACCTFROM>")?;
writeln!(writer, "<BANKTRANLIST>")?;
writeln!(
writer,
"<DTSTART>{}",
start_date.format("%Y%m%d")
)?;
writeln!(
writer,
"<DTEND>{}",
end_date.format("%Y%m%d")
)?;
for txn in &transactions {
let trntype = match txn.transaction_type {
TransactionType::Expense => "DEBIT",
TransactionType::Income => "CREDIT",
};
let amount = match txn.transaction_type {
TransactionType::Expense => -txn.amount,
TransactionType::Income => txn.amount,
};
let cat_name = db
.get_category(txn.category_id)
.map(|c| c.name)
.unwrap_or_else(|_| "Unknown".to_string());
let name = if let Some(ref payee) = txn.payee {
if !payee.is_empty() {
ofx_escape(payee)
} else {
ofx_escape(&cat_name)
}
} else {
ofx_escape(&cat_name)
};
writeln!(writer, "<STMTTRN>")?;
writeln!(writer, "<TRNTYPE>{}", trntype)?;
writeln!(
writer,
"<DTPOSTED>{}",
txn.date.format("%Y%m%d")
)?;
writeln!(writer, "<TRNAMT>{:.2}", amount)?;
writeln!(writer, "<FITID>{}", txn.id)?;
writeln!(writer, "<NAME>{}", name)?;
if let Some(ref note) = txn.note {
if !note.is_empty() {
writeln!(writer, "<MEMO>{}", ofx_escape(note))?;
}
}
writeln!(writer, "</STMTTRN>")?;
}
writeln!(writer, "</BANKTRANLIST>")?;
// Ledger balance (sum of all exported transactions)
let balance: f64 = transactions.iter().map(|t| match t.transaction_type {
TransactionType::Expense => -t.amount,
TransactionType::Income => t.amount,
}).sum();
writeln!(writer, "<LEDGERBAL>")?;
writeln!(writer, "<BALAMT>{:.2}", balance)?;
writeln!(
writer,
"<DTASOF>{}",
end_date.format("%Y%m%d")
)?;
writeln!(writer, "</LEDGERBAL>")?;
writeln!(writer, "</STMTRS>")?;
writeln!(writer, "</STMTTRNRS>")?;
writeln!(writer, "</BANKMSGSRSV1>")?;
writeln!(writer, "</OFX>")?;
Ok(transactions.len())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::NewTransaction;
fn setup_db() -> Database {
Database::open_in_memory().unwrap()
}
#[test]
fn test_ofx_header() {
let db = setup_db();
let mut buf = Vec::new();
export_ofx(&db, &mut buf, None, None).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.starts_with("OFXHEADER:100"));
assert!(output.contains("VERSION:160"));
assert!(output.contains("<OFX>"));
assert!(output.contains("</OFX>"));
}
#[test]
fn test_ofx_expense_debit() {
let db = setup_db();
let cats = db
.list_categories(Some(TransactionType::Expense))
.unwrap();
let txn = NewTransaction {
amount: 25.99,
transaction_type: TransactionType::Expense,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Books".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None,
payee: Some("Amazon".to_string()),
};
db.insert_transaction(&txn).unwrap();
let mut buf = Vec::new();
let count = export_ofx(&db, &mut buf, None, None).unwrap();
assert_eq!(count, 1);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("<TRNTYPE>DEBIT"));
assert!(output.contains("<TRNAMT>-25.99"));
assert!(output.contains("<NAME>Amazon"));
assert!(output.contains("<MEMO>Books"));
assert!(output.contains("<DTPOSTED>20260301"));
}
#[test]
fn test_ofx_income_credit() {
let db = setup_db();
let cats = db
.list_categories(Some(TransactionType::Income))
.unwrap();
let txn = NewTransaction {
amount: 5000.0,
transaction_type: TransactionType::Income,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: None,
date: NaiveDate::from_ymd_opt(2026, 2, 28).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn).unwrap();
let mut buf = Vec::new();
export_ofx(&db, &mut buf, None, None).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("<TRNTYPE>CREDIT"));
assert!(output.contains("<TRNAMT>5000.00"));
}
#[test]
fn test_ofx_escapes_special_chars() {
let db = setup_db();
let cats = db
.list_categories(Some(TransactionType::Expense))
.unwrap();
let txn = NewTransaction {
amount: 10.0,
transaction_type: TransactionType::Expense,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Tom & Jerry's <shop>".to_string()),
date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
recurring_id: None,
payee: Some("A&B Store".to_string()),
};
db.insert_transaction(&txn).unwrap();
let mut buf = Vec::new();
export_ofx(&db, &mut buf, None, None).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("<NAME>A&amp;B Store"));
assert!(output.contains("Tom &amp; Jerry's &lt;shop&gt;"));
}
#[test]
fn test_ofx_empty_export() {
let db = setup_db();
let mut buf = Vec::new();
let count = export_ofx(&db, &mut buf, None, None).unwrap();
assert_eq!(count, 0);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("<BANKTRANLIST>"));
assert!(output.contains("</BANKTRANLIST>"));
}
#[test]
fn test_ofx_ledger_balance() {
let db = setup_db();
let expense_cats = db
.list_categories(Some(TransactionType::Expense))
.unwrap();
let income_cats = db
.list_categories(Some(TransactionType::Income))
.unwrap();
let txn1 = NewTransaction {
amount: 100.0,
transaction_type: TransactionType::Income,
category_id: income_cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: None,
date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
recurring_id: None,
payee: None,
};
let txn2 = NewTransaction {
amount: 30.0,
transaction_type: TransactionType::Expense,
category_id: expense_cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: None,
date: NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn1).unwrap();
db.insert_transaction(&txn2).unwrap();
let mut buf = Vec::new();
export_ofx(&db, &mut buf, None, None).unwrap();
let output = String::from_utf8(buf).unwrap();
// Balance should be 100 - 30 = 70
assert!(output.contains("<BALAMT>70.00"));
}
}

View File

@@ -0,0 +1,373 @@
use crate::db::Database;
use crate::models::TransactionType;
use genpdf::elements;
use genpdf::style;
use genpdf::Element as _;
use std::path::Path;
const FONT_DIRS: &[&str] = &[
"/usr/share/fonts/truetype/liberation",
"/usr/share/fonts/liberation-sans",
"/usr/share/fonts/TTF",
];
#[derive(Debug)]
pub enum PdfError {
Db(rusqlite::Error),
Pdf(genpdf::error::Error),
FontNotFound,
}
impl From<rusqlite::Error> for PdfError {
fn from(e: rusqlite::Error) -> Self {
PdfError::Db(e)
}
}
impl From<genpdf::error::Error> for PdfError {
fn from(e: genpdf::error::Error) -> Self {
PdfError::Pdf(e)
}
}
impl std::fmt::Display for PdfError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PdfError::Db(e) => write!(f, "Database error: {}", e),
PdfError::Pdf(e) => write!(f, "PDF error: {}", e),
PdfError::FontNotFound => write!(f, "Liberation Sans font not found"),
}
}
}
fn find_font_dir() -> Option<&'static str> {
for dir in FONT_DIRS {
let path = Path::new(dir).join("LiberationSans-Regular.ttf");
if path.exists() {
return Some(dir);
}
}
None
}
fn month_name(month: u32) -> &'static str {
match month {
1 => "January", 2 => "February", 3 => "March",
4 => "April", 5 => "May", 6 => "June",
7 => "July", 8 => "August", 9 => "September",
10 => "October", 11 => "November", 12 => "December",
_ => "Unknown",
}
}
pub fn generate_monthly_report(
db: &Database,
year: i32,
month: u32,
base_currency: &str,
output_path: &Path,
) -> Result<(), PdfError> {
let font_dir = find_font_dir().ok_or(PdfError::FontNotFound)?;
let font_family = genpdf::fonts::from_files(font_dir, "LiberationSans", None)?;
let mut doc = genpdf::Document::new(font_family);
doc.set_title("Outlay - Monthly Report");
let mut decorator = genpdf::SimplePageDecorator::new();
decorator.set_margins(15);
doc.set_page_decorator(decorator);
// Title
let title = format!(
"Outlay - {} {} Report",
month_name(month),
year
);
doc.push(elements::Paragraph::new(&title).styled(style::Style::new().bold().with_font_size(18)));
doc.push(elements::Break::new(1.5));
// Fetch data
let month_str = format!("{:04}-{:02}", year, month);
let transactions = db.list_transactions_by_month(year, month)?;
let mut total_income = 0.0_f64;
let mut total_expenses = 0.0_f64;
// Category breakdown
let mut category_totals: std::collections::HashMap<i64, (String, f64)> =
std::collections::HashMap::new();
for txn in &transactions {
let converted = txn.amount * txn.exchange_rate;
match txn.transaction_type {
TransactionType::Income => total_income += converted,
TransactionType::Expense => total_expenses += converted,
}
if txn.transaction_type == TransactionType::Expense {
let cat_name = db
.get_category(txn.category_id)
.map(|c| c.name)
.unwrap_or_else(|_| "Unknown".to_string());
let entry = category_totals
.entry(txn.category_id)
.or_insert_with(|| (cat_name, 0.0));
entry.1 += converted;
}
}
let net = total_income - total_expenses;
// Summary section
doc.push(elements::Paragraph::new("Summary").styled(style::Style::new().bold().with_font_size(14)));
doc.push(elements::Break::new(0.5));
doc.push(elements::Paragraph::new(format!(
"Total Income: {:.2} {}",
total_income, base_currency
)));
doc.push(elements::Paragraph::new(format!(
"Total Expenses: {:.2} {}",
total_expenses, base_currency
)));
doc.push(elements::Paragraph::new(format!(
"Net: {:.2} {}",
net, base_currency
)));
doc.push(elements::Break::new(1.0));
// Category breakdown table
if !category_totals.is_empty() {
doc.push(elements::Paragraph::new("Expense Breakdown by Category").styled(
style::Style::new().bold().with_font_size(14),
));
doc.push(elements::Break::new(0.5));
let mut table = elements::TableLayout::new(vec![3, 2, 1]);
table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false));
// Header
table
.row()
.element(elements::Paragraph::new("Category").styled(style::Style::new().bold()))
.element(elements::Paragraph::new("Amount").styled(style::Style::new().bold()))
.element(elements::Paragraph::new("% of Total").styled(style::Style::new().bold()))
.push()
.map_err(genpdf::error::Error::from)?;
let mut sorted: Vec<(i64, (String, f64))> = category_totals.into_iter().collect();
sorted.sort_by(|a, b| b.1 .1.partial_cmp(&a.1 .1).unwrap());
for (_, (name, amount)) in &sorted {
let pct = if total_expenses > 0.0 {
amount / total_expenses * 100.0
} else {
0.0
};
table
.row()
.element(elements::Paragraph::new(name))
.element(elements::Paragraph::new(format!("{:.2}", amount)))
.element(elements::Paragraph::new(format!("{:.1}%", pct)))
.push()
.map_err(genpdf::error::Error::from)?;
}
doc.push(table);
doc.push(elements::Break::new(1.0));
}
// Top 5 expenses
let mut expense_txns: Vec<_> = transactions
.iter()
.filter(|t| t.transaction_type == TransactionType::Expense)
.collect();
expense_txns.sort_by(|a, b| b.amount.partial_cmp(&a.amount).unwrap());
if !expense_txns.is_empty() {
doc.push(elements::Paragraph::new("Top Expenses").styled(
style::Style::new().bold().with_font_size(14),
));
doc.push(elements::Break::new(0.5));
let mut table = elements::TableLayout::new(vec![2, 2, 2, 3]);
table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false));
table
.row()
.element(elements::Paragraph::new("Date").styled(style::Style::new().bold()))
.element(elements::Paragraph::new("Category").styled(style::Style::new().bold()))
.element(elements::Paragraph::new("Amount").styled(style::Style::new().bold()))
.element(elements::Paragraph::new("Note").styled(style::Style::new().bold()))
.push()
.map_err(genpdf::error::Error::from)?;
for txn in expense_txns.iter().take(5) {
let cat_name = db
.get_category(txn.category_id)
.map(|c| c.name)
.unwrap_or_else(|_| "Unknown".to_string());
table
.row()
.element(elements::Paragraph::new(txn.date.format("%Y-%m-%d").to_string()))
.element(elements::Paragraph::new(&cat_name))
.element(elements::Paragraph::new(format!(
"{:.2} {}",
txn.amount, txn.currency
)))
.element(elements::Paragraph::new(
txn.note.as_deref().unwrap_or("-"),
))
.push()
.map_err(genpdf::error::Error::from)?;
}
doc.push(table);
doc.push(elements::Break::new(1.0));
}
// Budget vs actual
let budgets = db.list_budgets_for_month(&month_str)?;
if !budgets.is_empty() {
doc.push(elements::Paragraph::new("Budget vs Actual").styled(
style::Style::new().bold().with_font_size(14),
));
doc.push(elements::Break::new(0.5));
let mut table = elements::TableLayout::new(vec![3, 2, 2, 1]);
table.set_cell_decorator(elements::FrameCellDecorator::new(true, true, false));
table
.row()
.element(elements::Paragraph::new("Category").styled(style::Style::new().bold()))
.element(elements::Paragraph::new("Budget").styled(style::Style::new().bold()))
.element(elements::Paragraph::new("Spent").styled(style::Style::new().bold()))
.element(elements::Paragraph::new("Used").styled(style::Style::new().bold()))
.push()
.map_err(genpdf::error::Error::from)?;
for budget in &budgets {
let cat_name = db
.get_category(budget.category_id)
.map(|c| c.name)
.unwrap_or_else(|_| "Unknown".to_string());
let progress = db.get_budget_progress(budget.category_id, &month_str)?;
let (budget_amt, spent, pct) = progress.unwrap_or((budget.amount, 0.0, 0.0));
table
.row()
.element(elements::Paragraph::new(&cat_name))
.element(elements::Paragraph::new(format!("{:.2}", budget_amt)))
.element(elements::Paragraph::new(format!("{:.2}", spent)))
.element(elements::Paragraph::new(format!("{:.0}%", pct)))
.push()
.map_err(genpdf::error::Error::from)?;
}
doc.push(table);
}
// Footer
doc.push(elements::Break::new(1.5));
doc.push(
elements::Paragraph::new(format!("Generated by Outlay on {}", chrono::Local::now().format("%Y-%m-%d %H:%M")))
.styled(style::Style::new().with_font_size(8)),
);
doc.render_to_file(output_path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{NewTransaction, TransactionType};
use chrono::NaiveDate;
fn setup_db() -> Database {
Database::open_in_memory().unwrap()
}
#[test]
fn test_pdf_report_generates_file() {
if find_font_dir().is_none() {
eprintln!("Skipping PDF test - Liberation Sans fonts not found");
return;
}
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let income_cats = db.list_categories(Some(TransactionType::Income)).unwrap();
// Add sample transactions
let txns = vec![
NewTransaction {
amount: 45.0,
transaction_type: TransactionType::Expense,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Groceries".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None,
payee: None,
},
NewTransaction {
amount: 12.50,
transaction_type: TransactionType::Expense,
category_id: cats[1].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Coffee".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 5).unwrap(),
recurring_id: None,
payee: None,
},
NewTransaction {
amount: 3000.0,
transaction_type: TransactionType::Income,
category_id: income_cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Salary".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None,
payee: None,
},
];
for txn in &txns {
db.insert_transaction(txn).unwrap();
}
// Set a budget
db.set_budget(cats[0].id, "2026-03", 200.0, false).unwrap();
let tmp = std::env::temp_dir().join("outlay_test_report.pdf");
generate_monthly_report(&db, 2026, 3, "USD", &tmp).unwrap();
assert!(tmp.exists());
let metadata = std::fs::metadata(&tmp).unwrap();
assert!(metadata.len() > 100); // PDF should be non-trivial size
// Cleanup
std::fs::remove_file(&tmp).ok();
}
#[test]
fn test_pdf_report_empty_month() {
if find_font_dir().is_none() {
eprintln!("Skipping PDF test - Liberation Sans fonts not found");
return;
}
let db = setup_db();
let tmp = std::env::temp_dir().join("outlay_test_empty_report.pdf");
generate_monthly_report(&db, 2026, 1, "USD", &tmp).unwrap();
assert!(tmp.exists());
std::fs::remove_file(&tmp).ok();
}
}

View File

@@ -0,0 +1,264 @@
use crate::db::Database;
use crate::models::TransactionType;
use chrono::NaiveDate;
use std::io::Write;
#[derive(Debug)]
pub enum ExportError {
Db(rusqlite::Error),
Io(std::io::Error),
}
impl From<rusqlite::Error> for ExportError {
fn from(e: rusqlite::Error) -> Self {
ExportError::Db(e)
}
}
impl From<std::io::Error> for ExportError {
fn from(e: std::io::Error) -> Self {
ExportError::Io(e)
}
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExportError::Db(e) => write!(f, "Database error: {}", e),
ExportError::Io(e) => write!(f, "IO error: {}", e),
}
}
}
/// Build a QIF-style category path like "Food:Groceries" for subcategories.
fn category_path(db: &Database, category_id: i64) -> String {
let cat = match db.get_category(category_id) {
Ok(c) => c,
Err(_) => return "Unknown".to_string(),
};
if let Some(parent_id) = cat.parent_id {
if let Ok(parent) = db.get_category(parent_id) {
return format!("{}:{}", parent.name, cat.name);
}
}
cat.name
}
/// Export transactions to QIF (Quicken Interchange Format).
///
/// Produces `!Type:Bank` records with support for splits.
/// Dates use MM/DD/YYYY as per the QIF specification.
pub fn export_qif<W: Write>(
db: &Database,
writer: &mut W,
from: Option<NaiveDate>,
to: Option<NaiveDate>,
) -> Result<usize, ExportError> {
let transactions = db.list_all_transactions(from, to)?;
writeln!(writer, "!Type:Bank")?;
for txn in &transactions {
// D - date in MM/DD/YYYY
let date_str = txn.date.format("%m/%d/%Y").to_string();
writeln!(writer, "D{}", date_str)?;
// T - amount (negative for expenses)
let amount = match txn.transaction_type {
TransactionType::Expense => -txn.amount,
TransactionType::Income => txn.amount,
};
writeln!(writer, "T{:.2}", amount)?;
// P - payee
if let Some(ref payee) = txn.payee {
if !payee.is_empty() {
writeln!(writer, "P{}", payee)?;
}
}
// Check for splits
let splits = db.get_splits(txn.id).unwrap_or_default();
if splits.is_empty() {
// L - category
let cat_path = category_path(db, txn.category_id);
writeln!(writer, "L{}", cat_path)?;
} else {
// Split lines: S for category, $ for amount, E for memo
for split in &splits {
let split_cat = category_path(db, split.category_id);
writeln!(writer, "S{}", split_cat)?;
let split_amount = match txn.transaction_type {
TransactionType::Expense => -split.amount,
TransactionType::Income => split.amount,
};
writeln!(writer, "${:.2}", split_amount)?;
if let Some(ref note) = split.note {
if !note.is_empty() {
writeln!(writer, "E{}", note)?;
}
}
}
}
// M - memo/note
if let Some(ref note) = txn.note {
if !note.is_empty() {
writeln!(writer, "M{}", note)?;
}
}
// ^ - end of record
writeln!(writer, "^")?;
}
Ok(transactions.len())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::NewTransaction;
fn setup_db() -> Database {
Database::open_in_memory().unwrap()
}
#[test]
fn test_qif_header() {
let db = setup_db();
let mut buf = Vec::new();
export_qif(&db, &mut buf, None, None).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.starts_with("!Type:Bank"));
}
#[test]
fn test_qif_expense_negative_amount() {
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let txn = NewTransaction {
amount: 42.50,
transaction_type: TransactionType::Expense,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Lunch".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None,
payee: Some("Cafe".to_string()),
};
db.insert_transaction(&txn).unwrap();
let mut buf = Vec::new();
let count = export_qif(&db, &mut buf, None, None).unwrap();
assert_eq!(count, 1);
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("D03/01/2026"));
assert!(output.contains("T-42.50"));
assert!(output.contains("PCafe"));
assert!(output.contains("MLunch"));
assert!(output.contains("^"));
}
#[test]
fn test_qif_income_positive_amount() {
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Income)).unwrap();
let txn = NewTransaction {
amount: 1000.0,
transaction_type: TransactionType::Income,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Salary".to_string()),
date: NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn).unwrap();
let mut buf = Vec::new();
export_qif(&db, &mut buf, None, None).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("T1000.00"));
assert!(output.contains("MSalary"));
}
#[test]
fn test_qif_record_separator() {
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
for day in 1..=3 {
let txn = NewTransaction {
amount: 10.0,
transaction_type: TransactionType::Expense,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: None,
date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn).unwrap();
}
let mut buf = Vec::new();
let count = export_qif(&db, &mut buf, None, None).unwrap();
assert_eq!(count, 3);
let output = String::from_utf8(buf).unwrap();
let separators = output.lines().filter(|l| *l == "^").count();
assert_eq!(separators, 3);
}
#[test]
fn test_qif_date_range_filter() {
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
for day in 1..=5 {
let txn = NewTransaction {
amount: 10.0,
transaction_type: TransactionType::Expense,
category_id: cats[0].id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: None,
date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(),
recurring_id: None,
payee: None,
};
db.insert_transaction(&txn).unwrap();
}
let mut buf = Vec::new();
let count = export_qif(
&db,
&mut buf,
Some(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()),
Some(NaiveDate::from_ymd_opt(2026, 1, 4).unwrap()),
)
.unwrap();
assert_eq!(count, 3);
}
#[test]
fn test_qif_empty_export() {
let db = setup_db();
let mut buf = Vec::new();
let count = export_qif(&db, &mut buf, None, None).unwrap();
assert_eq!(count, 0);
let output = String::from_utf8(buf).unwrap();
assert_eq!(output.trim(), "!Type:Bank");
}
}

168
outlay-core/src/expr.rs Normal file
View File

@@ -0,0 +1,168 @@
/// Evaluate a simple arithmetic expression containing +, -, *, /.
/// Supports decimal numbers. Returns None if the input is not a valid expression.
pub fn eval_expr(input: &str) -> Option<f64> {
let input = input.trim();
if input.is_empty() {
return None;
}
// If it's just a plain number, parse directly
if let Ok(v) = input.parse::<f64>() {
return Some(v);
}
// Tokenize
let mut tokens = Vec::new();
let mut num_buf = String::new();
for ch in input.chars() {
if ch.is_ascii_digit() || ch == '.' {
num_buf.push(ch);
} else if ch == '+' || ch == '-' || ch == '*' || ch == '/' {
if num_buf.is_empty() {
return None;
}
tokens.push(Token::Num(num_buf.parse::<f64>().ok()?));
num_buf.clear();
tokens.push(Token::Op(ch));
} else if ch.is_whitespace() {
continue;
} else {
return None;
}
}
if !num_buf.is_empty() {
tokens.push(Token::Num(num_buf.parse::<f64>().ok()?));
}
if tokens.is_empty() {
return None;
}
// Evaluate: * and / first (left to right), then + and -
let mut simplified: Vec<Token> = Vec::new();
let mut i = 0;
while i < tokens.len() {
if !simplified.is_empty() {
match simplified.last() {
Some(Token::Op('*')) | Some(Token::Op('/')) => {
if let Token::Num(b) = &tokens[i] {
let op = if let Some(Token::Op(op)) = simplified.pop() { op } else { return None; };
if let Some(Token::Num(a)) = simplified.pop() {
let result = if op == '*' { a * b } else {
if *b == 0.0 { return None; }
a / b
};
simplified.push(Token::Num(result));
i += 1;
continue;
}
return None;
}
}
_ => {}
}
}
simplified.push(tokens[i].clone());
i += 1;
}
// Second pass: handle + and -
let mut result = match simplified.first()? {
Token::Num(n) => *n,
_ => return None,
};
let mut j = 1;
while j + 1 < simplified.len() {
let num = match &simplified[j + 1] {
Token::Num(n) => *n,
_ => return None,
};
match &simplified[j] {
Token::Op('+') => result += num,
Token::Op('-') => result -= num,
_ => return None,
}
j += 2;
}
Some(result)
}
#[derive(Clone, Debug)]
enum Token {
Num(f64),
Op(char),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plain_number() {
assert_eq!(eval_expr("12.50"), Some(12.50));
}
#[test]
fn test_addition() {
assert_eq!(eval_expr("12.50+8.75"), Some(21.25));
}
#[test]
fn test_subtraction() {
assert_eq!(eval_expr("100-25.50"), Some(74.50));
}
#[test]
fn test_multiplication() {
assert_eq!(eval_expr("3*4.5"), Some(13.5));
}
#[test]
fn test_mul_before_add() {
assert_eq!(eval_expr("10+5*2"), Some(20.0));
}
#[test]
fn test_spaces() {
assert_eq!(eval_expr("10 + 5"), Some(15.0));
}
#[test]
fn test_empty() {
assert_eq!(eval_expr(""), None);
}
#[test]
fn test_invalid() {
assert_eq!(eval_expr("abc"), None);
}
#[test]
fn test_chain() {
assert_eq!(eval_expr("1+2+3"), Some(6.0));
}
#[test]
fn test_mixed_ops() {
// 5 + 3*2 - 1 = 5 + 6 - 1 = 10
assert_eq!(eval_expr("5+3*2-1"), Some(10.0));
}
#[test]
fn test_division() {
assert_eq!(eval_expr("10/4"), Some(2.5));
}
#[test]
fn test_division_by_zero() {
assert_eq!(eval_expr("10/0"), None);
}
#[test]
fn test_div_before_add() {
// 10 + 6/2 = 10 + 3 = 13
assert_eq!(eval_expr("10+6/2"), Some(13.0));
}
}

View File

@@ -0,0 +1,75 @@
use crate::db::Database;
use crate::models::{NewTransaction, TransactionType};
use std::path::Path;
pub fn import_csv(db: &Database, path: &Path, merge: bool) -> Result<usize, Box<dyn std::error::Error>> {
if !merge {
db.reset_all_data()?;
}
let mut reader = csv::Reader::from_path(path)?;
let mut count = 0;
for result in reader.records() {
let record = result?;
if record.len() < 6 {
continue;
}
let date_str = &record[0];
let type_str = &record[1];
let category_name = &record[2];
let amount: f64 = match record[3].parse() {
Ok(v) => v,
Err(_) => continue,
};
let currency = &record[4];
let exchange_rate: f64 = match record[5].parse() {
Ok(v) => v,
Err(_) => 1.0,
};
let note = if record.len() > 6 && !record[6].is_empty() {
Some(record[6].to_string())
} else {
None
};
let payee = if record.len() > 7 && !record[7].is_empty() {
Some(record[7].to_string())
} else {
None
};
let txn_type = match type_str.to_lowercase().as_str() {
"expense" => TransactionType::Expense,
"income" => TransactionType::Income,
_ => continue,
};
let categories = db.list_categories(Some(txn_type))?;
let category_id = match categories.iter().find(|c| c.name == category_name) {
Some(c) => c.id,
None => continue,
};
let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?;
if merge && db.find_duplicate_transaction(amount, txn_type, category_id, date)? {
continue;
}
let new_txn = NewTransaction {
amount,
transaction_type: txn_type,
category_id,
currency: currency.to_string(),
exchange_rate,
note,
date,
recurring_id: None,
payee,
};
db.insert_transaction(&new_txn)?;
count += 1;
}
Ok(count)
}

View File

@@ -0,0 +1,59 @@
use crate::db::Database;
use crate::export_json::ExportData;
use crate::models::{NewCategory, NewTransaction};
use std::path::Path;
pub fn import_json(db: &Database, path: &Path, merge: bool) -> Result<usize, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let data: ExportData = serde_json::from_str(&content)?;
if !merge {
db.reset_all_data()?;
}
for cat in &data.categories {
let existing = db.list_categories(Some(cat.transaction_type))?;
if !existing.iter().any(|c| c.name == cat.name) {
let new_cat = NewCategory {
name: cat.name.clone(),
icon: cat.icon.clone(),
color: cat.color.clone(),
transaction_type: cat.transaction_type,
sort_order: cat.sort_order,
parent_id: None,
};
db.insert_category(&new_cat)?;
}
}
let mut count = 0;
for txn in &data.transactions {
let categories = db.list_categories(Some(txn.transaction_type))?;
let original_cat = data.categories.iter().find(|c| c.id == txn.category_id);
let category_id = match original_cat {
Some(oc) => categories.iter().find(|c| c.name == oc.name).map(|c| c.id),
None => None,
};
let Some(category_id) = category_id else { continue };
if merge && db.find_duplicate_transaction(txn.amount, txn.transaction_type, category_id, txn.date)? {
continue;
}
let new_txn = NewTransaction {
amount: txn.amount,
transaction_type: txn.transaction_type,
category_id,
currency: txn.currency.clone(),
exchange_rate: txn.exchange_rate,
note: txn.note.clone(),
date: txn.date,
recurring_id: None,
payee: txn.payee.clone(),
};
db.insert_transaction(&new_txn)?;
count += 1;
}
Ok(count)
}

View File

@@ -0,0 +1,298 @@
use crate::db::Database;
use crate::models::{NewTransaction, TransactionType};
use chrono::NaiveDate;
use std::path::Path;
/// Import transactions from an OFX 1.6 SGML file.
///
/// Parses STMTTRN records looking for:
/// - TRNTYPE (DEBIT/CREDIT)
/// - DTPOSTED (YYYYMMDD date)
/// - TRNAMT (signed amount)
/// - NAME (payee/description)
/// - MEMO (note)
///
/// Since OFX does not carry category information, all imported
/// transactions are assigned to the first available category
/// of the matching type (expense for DEBIT, income for CREDIT).
pub fn import_ofx(db: &Database, path: &Path, merge: bool) -> Result<usize, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
if !merge {
db.reset_all_data()?;
}
let expense_cats = db.list_categories(Some(TransactionType::Expense))?;
let income_cats = db.list_categories(Some(TransactionType::Income))?;
let default_expense_id = expense_cats.first().map(|c| c.id).unwrap_or(1);
let default_income_id = income_cats.first().map(|c| c.id).unwrap_or(1);
let base_currency = db
.get_setting("base_currency")
.ok()
.flatten()
.unwrap_or_else(|| "USD".to_string());
let mut count = 0;
// Parse STMTTRN blocks
let mut pos = 0;
let upper = content.to_uppercase();
while let Some(start) = upper[pos..].find("<STMTTRN>") {
let block_start = pos + start;
let block_end = if let Some(end) = upper[block_start..].find("</STMTTRN>") {
block_start + end + "</STMTTRN>".len()
} else {
// No closing tag - take until next STMTTRN or end
if let Some(next) = upper[block_start + 9..].find("<STMTTRN>") {
block_start + 9 + next
} else {
content.len()
}
};
let block = &content[block_start..block_end];
let trntype = extract_tag_value(block, "TRNTYPE");
let dtposted = extract_tag_value(block, "DTPOSTED");
let trnamt = extract_tag_value(block, "TRNAMT");
let name = extract_tag_value(block, "NAME");
let memo = extract_tag_value(block, "MEMO");
if let Some(amt_str) = &trnamt {
if let Ok(amt) = amt_str.replace(',', "").parse::<f64>() {
let txn_type = if let Some(ref tt) = trntype {
match tt.to_uppercase().as_str() {
"CREDIT" => TransactionType::Income,
_ => TransactionType::Expense,
}
} else if amt < 0.0 {
TransactionType::Expense
} else {
TransactionType::Income
};
let abs_amount = amt.abs();
let date = dtposted
.as_ref()
.and_then(|d| parse_ofx_date(d))
.unwrap_or_else(|| chrono::Local::now().date_naive());
let category_id = match txn_type {
TransactionType::Expense => default_expense_id,
TransactionType::Income => default_income_id,
};
let payee = name.as_ref().map(|n| ofx_unescape(n));
let note = memo.as_ref().map(|m| ofx_unescape(m));
if merge && db.find_duplicate_transaction(abs_amount, txn_type, category_id, date)? {
// Skip duplicate
} else {
let new_txn = NewTransaction {
amount: abs_amount,
transaction_type: txn_type,
category_id,
currency: base_currency.clone(),
exchange_rate: 1.0,
note,
date,
recurring_id: None,
payee,
};
db.insert_transaction(&new_txn)?;
count += 1;
}
}
}
pos = block_end;
}
Ok(count)
}
/// Extract the value of an OFX SGML tag from a block of text.
/// OFX 1.6 SGML tags look like: <TAGNAME>value
/// The value runs until the next < or newline.
fn extract_tag_value(block: &str, tag: &str) -> Option<String> {
let upper_block = block.to_uppercase();
let search = format!("<{}>", tag.to_uppercase());
let start = upper_block.find(&search)?;
let value_start = start + search.len();
let remaining = &block[value_start..];
// Value ends at next '<' or newline
let end = remaining
.find(|c: char| c == '<' || c == '\n' || c == '\r')
.unwrap_or(remaining.len());
let value = remaining[..end].trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
}
/// Parse an OFX date string (YYYYMMDD or YYYYMMDDHHMMSS).
fn parse_ofx_date(s: &str) -> Option<NaiveDate> {
let s = s.trim();
// Take just the first 8 chars (YYYYMMDD)
if s.len() < 8 {
return None;
}
let date_part = &s[..8];
NaiveDate::parse_from_str(date_part, "%Y%m%d").ok()
}
/// Unescape OFX SGML entities.
fn ofx_unescape(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn setup_db() -> Database {
Database::open_in_memory().unwrap()
}
fn write_temp_ofx(content: &str) -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("outlay_test_ofx_{}.ofx", n));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
path
}
fn minimal_ofx(transactions: &str) -> String {
format!(
"OFXHEADER:100\nDATA:OFXSGML\nVERSION:160\n\n\
<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS>\n\
<BANKTRANLIST>\n{}\n</BANKTRANLIST>\n\
</STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>",
transactions
)
}
#[test]
fn test_import_ofx_expense() {
let db = setup_db();
let path = write_temp_ofx(&minimal_ofx(
"<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260301\n<TRNAMT>-42.50\n<NAME>Cafe\n<MEMO>Lunch\n</STMTTRN>",
));
let count = import_ofx(&db, &path, true).unwrap();
assert_eq!(count, 1);
let txns = db.list_all_transactions(None, None).unwrap();
assert_eq!(txns.len(), 1);
assert_eq!(txns[0].amount, 42.50);
assert_eq!(txns[0].transaction_type, TransactionType::Expense);
assert_eq!(txns[0].payee.as_deref(), Some("Cafe"));
assert_eq!(txns[0].note.as_deref(), Some("Lunch"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_import_ofx_income() {
let db = setup_db();
let path = write_temp_ofx(&minimal_ofx(
"<STMTTRN>\n<TRNTYPE>CREDIT\n<DTPOSTED>20260215\n<TRNAMT>5000.00\n<NAME>Employer\n</STMTTRN>",
));
let count = import_ofx(&db, &path, true).unwrap();
assert_eq!(count, 1);
let txns = db.list_all_transactions(None, None).unwrap();
assert_eq!(txns[0].transaction_type, TransactionType::Income);
assert_eq!(txns[0].amount, 5000.0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_import_ofx_multiple() {
let db = setup_db();
let path = write_temp_ofx(&minimal_ofx(
"<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260101\n<TRNAMT>-10.00\n</STMTTRN>\n\
<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260102\n<TRNAMT>-20.00\n</STMTTRN>\n\
<STMTTRN>\n<TRNTYPE>CREDIT\n<DTPOSTED>20260103\n<TRNAMT>50.00\n</STMTTRN>",
));
let count = import_ofx(&db, &path, true).unwrap();
assert_eq!(count, 3);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_import_ofx_merge_deduplication() {
let db = setup_db();
let path = write_temp_ofx(&minimal_ofx(
"<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260301\n<TRNAMT>-42.50\n</STMTTRN>",
));
let count1 = import_ofx(&db, &path, true).unwrap();
assert_eq!(count1, 1);
let count2 = import_ofx(&db, &path, true).unwrap();
assert_eq!(count2, 0);
let txns = db.list_all_transactions(None, None).unwrap();
assert_eq!(txns.len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_import_ofx_unescapes_entities() {
let db = setup_db();
let path = write_temp_ofx(&minimal_ofx(
"<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260101\n<TRNAMT>-10.00\n<NAME>A&amp;B Store\n<MEMO>Tom &amp; Jerry&apos;s\n</STMTTRN>",
));
let count = import_ofx(&db, &path, true).unwrap();
assert_eq!(count, 1);
let txns = db.list_all_transactions(None, None).unwrap();
assert_eq!(txns[0].payee.as_deref(), Some("A&B Store"));
assert_eq!(txns[0].note.as_deref(), Some("Tom & Jerry's"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_import_ofx_empty() {
let db = setup_db();
let path = write_temp_ofx(&minimal_ofx(""));
let count = import_ofx(&db, &path, true).unwrap();
assert_eq!(count, 0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_extract_tag_value() {
let block = "<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20260301\n<TRNAMT>-42.50\n</STMTTRN>";
assert_eq!(extract_tag_value(block, "TRNTYPE"), Some("DEBIT".to_string()));
assert_eq!(extract_tag_value(block, "DTPOSTED"), Some("20260301".to_string()));
assert_eq!(extract_tag_value(block, "TRNAMT"), Some("-42.50".to_string()));
assert_eq!(extract_tag_value(block, "FITID"), None);
}
#[test]
fn test_parse_ofx_date() {
assert_eq!(
parse_ofx_date("20260301"),
Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
);
assert_eq!(
parse_ofx_date("20260301120000"),
Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
);
assert_eq!(parse_ofx_date("2026"), None);
}
}

View File

@@ -0,0 +1,187 @@
use crate::models::PdfParsedRow;
use chrono::NaiveDate;
/// Extract transactions from a PDF bank statement.
/// Tries text extraction first, falls back to OCR if no text found.
pub fn extract_transactions_from_pdf(bytes: &[u8]) -> Result<Vec<PdfParsedRow>, String> {
// Try text-based extraction first
match extract_text_based(bytes) {
Ok(rows) if !rows.is_empty() => return Ok(rows),
_ => {}
}
// Fall back to OCR
if crate::ocr::is_available() {
return extract_ocr_based(bytes);
}
Err("No text found in PDF and OCR is not available".to_string())
}
fn extract_text_based(bytes: &[u8]) -> Result<Vec<PdfParsedRow>, String> {
let text = pdf_extract::extract_text_from_mem(bytes)
.map_err(|e| format!("PDF text extraction failed: {}", e))?;
if text.trim().is_empty() {
return Ok(Vec::new());
}
let mut rows = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(row) = parse_statement_line(line) {
rows.push(row);
}
}
Ok(rows)
}
fn extract_ocr_based(bytes: &[u8]) -> Result<Vec<PdfParsedRow>, String> {
let amounts: Vec<(f64, String)> = crate::ocr::extract_amounts_from_image(bytes)
.ok_or_else(|| "OCR extraction returned no results".to_string())?;
let rows: Vec<PdfParsedRow> = amounts
.into_iter()
.map(|(amount, source_line)| PdfParsedRow {
date: None,
description: source_line,
amount: amount.abs(),
is_credit: amount > 0.0,
})
.collect();
Ok(rows)
}
/// Try to parse a single line from a bank statement.
/// Common formats:
/// "01/15/2026 GROCERY STORE -45.67"
/// "2026-01-15 SALARY +2500.00"
/// "15 Jan Coffee Shop 12.50"
fn parse_statement_line(line: &str) -> Option<PdfParsedRow> {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 2 {
return None;
}
// Try to find a date at the start
let (date, desc_start) = try_parse_date_prefix(&tokens);
// Try to find an amount at the end
let (amount, is_credit, desc_end) = try_parse_amount_suffix(&tokens)?;
// Everything between date and amount is description
if desc_start >= desc_end {
return None;
}
let description = tokens[desc_start..desc_end].join(" ");
if description.is_empty() {
return None;
}
Some(PdfParsedRow {
date,
description,
amount,
is_credit,
})
}
fn try_parse_date_prefix(tokens: &[&str]) -> (Option<NaiveDate>, usize) {
if tokens.is_empty() {
return (None, 0);
}
// Try single token: "2026-01-15", "01/15/2026", "15/01/2026"
if let Some(d) = parse_date_flexible(tokens[0]) {
return (Some(d), 1);
}
// Try two tokens: "15 Jan", "Jan 15"
if tokens.len() >= 2 {
let combined = format!("{} {}", tokens[0], tokens[1]);
if let Some(d) = parse_date_flexible(&combined) {
return (Some(d), 2);
}
// Try three tokens: "15 Jan 2026"
if tokens.len() >= 3 {
let combined3 = format!("{} {} {}", tokens[0], tokens[1], tokens[2]);
if let Some(d) = parse_date_flexible(&combined3) {
return (Some(d), 3);
}
}
}
(None, 0)
}
fn try_parse_amount_suffix(tokens: &[&str]) -> Option<(f64, bool, usize)> {
for i in (0..tokens.len()).rev() {
let tok = tokens[i];
let cleaned = tok.replace(',', "").replace('$', "");
if let Ok(val) = cleaned.parse::<f64>() {
let is_credit = val > 0.0 || tok.starts_with('+');
return Some((val.abs(), is_credit, i));
}
}
None
}
fn parse_date_flexible(s: &str) -> Option<NaiveDate> {
let formats = [
"%Y-%m-%d",
"%m/%d/%Y",
"%d/%m/%Y",
"%m-%d-%Y",
"%d %b %Y",
"%b %d %Y",
"%d %b",
"%b %d",
];
for fmt in &formats {
if let Ok(d) = NaiveDate::parse_from_str(s, fmt) {
return Some(d);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_line_with_date_and_amount() {
let row = parse_statement_line("2026-01-15 GROCERY STORE -45.67").unwrap();
assert_eq!(
row.date,
Some(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap())
);
assert_eq!(row.description, "GROCERY STORE");
assert!((row.amount - 45.67).abs() < 0.01);
assert!(!row.is_credit);
}
#[test]
fn test_parse_line_credit() {
let row = parse_statement_line("2026-01-15 SALARY +2500.00").unwrap();
assert!((row.amount - 2500.0).abs() < 0.01);
assert!(row.is_credit);
}
#[test]
fn test_parse_line_no_date() {
let row = parse_statement_line("COFFEE SHOP 12.50").unwrap();
assert!(row.date.is_none());
assert_eq!(row.description, "COFFEE SHOP");
assert!((row.amount - 12.50).abs() < 0.01);
}
#[test]
fn test_parse_line_too_short() {
assert!(parse_statement_line("hello").is_none());
}
}

View File

@@ -0,0 +1,333 @@
use crate::db::Database;
use crate::models::{NewTransaction, TransactionType};
use chrono::NaiveDate;
use std::path::Path;
/// Import transactions from a QIF (Quicken Interchange Format) file.
///
/// QIF records use single-character line prefixes:
/// - D = date (MM/DD/YYYY or MM/DD'YY)
/// - T = amount (negative = expense, positive = income)
/// - P = payee
/// - L = category
/// - M = memo/note
/// - S/$/E = split lines (category/amount/memo)
/// - ^ = end of record
///
/// Categories are matched by name. If a category is not found,
/// the transaction is assigned to the first matching-type category.
pub fn import_qif(db: &Database, path: &Path, merge: bool) -> Result<usize, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
if !merge {
db.reset_all_data()?;
}
let expense_cats = db.list_categories(Some(TransactionType::Expense))?;
let income_cats = db.list_categories(Some(TransactionType::Income))?;
let default_expense_id = expense_cats.first().map(|c| c.id).unwrap_or(1);
let default_income_id = income_cats.first().map(|c| c.id).unwrap_or(1);
let mut count = 0;
let mut date: Option<NaiveDate> = None;
let mut amount: Option<f64> = None;
let mut payee: Option<String> = None;
let mut category: Option<String> = None;
let mut memo: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('!') {
continue;
}
let prefix = &line[..1];
let value = &line[1..];
match prefix {
"D" => {
date = parse_qif_date(value);
}
"T" => {
amount = value.replace(',', "").parse::<f64>().ok();
}
"P" => {
if !value.is_empty() {
payee = Some(value.to_string());
}
}
"L" => {
if !value.is_empty() {
category = Some(value.to_string());
}
}
"M" => {
if !value.is_empty() {
memo = Some(value.to_string());
}
}
"^" => {
// End of record - save transaction
if let (Some(d), Some(amt)) = (date, amount) {
let txn_type = if amt < 0.0 {
TransactionType::Expense
} else {
TransactionType::Income
};
let abs_amount = amt.abs();
let category_id = resolve_category(
&category,
txn_type,
&expense_cats,
&income_cats,
default_expense_id,
default_income_id,
);
if merge && db.find_duplicate_transaction(abs_amount, txn_type, category_id, d)? {
// Skip duplicate
} else {
let new_txn = NewTransaction {
amount: abs_amount,
transaction_type: txn_type,
category_id,
currency: base_currency(db),
exchange_rate: 1.0,
note: memo.clone(),
date: d,
recurring_id: None,
payee: payee.clone(),
};
db.insert_transaction(&new_txn)?;
count += 1;
}
}
// Reset for next record
date = None;
amount = None;
payee = None;
category = None;
memo = None;
}
// Skip split lines (S, $, E) and other unknown prefixes
_ => {}
}
}
Ok(count)
}
fn base_currency(db: &Database) -> String {
db.get_setting("base_currency")
.ok()
.flatten()
.unwrap_or_else(|| "USD".to_string())
}
/// Parse a QIF date string. Supports:
/// - MM/DD/YYYY (e.g., 03/01/2026)
/// - MM/DD'YY (e.g., 3/ 1'26)
/// - M/D/YYYY
/// - MM-DD-YYYY
fn parse_qif_date(s: &str) -> Option<NaiveDate> {
let s = s.trim().replace(' ', "");
// Try MM/DD/YYYY or M/D/YYYY
if let Ok(d) = NaiveDate::parse_from_str(&s, "%m/%d/%Y") {
return Some(d);
}
// Try MM-DD-YYYY
if let Ok(d) = NaiveDate::parse_from_str(&s, "%m-%d-%Y") {
return Some(d);
}
// Try the apostrophe format: M/D'YY
if let Some(apos_idx) = s.find('\'') {
let date_part = &s[..apos_idx];
let year_part = &s[apos_idx + 1..];
if let Some((month_str, day_str)) = date_part.split_once('/') {
let month: u32 = month_str.parse().ok()?;
let day: u32 = day_str.parse().ok()?;
let year_short: i32 = year_part.parse().ok()?;
let year = if year_short < 100 { 2000 + year_short } else { year_short };
return NaiveDate::from_ymd_opt(year, month, day);
}
}
None
}
/// Resolve a QIF category name to a database category ID.
/// QIF uses "Parent:Sub" for subcategories.
fn resolve_category(
cat_name: &Option<String>,
txn_type: TransactionType,
expense_cats: &[crate::models::Category],
income_cats: &[crate::models::Category],
default_expense_id: i64,
default_income_id: i64,
) -> i64 {
let cats = match txn_type {
TransactionType::Expense => expense_cats,
TransactionType::Income => income_cats,
};
let default_id = match txn_type {
TransactionType::Expense => default_expense_id,
TransactionType::Income => default_income_id,
};
let Some(name) = cat_name else {
return default_id;
};
// Try exact match first
if let Some(c) = cats.iter().find(|c| c.name == *name) {
return c.id;
}
// For "Parent:Sub" format, try matching just the sub-category name
if let Some((_parent, sub)) = name.split_once(':') {
if let Some(c) = cats.iter().find(|c| c.name == sub) {
return c.id;
}
}
// Case-insensitive match
let lower = name.to_lowercase();
if let Some(c) = cats.iter().find(|c| c.name.to_lowercase() == lower) {
return c.id;
}
default_id
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn setup_db() -> Database {
Database::open_in_memory().unwrap()
}
fn write_temp_qif(content: &str) -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("outlay_test_qif_{}.qif", n));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
path
}
#[test]
fn test_import_qif_expense() {
let db = setup_db();
let path = write_temp_qif(
"!Type:Bank\nD03/01/2026\nT-42.50\nPCafe\nMLunch\n^\n",
);
let count = import_qif(&db, &path, true).unwrap();
assert_eq!(count, 1);
let txns = db.list_all_transactions(None, None).unwrap();
assert_eq!(txns.len(), 1);
assert_eq!(txns[0].amount, 42.50);
assert_eq!(txns[0].transaction_type, TransactionType::Expense);
assert_eq!(txns[0].payee.as_deref(), Some("Cafe"));
assert_eq!(txns[0].note.as_deref(), Some("Lunch"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_import_qif_income() {
let db = setup_db();
let path = write_temp_qif(
"!Type:Bank\nD02/15/2026\nT1000.00\nMSalary\n^\n",
);
let count = import_qif(&db, &path, true).unwrap();
assert_eq!(count, 1);
let txns = db.list_all_transactions(None, None).unwrap();
assert_eq!(txns[0].transaction_type, TransactionType::Income);
assert_eq!(txns[0].amount, 1000.0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_import_qif_multiple_records() {
let db = setup_db();
let path = write_temp_qif(
"!Type:Bank\nD01/01/2026\nT-10.00\n^\nD01/02/2026\nT-20.00\n^\nD01/03/2026\nT50.00\n^\n",
);
let count = import_qif(&db, &path, true).unwrap();
assert_eq!(count, 3);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_import_qif_merge_deduplication() {
let db = setup_db();
let path = write_temp_qif(
"!Type:Bank\nD03/01/2026\nT-42.50\n^\n",
);
let count1 = import_qif(&db, &path, true).unwrap();
assert_eq!(count1, 1);
let count2 = import_qif(&db, &path, true).unwrap();
assert_eq!(count2, 0);
let txns = db.list_all_transactions(None, None).unwrap();
assert_eq!(txns.len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_import_qif_category_matching() {
let db = setup_db();
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let cat_name = &cats[0].name;
let path = write_temp_qif(&format!(
"!Type:Bank\nD01/01/2026\nT-25.00\nL{}\n^\n",
cat_name
));
let count = import_qif(&db, &path, true).unwrap();
assert_eq!(count, 1);
let txns = db.list_all_transactions(None, None).unwrap();
assert_eq!(txns[0].category_id, cats[0].id);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_parse_qif_date_formats() {
assert_eq!(
parse_qif_date("03/01/2026"),
Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
);
assert_eq!(
parse_qif_date("3/1/2026"),
Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
);
assert_eq!(
parse_qif_date("03-01-2026"),
Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
);
assert_eq!(
parse_qif_date("3/ 1'26"),
Some(NaiveDate::from_ymd_opt(2026, 3, 1).unwrap())
);
}
#[test]
fn test_import_qif_empty_file() {
let db = setup_db();
let path = write_temp_qif("!Type:Bank\n");
let count = import_qif(&db, &path, true).unwrap();
assert_eq!(count, 0);
let _ = std::fs::remove_file(&path);
}
}

View File

@@ -3,6 +3,19 @@ pub mod db;
pub mod exchange;
pub mod export_csv;
pub mod export_json;
pub mod export_ofx;
pub mod export_pdf;
pub mod export_qif;
pub mod import_csv;
pub mod import_json;
pub mod import_ofx;
pub mod import_qif;
pub mod backup;
pub mod recurring;
pub mod expr;
pub mod ocr;
pub mod notifications;
pub mod nlp;
pub mod sankey;
pub mod import_pdf;
pub mod seed;

View File

@@ -78,6 +78,7 @@ pub struct Category {
pub transaction_type: TransactionType,
pub is_default: bool,
pub sort_order: i32,
pub parent_id: Option<i64>,
}
#[derive(Debug, Clone)]
@@ -87,6 +88,7 @@ pub struct NewCategory {
pub color: Option<String>,
pub transaction_type: TransactionType,
pub sort_order: i32,
pub parent_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -101,6 +103,7 @@ pub struct Transaction {
pub date: NaiveDate,
pub created_at: String,
pub recurring_id: Option<i64>,
pub payee: Option<String>,
}
#[derive(Debug, Clone)]
@@ -113,6 +116,7 @@ pub struct NewTransaction {
pub note: Option<String>,
pub date: NaiveDate,
pub recurring_id: Option<i64>,
pub payee: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -121,6 +125,7 @@ pub struct Budget {
pub category_id: i64,
pub amount: f64,
pub month: String,
pub rollover: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -136,6 +141,10 @@ pub struct RecurringTransaction {
pub end_date: Option<NaiveDate>,
pub last_generated: Option<NaiveDate>,
pub active: bool,
pub resume_date: Option<NaiveDate>,
pub is_bill: bool,
pub reminder_days: i32,
pub subscription_id: Option<i64>,
}
#[derive(Debug, Clone)]
@@ -148,6 +157,9 @@ pub struct NewRecurringTransaction {
pub frequency: Frequency,
pub start_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub is_bill: bool,
pub reminder_days: i32,
pub subscription_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -158,6 +170,240 @@ pub struct ExchangeRate {
pub fetched_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Split {
pub id: i64,
pub transaction_id: i64,
pub category_id: i64,
pub amount: f64,
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransactionTemplate {
pub id: i64,
pub name: String,
pub amount: Option<f64>,
pub transaction_type: TransactionType,
pub category_id: i64,
pub currency: String,
pub payee: Option<String>,
pub note: Option<String>,
pub tags: Option<String>,
pub sort_order: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CategorizeRule {
pub id: i64,
pub field: String,
pub pattern: String,
pub category_id: i64,
pub priority: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavingsGoal {
pub id: i64,
pub name: String,
pub target: f64,
pub saved: f64,
pub currency: String,
pub deadline: Option<NaiveDate>,
pub color: Option<String>,
pub icon: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WishlistItem {
pub id: i64,
pub name: String,
pub amount: f64,
pub category_id: Option<i64>,
pub url: Option<String>,
pub note: Option<String>,
pub priority: i32,
pub purchased: bool,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionCategory {
pub id: i64,
pub name: String,
pub icon: Option<String>,
pub color: Option<String>,
pub sort_order: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subscription {
pub id: i64,
pub name: String,
pub amount: f64,
pub currency: String,
pub frequency: Frequency,
pub category_id: i64,
pub start_date: NaiveDate,
pub next_due: NaiveDate,
pub active: bool,
pub note: Option<String>,
pub url: Option<String>,
pub recurring_id: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct NewSubscription {
pub name: String,
pub amount: f64,
pub currency: String,
pub frequency: Frequency,
pub category_id: i64,
pub start_date: NaiveDate,
pub note: Option<String>,
pub url: Option<String>,
pub recurring_id: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct CreditCard {
pub id: i64,
pub name: String,
pub credit_limit: Option<f64>,
pub statement_close_day: i32,
pub due_day: i32,
pub min_payment_pct: f64,
pub current_balance: f64,
pub currency: String,
pub color: Option<String>,
pub active: bool,
}
#[derive(Debug, Clone)]
pub struct NewCreditCard {
pub name: String,
pub credit_limit: Option<f64>,
pub statement_close_day: i32,
pub due_day: i32,
pub min_payment_pct: f64,
pub currency: String,
pub color: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Achievement {
pub id: i64,
pub name: String,
pub description: String,
pub earned_at: Option<String>,
pub icon: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ParsedTransaction {
pub amount: f64,
pub category_name: Option<String>,
pub category_id: Option<i64>,
pub note: Option<String>,
pub payee: Option<String>,
pub transaction_type: TransactionType,
}
#[derive(Debug, Clone)]
pub struct SankeyNode {
pub label: String,
pub value: f64,
pub color: (f64, f64, f64),
pub y: f64,
pub height: f64,
}
#[derive(Debug, Clone)]
pub struct SankeyFlow {
pub from_idx: usize,
pub to_idx: usize,
pub value: f64,
pub from_y: f64,
pub to_y: f64,
pub width: f64,
}
#[derive(Debug, Clone)]
pub struct SankeyLayout {
pub left_nodes: Vec<SankeyNode>,
pub right_nodes: Vec<SankeyNode>,
pub center_y: f64,
pub center_height: f64,
pub flows_in: Vec<SankeyFlow>,
pub flows_out: Vec<SankeyFlow>,
pub net: f64,
}
#[derive(Debug, Clone)]
pub struct RecapCategory {
pub category_name: String,
pub category_icon: Option<String>,
pub category_color: Option<String>,
pub amount: f64,
pub percentage: f64,
pub change_pct: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct MonthlyRecap {
pub total_income: f64,
pub total_expenses: f64,
pub net: f64,
pub transaction_count: i64,
pub categories: Vec<RecapCategory>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BudgetCycleMode {
Calendar,
Payday,
Rolling,
}
impl BudgetCycleMode {
pub fn as_str(&self) -> &'static str {
match self {
BudgetCycleMode::Calendar => "calendar",
BudgetCycleMode::Payday => "payday",
BudgetCycleMode::Rolling => "rolling",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"payday" => BudgetCycleMode::Payday,
"rolling" => BudgetCycleMode::Rolling,
_ => BudgetCycleMode::Calendar,
}
}
}
impl fmt::Display for BudgetCycleMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct PdfParsedRow {
pub date: Option<NaiveDate>,
pub description: String,
pub amount: f64,
pub is_credit: bool,
}
#[cfg(test)]
mod tests {
use super::*;

248
outlay-core/src/nlp.rs Normal file
View File

@@ -0,0 +1,248 @@
use crate::models::{Category, ParsedTransaction, TransactionType};
/// Parse a free-form text string into a transaction.
///
/// Supported patterns:
/// "Coffee 4.50" -> amount=4.50, category=fuzzy("Coffee")
/// "4.50 groceries milk" -> amount=4.50, category=fuzzy("groceries"), note="milk"
/// "Lunch 12.50 at Subway" -> amount=12.50, category=fuzzy("Lunch"), payee="Subway"
/// "$25 gas" -> amount=25, category=fuzzy("gas")
pub fn parse_transaction(input: &str, categories: &[Category]) -> Option<ParsedTransaction> {
let input = input.trim();
if input.is_empty() {
return None;
}
let tokens: Vec<&str> = input.split_whitespace().collect();
if tokens.is_empty() {
return None;
}
// Find the amount token (first token that parses as a number, with optional $ prefix)
let mut amount: Option<f64> = None;
let mut amount_idx: Option<usize> = None;
for (i, tok) in tokens.iter().enumerate() {
let cleaned = tok.trim_start_matches('$').replace(',', "");
if let Ok(val) = cleaned.parse::<f64>() {
if val > 0.0 {
amount = Some(val);
amount_idx = Some(i);
break;
}
}
}
let amount = amount?;
let amount_idx = amount_idx.unwrap();
// Collect non-amount tokens
let word_tokens: Vec<&str> = tokens
.iter()
.enumerate()
.filter(|(i, _)| *i != amount_idx)
.map(|(_, t)| *t)
.collect();
// Find payee marker ("at", "from", "to")
let mut payee: Option<String> = None;
let mut pre_marker_words: Vec<&str> = Vec::new();
let mut found_marker = false;
let mut post_marker_words: Vec<&str> = Vec::new();
for word in &word_tokens {
let lower = word.to_lowercase();
if !found_marker && (lower == "at" || lower == "from" || lower == "to") {
found_marker = true;
continue;
}
if found_marker {
post_marker_words.push(word);
} else {
pre_marker_words.push(word);
}
}
if !post_marker_words.is_empty() {
payee = Some(post_marker_words.join(" "));
}
// Try to match first pre-marker word(s) to a category
let mut matched_category: Option<(String, i64)> = None;
let mut note_words: Vec<&str> = Vec::new();
if !pre_marker_words.is_empty() {
// Try matching progressively fewer words from the start
for len in (1..=pre_marker_words.len()).rev() {
let candidate = pre_marker_words[..len].join(" ");
if let Some(cat) = fuzzy_match_category(&candidate, categories) {
matched_category = Some((cat.name.clone(), cat.id));
note_words = pre_marker_words[len..].to_vec();
break;
}
}
// If no match, treat all words as note
if matched_category.is_none() {
note_words = pre_marker_words;
}
}
let note = if note_words.is_empty() {
None
} else {
Some(note_words.join(" "))
};
Some(ParsedTransaction {
amount,
category_name: matched_category.as_ref().map(|(n, _)| n.clone()),
category_id: matched_category.map(|(_, id)| id),
note,
payee,
transaction_type: TransactionType::Expense,
})
}
fn fuzzy_match_category<'a>(query: &str, categories: &'a [Category]) -> Option<&'a Category> {
let query_lower = query.to_lowercase();
// Exact match
if let Some(cat) = categories
.iter()
.find(|c| c.name.to_lowercase() == query_lower)
{
return Some(cat);
}
// Prefix match
if let Some(cat) = categories
.iter()
.find(|c| c.name.to_lowercase().starts_with(&query_lower))
{
return Some(cat);
}
// Contains match
if let Some(cat) = categories
.iter()
.find(|c| c.name.to_lowercase().contains(&query_lower))
{
return Some(cat);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn test_categories() -> Vec<Category> {
vec![
Category {
id: 1,
name: "Food and Dining".into(),
icon: None,
color: None,
transaction_type: TransactionType::Expense,
is_default: false,
sort_order: 0,
parent_id: None,
},
Category {
id: 2,
name: "Transport".into(),
icon: None,
color: None,
transaction_type: TransactionType::Expense,
is_default: false,
sort_order: 0,
parent_id: None,
},
Category {
id: 3,
name: "Groceries".into(),
icon: None,
color: None,
transaction_type: TransactionType::Expense,
is_default: false,
sort_order: 0,
parent_id: None,
},
Category {
id: 4,
name: "Gas".into(),
icon: None,
color: None,
transaction_type: TransactionType::Expense,
is_default: false,
sort_order: 0,
parent_id: None,
},
Category {
id: 5,
name: "Coffee".into(),
icon: None,
color: None,
transaction_type: TransactionType::Expense,
is_default: false,
sort_order: 0,
parent_id: None,
},
]
}
#[test]
fn test_simple_category_amount() {
let cats = test_categories();
let result = parse_transaction("Coffee 4.50", &cats).unwrap();
assert!((result.amount - 4.50).abs() < 0.001);
assert_eq!(result.category_id, Some(5));
assert!(result.payee.is_none());
}
#[test]
fn test_amount_first() {
let cats = test_categories();
let result = parse_transaction("4.50 groceries milk", &cats).unwrap();
assert!((result.amount - 4.50).abs() < 0.001);
assert_eq!(result.category_id, Some(3));
assert_eq!(result.note.as_deref(), Some("milk"));
}
#[test]
fn test_with_payee() {
let cats = test_categories();
let result = parse_transaction("Coffee 12.50 at Starbucks", &cats).unwrap();
assert!((result.amount - 12.50).abs() < 0.001);
assert_eq!(result.category_id, Some(5));
assert_eq!(result.payee.as_deref(), Some("Starbucks"));
}
#[test]
fn test_dollar_sign() {
let cats = test_categories();
let result = parse_transaction("$25 gas", &cats).unwrap();
assert!((result.amount - 25.0).abs() < 0.001);
assert_eq!(result.category_id, Some(4));
}
#[test]
fn test_no_category_match() {
let cats = test_categories();
let result = parse_transaction("15.00 mystery", &cats).unwrap();
assert!((result.amount - 15.0).abs() < 0.001);
assert!(result.category_id.is_none());
}
#[test]
fn test_empty_input() {
let cats = test_categories();
assert!(parse_transaction("", &cats).is_none());
}
#[test]
fn test_no_amount() {
let cats = test_categories();
assert!(parse_transaction("just some words", &cats).is_none());
}
}

View File

@@ -0,0 +1,76 @@
use crate::db::Database;
use std::process::Command;
/// Send a desktop notification via notify-send (Linux).
/// Returns silently if notify-send is not available.
pub fn send_notification(title: &str, body: &str, urgency: &str) {
let _ = Command::new("notify-send")
.arg("--urgency")
.arg(urgency)
.arg("--app-name=Outlay")
.arg(title)
.arg(body)
.spawn();
}
/// Check all budgets for the given month and send notifications
/// for any thresholds crossed that haven't been notified yet.
/// Only sends if budget_notifications setting is enabled.
pub fn check_and_send_budget_notifications(db: &Database, month: &str) {
let enabled = db.get_setting("budget_notifications")
.ok().flatten().map(|s| s == "1").unwrap_or(false);
if !enabled {
return;
}
let budgets = match db.list_budgets_for_month(month) {
Ok(b) => b,
Err(_) => return,
};
for budget in &budgets {
let cat_name = db.get_category(budget.category_id)
.map(|c| c.name)
.unwrap_or_else(|_| "Unknown".to_string());
let thresholds = match db.check_budget_thresholds(budget.category_id, month) {
Ok(t) => t,
Err(_) => continue,
};
for threshold in &thresholds {
let (title, urgency) = match threshold {
100 => (
format!("Budget exceeded: {}", cat_name),
"critical",
),
_ => (
format!("Budget {}% used: {}", threshold, cat_name),
"normal",
),
};
let progress = db.get_budget_progress(budget.category_id, month)
.ok().flatten();
let body = if let Some((budget_amt, spent, pct)) = progress {
format!("{:.2} of {:.2} spent ({:.0}%)", spent, budget_amt, pct)
} else {
String::new()
};
send_notification(&title, &body, urgency);
let _ = db.record_notification(budget.category_id, month, *threshold);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_send_notification_does_not_panic() {
// Should not panic even if notify-send is not installed
send_notification("Test", "Body", "normal");
}
}

206
outlay-core/src/ocr.rs Normal file
View File

@@ -0,0 +1,206 @@
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
/// Extract all monetary amounts from a receipt image using tesseract OCR.
/// Returns each amount paired with the line of text it was found on (trimmed).
/// Results are sorted: lines containing "total" first, then by amount descending.
/// Returns None if tesseract is unavailable or no amounts are found.
pub fn extract_amounts_from_image(image_bytes: &[u8]) -> Option<Vec<(f64, String)>> {
let tesseract = find_tesseract()?;
// Write image to a temp file
let tmp_dir = std::env::temp_dir();
let tmp_path = tmp_dir.join("outlay_ocr_tmp.png");
let mut file = std::fs::File::create(&tmp_path).ok()?;
file.write_all(image_bytes).ok()?;
drop(file);
let mut cmd = Command::new(&tesseract);
cmd.arg(&tmp_path).arg("stdout");
// If using bundled tesseract, point TESSDATA_PREFIX to bundled tessdata
if let Some(parent) = tesseract.parent() {
let tessdata = parent.join("tessdata");
if tessdata.is_dir() {
cmd.env("TESSDATA_PREFIX", parent);
}
}
let output = cmd.output().ok()?;
let _ = std::fs::remove_file(&tmp_path);
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let results = parse_all_amounts(&text);
if results.is_empty() {
None
} else {
Some(results)
}
}
/// Returns true if tesseract is available (bundled or system).
pub fn is_available() -> bool {
find_tesseract().is_some()
}
fn find_tesseract() -> Option<PathBuf> {
// Check for bundled tesseract next to our binary (AppImage layout)
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
let bundled = bin_dir.join("tesseract");
if bundled.is_file() {
return Some(bundled);
}
// Also check ../lib/tesseract (AppImage usr/lib layout)
let lib_bundled = bin_dir.join("../lib/tesseract").canonicalize().ok();
if let Some(p) = lib_bundled {
if p.is_file() {
return Some(p);
}
}
}
}
// Fall back to system PATH
Command::new("tesseract")
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|_| PathBuf::from("tesseract"))
}
fn parse_all_amounts(text: &str) -> Vec<(f64, String)> {
let mut results: Vec<(f64, String, bool)> = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let line_amounts = extract_amounts_from_line(trimmed);
let is_total = trimmed.to_lowercase().contains("total");
for amt in line_amounts {
// Deduplicate: skip if we already have this exact amount
if !results.iter().any(|(a, _, _)| (*a - amt).abs() < 0.001) {
results.push((amt, trimmed.to_string(), is_total));
}
}
}
// Sort: "total" lines first, then by amount descending
results.sort_by(|a, b| {
b.2.cmp(&a.2).then(b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal))
});
results.into_iter().map(|(amt, line, _)| (amt, line)).collect()
}
fn extract_amounts_from_line(line: &str) -> Vec<f64> {
let mut results = Vec::new();
let chars: Vec<char> = line.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
// Look for digit sequences followed by separator and exactly 2 digits
if chars[i].is_ascii_digit() {
let start = i;
// Consume integer part
while i < len && chars[i].is_ascii_digit() {
i += 1;
}
// Check for decimal separator followed by exactly 2 digits
if i < len && (chars[i] == '.' || chars[i] == ',') {
let sep = i;
i += 1;
let decimal_start = i;
while i < len && chars[i].is_ascii_digit() {
i += 1;
}
if i - decimal_start == 2 {
let int_part: String = chars[start..sep].iter().collect();
let dec_part: String = chars[decimal_start..i].iter().collect();
if let Ok(val) = format!("{}.{}", int_part, dec_part).parse::<f64>() {
if val > 0.0 {
results.push(val);
}
}
}
}
} else {
i += 1;
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_all_returns_sorted() {
let text = "Item 1 5.99\nItem 2 3.50\nTotal 9.49\n";
let results = parse_all_amounts(text);
// "Total" line should come first
assert_eq!(results[0].0, 9.49);
assert!(results[0].1.contains("Total"));
assert_eq!(results.len(), 3);
}
#[test]
fn test_parse_comma_separator() {
let text = "Total: 12,99\n";
let results = parse_all_amounts(text);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 12.99);
}
#[test]
fn test_no_total_sorts_by_amount() {
let text = "Coffee 4.50\nSandwich 8.99\n";
let results = parse_all_amounts(text);
assert_eq!(results[0].0, 8.99);
assert_eq!(results[1].0, 4.50);
}
#[test]
fn test_no_amounts() {
let text = "Hello world\nNo numbers here\n";
let results = parse_all_amounts(text);
assert!(results.is_empty());
}
#[test]
fn test_total_case_insensitive() {
let text = "Sub 5.00\nTOTAL 15.00\nChange 5.00\n";
let results = parse_all_amounts(text);
// TOTAL line first
assert_eq!(results[0].0, 15.00);
assert!(results[0].1.contains("TOTAL"));
}
#[test]
fn test_deduplicates_amounts() {
let text = "Subtotal 10.00\nTotal 10.00\n";
let results = parse_all_amounts(text);
// Same amount on two lines - should deduplicate
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 10.00);
}
#[test]
fn test_large_amount() {
let text = "Grand Total 1250.00\n";
let results = parse_all_amounts(text);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, 1250.00);
}
}

View File

@@ -1,13 +1,33 @@
use crate::db::Database;
use crate::exchange::ExchangeRateService;
use crate::models::{Frequency, NewTransaction};
use chrono::{Datelike, Days, NaiveDate};
/// Details about a generated recurring transaction.
pub struct GeneratedInfo {
pub description: String,
pub amount: f64,
pub currency: String,
}
pub fn generate_missed_transactions(
db: &Database,
today: NaiveDate,
base_currency: &str,
) -> Result<usize, rusqlite::Error> {
let (count, _details) = generate_missed_transactions_detailed(db, today, base_currency)?;
Ok(count)
}
pub fn generate_missed_transactions_detailed(
db: &Database,
today: NaiveDate,
base_currency: &str,
) -> Result<(usize, Vec<GeneratedInfo>), rusqlite::Error> {
let recurring = db.list_recurring(true)?;
let mut count = 0;
let mut details = Vec::new();
let rate_service = ExchangeRateService::new(db);
for rec in &recurring {
let from = match rec.last_generated {
@@ -22,19 +42,43 @@ pub fn generate_missed_transactions(
let dates = generate_dates(from, until, rec.frequency);
// Fetch exchange rate once per recurring (same currency for all dates)
let exchange_rate = if rec.currency.eq_ignore_ascii_case(base_currency) {
1.0
} else {
fetch_rate_sync(&rate_service, &rec.currency, base_currency).unwrap_or(1.0)
};
let desc = rec
.note
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| {
db.get_category(rec.category_id)
.map(|c| c.name)
.unwrap_or_else(|_| "Recurring".to_string())
});
for date in &dates {
let txn = NewTransaction {
amount: rec.amount,
transaction_type: rec.transaction_type,
category_id: rec.category_id,
currency: rec.currency.clone(),
exchange_rate: 1.0,
exchange_rate,
note: rec.note.clone(),
date: *date,
recurring_id: Some(rec.id),
payee: None,
};
db.insert_transaction(&txn)?;
count += 1;
details.push(GeneratedInfo {
description: desc.clone(),
amount: rec.amount,
currency: rec.currency.clone(),
});
}
if let Some(&last) = dates.last() {
@@ -42,7 +86,15 @@ pub fn generate_missed_transactions(
}
}
Ok(count)
Ok((count, details))
}
fn fetch_rate_sync(service: &ExchangeRateService<'_>, from: &str, to: &str) -> Option<f64> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?;
rt.block_on(service.get_rate(from, to)).ok()
}
fn next_date(date: NaiveDate, freq: Frequency) -> NaiveDate {
@@ -55,6 +107,32 @@ fn next_date(date: NaiveDate, freq: Frequency) -> NaiveDate {
}
}
/// Compute the next occurrence date for a recurring transaction from today.
pub fn next_occurrence(rec: &crate::models::RecurringTransaction, from: NaiveDate) -> Option<NaiveDate> {
if !rec.active {
return None;
}
// Start from last_generated + 1 period, or start_date
let mut date = match rec.last_generated {
Some(last) => next_date(last, rec.frequency),
None => rec.start_date,
};
// Advance until we reach today or beyond
while date < from {
date = next_date(date, rec.frequency);
}
// Check end_date
if let Some(end) = rec.end_date {
if date > end {
return None;
}
}
Some(date)
}
fn generate_dates(from: NaiveDate, until: NaiveDate, freq: Frequency) -> Vec<NaiveDate> {
let mut dates = Vec::new();
let mut current = from;
@@ -65,7 +143,7 @@ fn generate_dates(from: NaiveDate, until: NaiveDate, freq: Frequency) -> Vec<Nai
dates
}
fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
pub fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
let total_months = date.month0() + months;
let new_year = date.year() + (total_months / 12) as i32;
let new_month = (total_months % 12) + 1;
@@ -113,13 +191,16 @@ mod tests {
frequency: Frequency::Daily,
start_date: NaiveDate::from_ymd_opt(2026, 2, 24).unwrap(),
end_date: None,
is_bill: false,
reminder_days: 3,
subscription_id: None,
};
let rec_id = db.insert_recurring(&rec).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
db.update_recurring_last_generated(rec_id, NaiveDate::from_ymd_opt(2026, 2, 26).unwrap()).unwrap();
let count = generate_missed_transactions(&db, today).unwrap();
let count = generate_missed_transactions(&db, today, "USD").unwrap();
// Should generate Feb 27, Feb 28, Mar 1 = 3 transactions
assert_eq!(count, 3);
}
@@ -139,13 +220,16 @@ mod tests {
frequency: Frequency::Monthly,
start_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
end_date: None,
is_bill: false,
reminder_days: 3,
subscription_id: None,
};
let rec_id = db.insert_recurring(&rec).unwrap();
db.update_recurring_last_generated(rec_id, NaiveDate::from_ymd_opt(2026, 1, 15).unwrap()).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 3, 20).unwrap();
let count = generate_missed_transactions(&db, today).unwrap();
let count = generate_missed_transactions(&db, today, "USD").unwrap();
// Should generate Feb 15 and Mar 15
assert_eq!(count, 2);
}
@@ -165,11 +249,14 @@ mod tests {
frequency: Frequency::Daily,
start_date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
end_date: Some(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()),
is_bill: false,
reminder_days: 3,
subscription_id: None,
};
db.insert_recurring(&rec).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
let count = generate_missed_transactions(&db, today).unwrap();
let count = generate_missed_transactions(&db, today, "USD").unwrap();
// end_date is Jan 5, generates Jan 1-5 = 5 transactions
assert_eq!(count, 5);
}
@@ -189,11 +276,14 @@ mod tests {
frequency: Frequency::Weekly,
start_date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
end_date: None,
is_bill: false,
reminder_days: 3,
subscription_id: None,
};
db.insert_recurring(&rec).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 2, 22).unwrap();
let count = generate_missed_transactions(&db, today).unwrap();
let count = generate_missed_transactions(&db, today, "USD").unwrap();
// From Feb 1 weekly: Feb 1, 8, 15, 22 = 4
assert_eq!(count, 4);
}

147
outlay-core/src/sankey.rs Normal file
View File

@@ -0,0 +1,147 @@
use crate::models::{SankeyFlow, SankeyLayout, SankeyNode};
/// Compute a Sankey diagram layout.
///
/// `income_sources`: (label, amount, (r, g, b)) for each income category
/// `expense_categories`: (label, amount, (r, g, b)) for each expense category
/// `total_height`: pixel height of the diagram area
pub fn compute_sankey_layout(
income_sources: &[(String, f64, (f64, f64, f64))],
expense_categories: &[(String, f64, (f64, f64, f64))],
total_height: f64,
) -> SankeyLayout {
let total_income: f64 = income_sources.iter().map(|(_, v, _)| v).sum();
let total_expense: f64 = expense_categories.iter().map(|(_, v, _)| v).sum();
let net = total_income - total_expense;
let max_side = total_income.max(total_expense).max(1.0);
let padding = 4.0;
// Layout left (income) nodes
let mut left_nodes = Vec::new();
let mut y = 0.0;
let income_count = income_sources.len().max(1);
let total_padding_left = padding * (income_count.saturating_sub(1)) as f64;
let available_left = total_height - total_padding_left;
for (label, value, color) in income_sources {
let h = (value / max_side) * available_left;
left_nodes.push(SankeyNode {
label: label.clone(),
value: *value,
color: *color,
y,
height: h,
});
y += h + padding;
}
// Layout right (expense) nodes
let mut right_nodes = Vec::new();
y = 0.0;
let expense_count = expense_categories.len().max(1);
let total_padding_right = padding * (expense_count.saturating_sub(1)) as f64;
let available_right = total_height - total_padding_right;
for (label, value, color) in expense_categories {
let h = (value / max_side) * available_right;
right_nodes.push(SankeyNode {
label: label.clone(),
value: *value,
color: *color,
y,
height: h,
});
y += h + padding;
}
// Center node (net/available)
let center_height = (total_income / max_side) * available_left;
let center_y = 0.0;
// Flows from income -> center
let mut flows_in = Vec::new();
let mut from_y_cursor = 0.0;
let mut to_y_cursor = 0.0;
for (i, node) in left_nodes.iter().enumerate() {
let w = (node.value / max_side) * available_left;
flows_in.push(SankeyFlow {
from_idx: i,
to_idx: 0,
value: node.value,
from_y: from_y_cursor,
to_y: to_y_cursor,
width: w,
});
from_y_cursor += w + padding;
to_y_cursor += w;
}
// Flows from center -> expenses
let mut flows_out = Vec::new();
let mut from_y_cursor = 0.0;
let mut to_y_cursor = 0.0;
for (i, node) in right_nodes.iter().enumerate() {
let w = (node.value / max_side) * available_right;
flows_out.push(SankeyFlow {
from_idx: 0,
to_idx: i,
value: node.value,
from_y: from_y_cursor,
to_y: to_y_cursor,
width: w,
});
from_y_cursor += w;
to_y_cursor += w + padding;
}
SankeyLayout {
left_nodes,
right_nodes,
center_y,
center_height,
flows_in,
flows_out,
net,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_layout() {
let income = vec![("Salary".into(), 5000.0, (0.0, 0.8, 0.0))];
let expenses = vec![
("Rent".into(), 2000.0, (0.8, 0.0, 0.0)),
("Food".into(), 1000.0, (0.8, 0.4, 0.0)),
];
let layout = compute_sankey_layout(&income, &expenses, 400.0);
assert_eq!(layout.left_nodes.len(), 1);
assert_eq!(layout.right_nodes.len(), 2);
assert!((layout.net - 2000.0).abs() < 0.01);
assert_eq!(layout.flows_in.len(), 1);
assert_eq!(layout.flows_out.len(), 2);
}
#[test]
fn test_empty_inputs() {
let layout = compute_sankey_layout(&[], &[], 400.0);
assert!(layout.left_nodes.is_empty());
assert!(layout.right_nodes.is_empty());
assert!((layout.net - 0.0).abs() < 0.01);
}
#[test]
fn test_proportional_heights() {
let income = vec![
("Salary".into(), 3000.0, (0.0, 0.8, 0.0)),
("Freelance".into(), 1000.0, (0.0, 0.6, 0.0)),
];
let expenses = vec![("Rent".into(), 2000.0, (0.8, 0.0, 0.0))];
let layout = compute_sankey_layout(&income, &expenses, 400.0);
// Salary should be 3x the height of Freelance
let salary_h = layout.left_nodes[0].height;
let freelance_h = layout.left_nodes[1].height;
assert!((salary_h / freelance_h - 3.0).abs() < 0.1);
}
}

539
outlay-core/src/seed.rs Normal file
View File

@@ -0,0 +1,539 @@
use chrono::{Datelike, Local, NaiveDate};
use rand::Rng;
use rusqlite::params;
use crate::db::Database;
/// Populate the database with realistic demo data spanning ~2 years.
/// Assumes the database already has default categories seeded.
pub fn seed_demo_data(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
let mut rng = rand::thread_rng();
let today = Local::now().date_naive();
let start = NaiveDate::from_ymd_opt(today.year() - 2, today.month(), 1).unwrap();
// -- Settings --
db.set_setting("base_currency", "USD")?;
db.set_setting("theme", "system")?;
// -- Look up category IDs --
let cats: Vec<(i64, String, String)> = db.conn.prepare(
"SELECT id, name, type FROM categories ORDER BY id"
)?.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?.filter_map(|r| r.ok()).collect();
let cat_id = |name: &str| -> i64 {
cats.iter().find(|(_, n, _)| n == name).map(|(id, _, _)| *id).unwrap_or(1)
};
// Expense category IDs
let food_id = cat_id("Food and Dining");
let groceries_id = cat_id("Groceries");
let transport_id = cat_id("Transport");
let housing_id = cat_id("Housing/Rent");
let utilities_id = cat_id("Utilities");
let entertainment_id = cat_id("Entertainment");
let shopping_id = cat_id("Shopping");
let health_id = cat_id("Health");
let education_id = cat_id("Education");
let subscriptions_id = cat_id("Subscriptions");
let personal_id = cat_id("Personal Care");
let gifts_id = cat_id("Gifts");
let travel_id = cat_id("Travel");
// Income category IDs
let salary_id = cat_id("Salary");
let freelance_id = cat_id("Freelance");
let investment_id = cat_id("Investment");
let gift_income_id = cat_id("Gift");
let refund_id = cat_id("Refund");
// Realistic payees and notes per category
let food_payees = ["Chipotle", "Starbucks", "Panda Express", "Subway", "Pizza Hut",
"Local Diner", "Thai Kitchen", "Burger Joint", "Sushi Bar", "Taco Bell"];
let grocery_payees = ["Whole Foods", "Trader Joe's", "Kroger", "Costco", "Aldi",
"Safeway", "Target", "Walmart"];
let transport_notes = ["Gas station", "Bus pass", "Uber ride", "Lyft", "Parking",
"Car wash", "Oil change", "Tire rotation"];
let entertainment_notes = ["Movie tickets", "Netflix", "Concert", "Board game",
"Bowling", "Escape room", "Museum", "Book"];
let shopping_payees = ["Amazon", "Target", "Best Buy", "IKEA", "Home Depot",
"Etsy", "Thrift store"];
let health_notes = ["Pharmacy", "Doctor copay", "Gym membership", "Vitamins",
"Dentist", "Eye exam"];
let personal_notes = ["Haircut", "Toiletries", "Dry cleaning", "Laundry"];
// Helper: random float in range
let rand_amount = |rng: &mut rand::rngs::ThreadRng, low: f64, high: f64| -> f64 {
let val = rng.gen_range(low..high);
(val * 100.0).round() / 100.0
};
let rand_pick = |rng: &mut rand::rngs::ThreadRng, items: &[&str]| -> String {
items[rng.gen_range(0..items.len())].to_string()
};
let insert_txn = |date: NaiveDate, amount: f64, txn_type: &str, cat: i64,
note: Option<&str>, payee: Option<&str>| -> Result<(), Box<dyn std::error::Error>> {
let date_str = date.format("%Y-%m-%d").to_string();
let created = format!("{} 12:00:00", date_str);
db.conn.execute(
"INSERT INTO transactions (amount, type, category_id, currency, exchange_rate, note, date, created_at, payee)
VALUES (?1, ?2, ?3, 'USD', 1.0, ?4, ?5, ?6, ?7)",
params![amount, txn_type, cat, note, date_str, created, payee],
)?;
Ok(())
};
// -- Generate transactions month by month --
let mut current = start;
while current <= today {
let year = current.year();
let month = current.month();
let days_in_month = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1)
}.and_then(|d| d.pred_opt()).map(|d| d.day()).unwrap_or(30);
let month_str = format!("{}-{:02}", year, month);
// Monthly income: salary on the 1st and 15th (biweekly)
let base_salary = 2850.0 + (year - start.year()) as f64 * 150.0;
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 1) {
if d <= today {
insert_txn(d, base_salary, "income", salary_id, Some("Biweekly paycheck"), Some("Acme Corp"))?;
}
}
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 15) {
if d <= today {
insert_txn(d, base_salary, "income", salary_id, Some("Biweekly paycheck"), Some("Acme Corp"))?;
}
}
// Occasional freelance income (30% of months)
if rng.gen_bool(0.3) {
let day = rng.gen_range(5..=25).min(days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 200.0, 1200.0);
insert_txn(d, amt, "income", freelance_id, Some("Web dev project"), Some("Freelance client"))?;
}
}
}
// Investment dividends quarterly (March, June, Sept, Dec)
if matches!(month, 3 | 6 | 9 | 12) {
let day = rng.gen_range(10..=20).min(days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 50.0, 180.0);
insert_txn(d, amt, "income", investment_id, Some("Dividend payment"), Some("Vanguard"))?;
}
}
}
// Occasional refunds
if rng.gen_bool(0.15) {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 10.0, 80.0);
insert_txn(d, amt, "income", refund_id, Some("Return item"), Some("Amazon"))?;
}
}
}
// Birthday/holiday gift income (December, month of user)
if month == 12 {
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 25) {
if d <= today {
let amt = rand_amount(&mut rng, 50.0, 200.0);
insert_txn(d, amt, "income", gift_income_id, Some("Holiday gift"), None)?;
}
}
}
// -- EXPENSES --
// Rent: 1st of every month
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 1) {
if d <= today {
insert_txn(d, 1350.00, "expense", housing_id, Some("Monthly rent"), Some("Pinewood Apartments"))?;
}
}
// Utilities: ~10th of month
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 10.min(days_in_month)) {
if d <= today {
let electric = rand_amount(&mut rng, 60.0, 140.0);
insert_txn(d, electric, "expense", utilities_id, Some("Electric bill"), Some("City Power Co"))?;
let internet = 65.00;
insert_txn(d, internet, "expense", utilities_id, Some("Internet"), Some("Comcast"))?;
}
}
// Phone bill: 5th
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 5.min(days_in_month)) {
if d <= today {
insert_txn(d, 45.00, "expense", subscriptions_id, Some("Phone plan"), Some("Mint Mobile"))?;
}
}
// Streaming subscriptions: 1st
if let Some(d) = NaiveDate::from_ymd_opt(year, month, 1) {
if d <= today {
insert_txn(d, 15.99, "expense", subscriptions_id, Some("Streaming service"), Some("Netflix"))?;
insert_txn(d, 10.99, "expense", subscriptions_id, Some("Music streaming"), Some("Spotify"))?;
}
}
// Groceries: 2-4 trips per month
let grocery_trips = rng.gen_range(2..=4);
for _ in 0..grocery_trips {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 45.0, 160.0);
let payee = rand_pick(&mut rng, &grocery_payees);
insert_txn(d, amt, "expense", groceries_id, Some("Weekly groceries"), Some(&payee))?;
}
}
}
// Food and dining: 4-8 meals out per month
let meals_out = rng.gen_range(4..=8);
for _ in 0..meals_out {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 8.0, 55.0);
let payee = rand_pick(&mut rng, &food_payees);
insert_txn(d, amt, "expense", food_id, None, Some(&payee))?;
}
}
}
// Transport: 2-5 per month
let transport_count = rng.gen_range(2..=5);
for _ in 0..transport_count {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 5.0, 65.0);
let note = rand_pick(&mut rng, &transport_notes);
insert_txn(d, amt, "expense", transport_id, Some(&note), None)?;
}
}
}
// Entertainment: 1-3 per month
let ent_count = rng.gen_range(1..=3);
for _ in 0..ent_count {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 10.0, 70.0);
let note = rand_pick(&mut rng, &entertainment_notes);
insert_txn(d, amt, "expense", entertainment_id, Some(&note), None)?;
}
}
}
// Shopping: 1-3 per month
let shop_count = rng.gen_range(1..=3);
for _ in 0..shop_count {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 15.0, 120.0);
let payee = rand_pick(&mut rng, &shopping_payees);
insert_txn(d, amt, "expense", shopping_id, None, Some(&payee))?;
}
}
}
// Health: 0-2 per month
let health_count = rng.gen_range(0..=2);
for _ in 0..health_count {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 15.0, 120.0);
let note = rand_pick(&mut rng, &health_notes);
insert_txn(d, amt, "expense", health_id, Some(&note), None)?;
}
}
}
// Personal care: 0-2 per month
let personal_count = rng.gen_range(0..=2);
for _ in 0..personal_count {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 12.0, 60.0);
let note = rand_pick(&mut rng, &personal_notes);
insert_txn(d, amt, "expense", personal_id, Some(&note), None)?;
}
}
}
// Education: occasional (20% of months)
if rng.gen_bool(0.2) {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 15.0, 80.0);
insert_txn(d, amt, "expense", education_id, Some("Online course"), Some("Udemy"))?;
}
}
}
// Gifts: mainly November/December, occasionally otherwise
let gift_chance = if matches!(month, 11 | 12) { 0.8 } else { 0.1 };
if rng.gen_bool(gift_chance) {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 20.0, 150.0);
insert_txn(d, amt, "expense", gifts_id, Some("Birthday/holiday gift"), None)?;
}
}
}
// Travel: 1-2 trips per year (spread across a few months)
if rng.gen_bool(0.08) {
for _ in 0..rng.gen_range(2..=4) {
let day = rng.gen_range(1..=days_in_month);
if let Some(d) = NaiveDate::from_ymd_opt(year, month, day) {
if d <= today {
let amt = rand_amount(&mut rng, 50.0, 400.0);
let notes = ["Hotel stay", "Flight", "Restaurant abroad", "Sightseeing"];
let note = rand_pick(&mut rng, &notes);
insert_txn(d, amt, "expense", travel_id, Some(&note), None)?;
}
}
}
}
// -- Budgets for this month --
let budget_items: Vec<(i64, f64)> = vec![
(groceries_id, 500.0),
(food_id, 350.0),
(transport_id, 200.0),
(entertainment_id, 150.0),
(shopping_id, 200.0),
(utilities_id, 250.0),
(subscriptions_id, 80.0),
(health_id, 100.0),
(personal_id, 75.0),
];
for (cat, amt) in &budget_items {
db.conn.execute(
"INSERT OR IGNORE INTO budgets (category_id, amount, month) VALUES (?1, ?2, ?3)",
params![cat, amt, month_str],
)?;
}
// Advance to next month
current = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
};
}
// -- Recurring transactions (plain, non-subscription) --
let two_years_ago = format!("{}-{:02}-01", today.year() - 2, today.month());
let recurring_items: Vec<(f64, &str, i64, &str, &str)> = vec![
(1350.00, "expense", housing_id, "monthly", "Monthly rent"),
(65.00, "expense", utilities_id, "monthly", "Internet"),
];
for (amount, txn_type, cat, freq, note) in &recurring_items {
db.conn.execute(
"INSERT INTO recurring_transactions (amount, type, category_id, currency, note, frequency, start_date, active)
VALUES (?1, ?2, ?3, 'USD', ?4, ?5, ?6, 1)",
params![amount, txn_type, cat, note, freq, two_years_ago],
)?;
}
// -- Linked subscriptions + recurring --
use crate::models::{Frequency, NewRecurringTransaction, TransactionType};
let sub_services: Vec<(&str, f64, &str, &str)> = vec![
("Netflix", 15.99, "tabler-brand-netflix", "#E50914"),
("Spotify", 10.99, "tabler-brand-spotify", "#1DB954"),
("iCloud", 2.99, "tabler-cloud", "#3693F3"),
("GitHub", 4.00, "tabler-brand-github", "#333333"),
("Xbox Game Pass", 16.99, "tabler-brand-xbox", "#107C10"),
];
for (name, amount, _icon, _color) in &sub_services {
// Find the subscription category by name
let sub_cat_id: i64 = db.conn.query_row(
"SELECT id FROM subscription_categories WHERE name = ?1",
params![name],
|row| row.get(0),
).unwrap_or_else(|_| {
// Fallback to "Other" category
db.conn.query_row(
"SELECT id FROM subscription_categories WHERE name = 'Other'",
[],
|row| row.get(0),
).unwrap_or(1)
});
let start = chrono::NaiveDate::parse_from_str(&two_years_ago, "%Y-%m-%d")
.unwrap_or(today);
let new_rec = NewRecurringTransaction {
amount: *amount,
transaction_type: TransactionType::Expense,
category_id: subscriptions_id,
currency: "USD".to_string(),
note: Some(name.to_string()),
frequency: Frequency::Monthly,
start_date: start,
end_date: None,
is_bill: true,
reminder_days: 3,
subscription_id: None,
};
db.insert_linked_recurring_and_subscription(&new_rec, sub_cat_id, name)?;
}
// -- Savings goals --
db.conn.execute(
"INSERT INTO savings_goals (name, target, saved, currency, deadline, color, icon)
VALUES ('Emergency Fund', 10000.0, 6450.0, 'USD', ?1, '#27ae60', 'tabler-shield')",
params![format!("{}-12-31", today.year())],
)?;
db.conn.execute(
"INSERT INTO savings_goals (name, target, saved, currency, deadline, color, icon)
VALUES ('Vacation Fund', 3000.0, 1820.0, 'USD', ?1, '#3498db', 'tabler-plane')",
params![format!("{}-06-30", today.year() + 1)],
)?;
db.conn.execute(
"INSERT INTO savings_goals (name, target, saved, currency, deadline, color, icon)
VALUES ('New Laptop', 1500.0, 950.0, 'USD', ?1, '#9b59b6', 'tabler-device-laptop')",
params![format!("{}-09-01", today.year())],
)?;
// -- Wishlist items --
db.conn.execute(
"INSERT INTO wishlist_items (name, amount, category_id, note, priority)
VALUES ('Noise Cancelling Headphones', 299.99, ?1, 'Sony WH-1000XM5', 1)",
params![shopping_id],
)?;
db.conn.execute(
"INSERT INTO wishlist_items (name, amount, category_id, note, priority)
VALUES ('Ergonomic Keyboard', 179.00, ?1, 'Kinesis Advantage 360', 2)",
params![shopping_id],
)?;
db.conn.execute(
"INSERT INTO wishlist_items (name, amount, category_id, note, priority)
VALUES ('Camping Gear Set', 450.00, ?1, 'Tent + sleeping bag + mat', 3)",
params![travel_id],
)?;
// -- Credit Cards --
db.conn.execute(
"INSERT INTO credit_cards (name, credit_limit, statement_close_day, due_day, min_payment_pct, current_balance, currency, color)
VALUES ('Chase Sapphire', 8000.0, 25, 15, 2.0, 2340.0, 'USD', '#003087')",
[],
)?;
db.conn.execute(
"INSERT INTO credit_cards (name, credit_limit, statement_close_day, due_day, min_payment_pct, current_balance, currency, color)
VALUES ('Amex Gold', 12000.0, 20, 10, 2.0, 890.0, 'USD', '#C4A000')",
[],
)?;
// -- Purchased wishlist items --
db.conn.execute(
"INSERT INTO wishlist_items (name, amount, category_id, note, priority, purchased)
VALUES ('Mechanical Keyboard', 149.99, ?1, 'Cherry MX Brown switches', 2, 1)",
params![shopping_id],
)?;
db.conn.execute(
"INSERT INTO wishlist_items (name, amount, category_id, note, priority, purchased)
VALUES ('Running Shoes', 89.99, ?1, 'Nike Pegasus', 1, 1)",
params![shopping_id],
)?;
// -- Achievements --
let two_years_ago_dt = format!(
"{}-{:02}-15 12:00:00",
today.year() - 2,
today.month()
);
let one_year_ago_dt = format!(
"{}-{:02}-15 12:00:00",
today.year() - 1,
today.month()
);
let six_months_ago_dt = {
let m = if today.month() > 6 { today.month() - 6 } else { today.month() + 6 };
let y = if today.month() > 6 { today.year() } else { today.year() - 1 };
format!("{}-{:02}-15 12:00:00", y, m)
};
db.conn.execute(
"UPDATE achievements SET earned_at = ?1 WHERE name = 'First Transaction'",
params![two_years_ago_dt],
)?;
db.conn.execute(
"UPDATE achievements SET earned_at = ?1 WHERE name = '100 Transactions'",
params![one_year_ago_dt],
)?;
db.conn.execute(
"UPDATE achievements SET earned_at = ?1 WHERE name = 'Month Under Budget'",
params![six_months_ago_dt],
)?;
// -- Transaction Templates --
db.insert_template(
"Morning Coffee",
Some(5.50),
TransactionType::Expense,
food_id,
"USD",
Some("Starbucks"),
Some("Daily coffee"),
None,
)?;
db.insert_template(
"Weekly Groceries",
Some(85.00),
TransactionType::Expense,
groceries_id,
"USD",
Some("Trader Joe's"),
Some("Weekly grocery run"),
None,
)?;
// -- Tags --
db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('essential')", [])?;
db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('splurge')", [])?;
db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('recurring')", [])?;
db.conn.execute("INSERT OR IGNORE INTO tags (name) VALUES ('work-related')", [])?;
// -- Categorization rules --
db.conn.execute(
"INSERT INTO categorization_rules (field, pattern, category_id, priority)
VALUES ('payee', 'Starbucks', ?1, 1)",
params![food_id],
)?;
db.conn.execute(
"INSERT INTO categorization_rules (field, pattern, category_id, priority)
VALUES ('payee', 'Whole Foods', ?1, 1)",
params![groceries_id],
)?;
db.conn.execute(
"INSERT INTO categorization_rules (field, pattern, category_id, priority)
VALUES ('payee', 'Amazon', ?1, 1)",
params![shopping_id],
)?;
Ok(())
}

View File

@@ -5,9 +5,10 @@ edition.workspace = true
[dependencies]
outlay-core = { path = "../outlay-core" }
gtk = { package = "gtk4", version = "0.11" }
gtk = { package = "gtk4", version = "0.11", features = ["v4_10"] }
adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] }
cairo = { package = "cairo-rs", version = "0.22", features = ["png"] }
chrono = "0.4"
gdk = { package = "gdk4", version = "0.11" }
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "line_series", "area_series"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
ksni = { version = "0.3", features = ["tokio"] }

3
outlay-gtk/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
println!("cargo:rustc-link-lib=fontconfig");
}

View File

@@ -0,0 +1,11 @@
[Desktop Entry]
Name=Outlay
Comment=Personal income and expense tracker with budgets, charts, and multi-currency support
Exec=outlay-gtk
Icon=com.outlay.app
Terminal=false
Type=Application
Categories=Office;Finance;GTK;
Keywords=expense;budget;money;finance;income;tracker;currency;receipt;
StartupNotify=true
SingleMainWindow=true

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist gettext-domain="outlay">
<schema id="com.outlay.app" path="/com/outlay/app/">
<key name="window-width" type="i">
<default>900</default>
<summary>Window width</summary>
<description>The width of the main application window.</description>
</key>
<key name="window-height" type="i">
<default>600</default>
<summary>Window height</summary>
<description>The height of the main application window.</description>
</key>
<key name="window-maximized" type="b">
<default>false</default>
<summary>Window maximized</summary>
<description>Whether the main application window is maximized.</description>
</key>
</schema>
</schemalist>

View File

@@ -0,0 +1,196 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.outlay.app</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>CC0-1.0</project_license>
<name>Outlay</name>
<summary>Personal income and expense tracker with budgets, charts, and multi-currency support</summary>
<description>
<p>
Outlay is a native GTK4/libadwaita personal finance application for
tracking income and expenses, managing budgets, and visualizing spending
patterns. It stores everything locally in SQLite with no cloud dependency.
</p>
<p>Key features:</p>
<ul>
<li>Log expenses and income with category tagging and notes</li>
<li>Multi-currency support with live exchange rate conversion</li>
<li>Category-based budgets with threshold alerts and sparkline trends</li>
<li>Recurring transactions with flexible scheduling (daily, weekly, monthly, yearly)</li>
<li>Subscription tracking with pause, resume, and cost overview</li>
<li>Interactive charts: donut breakdown, monthly bars, and smooth net trend lines</li>
<li>Calendar heatmap view of daily spending</li>
<li>Financial forecasting based on historical patterns</li>
<li>Savings goals with progress tracking</li>
<li>Credit card balance and payment tracking</li>
<li>Wishlist with priority ranking and affordability estimates</li>
<li>Spending anomaly detection and monthly insights</li>
<li>Natural language transaction input</li>
<li>Receipt OCR for automatic amount extraction</li>
<li>Export to CSV, JSON, PDF, OFX, and QIF formats</li>
<li>Import from CSV, JSON, OFX, QIF, and PDF bank statements</li>
<li>Automatic and manual backup with restore</li>
<li>System tray with quick-add actions</li>
<li>Bill reminders and budget threshold notifications</li>
<li>Achievement system for building financial habits</li>
<li>Split transactions across multiple categories</li>
<li>Transaction templates for frequent entries</li>
</ul>
</description>
<icon type="stock">com.outlay.app</icon>
<launchable type="desktop-id">com.outlay.app.desktop</launchable>
<developer id="com.outlay">
<name>Outlay Contributors</name>
</developer>
<url type="homepage">https://git.lashman.live/lashman/outlay</url>
<url type="bugtracker">https://git.lashman.live/lashman/outlay/issues</url>
<url type="vcs-browser">https://git.lashman.live/lashman/outlay</url>
<url type="donation">https://ko-fi.com/lashman</url>
<url type="contact">https://git.lashman.live/lashman/outlay/issues</url>
<url type="contribute">https://git.lashman.live/lashman/outlay</url>
<update_contact>lashman@robotbrush.com</update_contact>
<screenshots>
<screenshot type="default">
<caption>Log view with numpad calculator for quick expense entry</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/01.png</image>
</screenshot>
<screenshot>
<caption>Transaction history with search, category filters, and daily totals</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/02.png</image>
</screenshot>
<screenshot>
<caption>Charts with donut category breakdown and monthly income vs expenses</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/03.png</image>
</screenshot>
<screenshot>
<caption>Budget tracking with sparkline trends, spending pace, and insights</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/04.png</image>
</screenshot>
<screenshot>
<caption>Savings goals with progress bars and monthly contribution targets</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/05.png</image>
</screenshot>
<screenshot>
<caption>Cash flow forecast with projected monthly balances</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/06.png</image>
</screenshot>
<screenshot>
<caption>Recurring transactions with next due dates and amounts</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/07.png</image>
</screenshot>
<screenshot>
<caption>Subscription management with monthly and yearly cost overview</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/08.png</image>
</screenshot>
<screenshot>
<caption>Wishlist with wanted items, prices, and purchase tracking</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/09.png</image>
</screenshot>
<screenshot>
<caption>Credit card tracking with balances, utilization, and payment details</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/10.png</image>
</screenshot>
<screenshot>
<caption>Insights with streaks, achievements, and monthly spending recap</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/11.png</image>
</screenshot>
<screenshot>
<caption>Settings for currency, appearance, and budget notifications</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/12.png</image>
</screenshot>
<screenshot>
<caption>Category management, auto-categorization rules, and transaction templates</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/13.png</image>
</screenshot>
<screenshot>
<caption>Export and import in CSV, JSON, QIF, OFX, and PDF formats</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/14.png</image>
</screenshot>
<screenshot>
<caption>Backup and restore with automatic scheduled backups</caption>
<image type="source" width="902" height="1330">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/15.png</image>
</screenshot>
<screenshot>
<caption>Quick Add popup for fast expense logging from the system tray</caption>
<image type="source" width="461" height="650">https://git.lashman.live/lashman/outlay/raw/branch/main/data/screenshots/16.png</image>
</screenshot>
</screenshots>
<branding>
<color type="primary" scheme_preference="light">#62a0ea</color>
<color type="primary" scheme_preference="dark">#1a5fb4</color>
</branding>
<categories>
<category>Office</category>
<category>Finance</category>
<category>GTK</category>
</categories>
<keywords>
<keyword>Expense</keyword>
<keyword>Income</keyword>
<keyword>Budget</keyword>
<keyword>Finance</keyword>
<keyword>Money</keyword>
<keyword>Currency</keyword>
<keyword>Receipt</keyword>
<keyword>Tracker</keyword>
<keyword>Chart</keyword>
<keyword>Subscription</keyword>
</keywords>
<content_rating type="oars-1.1">
<content_attribute id="social-info">mild</content_attribute>
</content_rating>
<requires>
<display_length compare="ge">360</display_length>
</requires>
<recommends>
<control>keyboard</control>
<control>pointing</control>
</recommends>
<supports>
<internet>first-run</internet>
</supports>
<provides>
<binary>outlay-gtk</binary>
</provides>
<translation type="gettext">com.outlay.app</translation>
<releases>
<release version="0.1.0" date="2026-03-03" type="stable">
<description>
<p>Initial release of Outlay with core features:</p>
<ul>
<li>Expense and income logging with multi-currency support</li>
<li>Category-based budgets with alerts and sparkline trends</li>
<li>Recurring transactions and subscription management</li>
<li>Interactive donut, bar, and net trend charts</li>
<li>Calendar heatmap, forecast, goals, and wishlist views</li>
<li>Credit card tracking with payment schedules</li>
<li>Natural language input and receipt OCR</li>
<li>Export to CSV, JSON, PDF, OFX, and QIF</li>
<li>Import from CSV, JSON, OFX, QIF, and PDF statements</li>
<li>Spending anomaly detection and monthly insights</li>
<li>System tray integration with quick-add actions</li>
<li>Automatic backup scheduling and restore</li>
<li>Achievement system for financial habit building</li>
</ul>
</description>
</release>
</releases>
</component>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1 @@
tabler-wallet-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-wallet.svg

View File

@@ -0,0 +1 @@
tabler-calendar-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-calendar.svg

View File

@@ -0,0 +1 @@
tabler-chart-pie-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-chart-pie.svg

View File

@@ -0,0 +1 @@
tabler-credit-card-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-credit-card.svg

View File

@@ -0,0 +1 @@
tabler-trash-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-trash.svg

View File

@@ -0,0 +1 @@
tabler-download-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-download.svg

View File

@@ -0,0 +1 @@
tabler-trending-up-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-trending-up.svg

View File

@@ -0,0 +1 @@
tabler-target-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-target.svg

View File

@@ -0,0 +1 @@
tabler-history-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-history.svg

View File

@@ -0,0 +1 @@
tabler-upload-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-upload.svg

View File

@@ -0,0 +1 @@
tabler-bulb-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-bulb.svg

View File

@@ -0,0 +1 @@
tabler-receipt-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-receipt.svg

View File

@@ -0,0 +1 @@
tabler-chevron-right-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-chevron-right.svg

View File

@@ -0,0 +1 @@
tabler-calculator-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-calculator.svg

View File

@@ -0,0 +1 @@
tabler-chevron-left-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-chevron-left.svg

View File

@@ -0,0 +1 @@
tabler-repeat-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-repeat.svg

View File

@@ -0,0 +1 @@
tabler-settings-symbolic.svg

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