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
617
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[workspace]
|
||||
members = ["outlay-core", "outlay-gtk"]
|
||||
resolver = "2"
|
||||
default-members = ["outlay-gtk"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -182,6 +182,7 @@ mod tests {
|
||||
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();
|
||||
|
||||
|
||||
33
outlay-core/src/bin/seed_demo.rs
Normal 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(".")
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub fn export_transactions_csv<W: Write>(
|
||||
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"])?;
|
||||
wtr.write_record(["Date", "Type", "Category", "Amount", "Currency", "Exchange Rate", "Note", "Payee"])?;
|
||||
|
||||
for txn in &transactions {
|
||||
let cat_name = db
|
||||
@@ -55,6 +55,7 @@ pub fn export_transactions_csv<W: Write>(
|
||||
txn.currency.clone(),
|
||||
format!("{:.4}", txn.exchange_rate),
|
||||
txn.note.clone().unwrap_or_default(),
|
||||
txn.payee.clone().unwrap_or_default(),
|
||||
])?;
|
||||
}
|
||||
|
||||
@@ -86,6 +87,7 @@ mod tests {
|
||||
note: Some("Lunch".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
@@ -96,7 +98,7 @@ mod tests {
|
||||
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");
|
||||
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"));
|
||||
@@ -131,6 +133,7 @@ mod tests {
|
||||
note: None,
|
||||
date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
}
|
||||
@@ -162,6 +165,7 @@ mod tests {
|
||||
note: None,
|
||||
date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
let txn2 = NewTransaction {
|
||||
amount: 1000.0,
|
||||
@@ -172,6 +176,7 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::models::{Budget, Category, RecurringTransaction, Transaction};
|
||||
use serde::Serialize;
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, serde::Deserialize)]
|
||||
pub struct ExportData {
|
||||
pub transactions: Vec<Transaction>,
|
||||
pub categories: Vec<Category>,
|
||||
@@ -87,6 +87,7 @@ mod tests {
|
||||
note: Some("Test".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
@@ -136,6 +137,7 @@ mod tests {
|
||||
note: Some("Freelance".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
db.insert_transaction(&txn).unwrap();
|
||||
|
||||
|
||||
351
outlay-core/src/export_ofx.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// 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&B Store"));
|
||||
assert!(output.contains("Tom & Jerry's <shop>"));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,7 @@ mod tests {
|
||||
note: Some("Groceries".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
},
|
||||
NewTransaction {
|
||||
amount: 12.50,
|
||||
@@ -322,6 +323,7 @@ mod tests {
|
||||
note: Some("Coffee".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 5).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
},
|
||||
NewTransaction {
|
||||
amount: 3000.0,
|
||||
@@ -332,6 +334,7 @@ mod tests {
|
||||
note: Some("Salary".to_string()),
|
||||
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -340,7 +343,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Set a budget
|
||||
db.set_budget(cats[0].id, "2026-03", 200.0).unwrap();
|
||||
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();
|
||||
|
||||
264
outlay-core/src/export_qif.rs
Normal 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
@@ -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));
|
||||
}
|
||||
}
|
||||
75
outlay-core/src/import_csv.rs
Normal 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)
|
||||
}
|
||||
59
outlay-core/src/import_json.rs
Normal 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)
|
||||
}
|
||||
298
outlay-core/src/import_ofx.rs
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
#[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&B Store\n<MEMO>Tom & Jerry'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);
|
||||
}
|
||||
}
|
||||
187
outlay-core/src/import_pdf.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
333
outlay-core/src/import_qif.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
76
outlay-core/src/notifications.rs
Normal 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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(¬e), 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(¬e), 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(¬e), 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(¬e), 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, ¬es);
|
||||
insert_txn(d, amt, "expense", travel_id, Some(¬e), 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(())
|
||||
}
|
||||
@@ -7,7 +7,8 @@ edition.workspace = true
|
||||
outlay-core = { path = "../outlay-core" }
|
||||
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
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("cargo:rustc-link-lib=fontconfig");
|
||||
}
|
||||
BIN
outlay-gtk/data/fonts/JetBrainsMono-Bold.ttf
Normal file
BIN
outlay-gtk/data/fonts/JetBrainsMono-Regular.ttf
Normal file
BIN
outlay-gtk/data/fonts/JetBrainsMono-SemiBold.ttf
Normal file
BIN
outlay-gtk/data/fonts/SpaceGrotesk-Bold.ttf
Normal file
BIN
outlay-gtk/data/fonts/SpaceGrotesk-Light.ttf
Normal file
BIN
outlay-gtk/data/fonts/SpaceGrotesk-Medium.ttf
Normal file
BIN
outlay-gtk/data/fonts/SpaceGrotesk-Regular.ttf
Normal file
BIN
outlay-gtk/data/fonts/SpaceGrotesk-Variable.ttf
Normal file
69
outlay-gtk/data/icons/app-icon-preview.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { background: transparent; width: 100%; height: 100%; }
|
||||
body {
|
||||
background-image:
|
||||
linear-gradient(45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #ccc 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.icon-wrapper {
|
||||
padding: 50px;
|
||||
filter: drop-shadow(0 8px 14px rgba(26, 95, 180, 0.45));
|
||||
}
|
||||
.icon-container {
|
||||
width: 512px;
|
||||
height: 512px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* GNOME-style rounded square - ~22% radius */
|
||||
border-radius: 22%;
|
||||
/* GNOME blue gradient - wider range for visible depth */
|
||||
background: linear-gradient(180deg, #62a0ea 0%, #1a5fb4 55%, #144a8a 100%);
|
||||
}
|
||||
/* Sharp top highlight + bottom shadow edges */
|
||||
.icon-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 22%;
|
||||
border-top: 4px solid rgba(255,255,255,0.2);
|
||||
border-bottom: 40px solid rgba(0,0,0,0.12);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.icon {
|
||||
font-size: 286px;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2));
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-container" id="icon-target">
|
||||
<i class="icon fa-solid fa-receipt"></i>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
outlay-gtk/data/icons/com.outlay.Outlay-128.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-16.png
Normal file
|
After Width: | Height: | Size: 746 B |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-24.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-256.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-48.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-512-shadow.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-512.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-64.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay-96.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
outlay-gtk/data/icons/com.outlay.Outlay.ico
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
outlay-gtk/data/icons/hicolor/128x128/apps/com.outlay.Outlay.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
outlay-gtk/data/icons/hicolor/16x16/apps/com.outlay.Outlay.png
Normal file
|
After Width: | Height: | Size: 746 B |
BIN
outlay-gtk/data/icons/hicolor/24x24/apps/com.outlay.Outlay.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
outlay-gtk/data/icons/hicolor/256x256/apps/com.outlay.Outlay.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
outlay-gtk/data/icons/hicolor/32x32/apps/com.outlay.Outlay.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
outlay-gtk/data/icons/hicolor/48x48/apps/com.outlay.Outlay.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
outlay-gtk/data/icons/hicolor/512x512/apps/com.outlay.Outlay.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
outlay-gtk/data/icons/hicolor/64x64/apps/com.outlay.Outlay.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
outlay-gtk/data/icons/hicolor/96x96/apps/com.outlay.Outlay.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1 @@
|
||||
tabler-wallet-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-wallet.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-calendar-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-calendar.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-chart-pie-symbolic.svg
|
||||
1
outlay-gtk/data/icons/hicolor/scalable/actions/outlay-charts.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
tabler-chart-pie.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-credit-card-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-credit-card.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-trash-symbolic.svg
|
||||
1
outlay-gtk/data/icons/hicolor/scalable/actions/outlay-delete.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
tabler-trash.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-download-symbolic.svg
|
||||
1
outlay-gtk/data/icons/hicolor/scalable/actions/outlay-export.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
tabler-download.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-trending-up-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-trending-up.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-target-symbolic.svg
|
||||
1
outlay-gtk/data/icons/hicolor/scalable/actions/outlay-goals.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
tabler-target.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-history-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-history.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-upload-symbolic.svg
|
||||
1
outlay-gtk/data/icons/hicolor/scalable/actions/outlay-import.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
tabler-upload.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-bulb-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-bulb.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-receipt-symbolic.svg
|
||||
1
outlay-gtk/data/icons/hicolor/scalable/actions/outlay-log.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
tabler-receipt.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-chevron-right-symbolic.svg
|
||||
1
outlay-gtk/data/icons/hicolor/scalable/actions/outlay-next.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
tabler-chevron-right.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-calculator-symbolic.svg
|
||||
1
outlay-gtk/data/icons/hicolor/scalable/actions/outlay-numpad.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
tabler-calculator.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-chevron-left-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-chevron-left.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-repeat-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-repeat.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-settings-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-settings.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-credit-card-pay-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-credit-card-pay.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-shopping-cart-symbolic.svg
|
||||
@@ -0,0 +1 @@
|
||||
tabler-shopping-cart.svg
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#222222"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path fill="none" d="M16 21h3c.81 0 1.48 -.67 1.48 -1.48l.02 -.02c0 -.82 -.69 -1.5 -1.5 -1.5h-3v3" />
|
||||
<path fill="none" d="M16 15h2.5c.84 -.01 1.5 .66 1.5 1.5s-.66 1.5 -1.5 1.5h-2.5v-3" />
|
||||
<path fill="none" d="M4 9v-4c0 -1.036 .895 -2 2 -2s2 .964 2 2v4" />
|
||||
<path fill="none" d="M2.99 11.98a9 9 0 0 0 9 9m9 -9a9 9 0 0 0 -9 -9" />
|
||||
<path fill="none" d="M8 7h-4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 575 B |
1
outlay-gtk/data/icons/hicolor/scalable/actions/tabler-a-b-2.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
tabler-a-b-2-symbolic.svg
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#222222"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path fill="none" d="M3 16v-5.5a2.5 2.5 0 0 1 5 0v5.5m0 -4h-5" />
|
||||
<path fill="none" d="M12 12v6" />
|
||||
<path fill="none" d="M12 6v2" />
|
||||
<path fill="none" d="M16 8h3a2 2 0 1 1 0 4h-3m3 0a2 2 0 0 1 .83 3.82m-3.83 -3.82v-4" />
|
||||
<path fill="none" d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 470 B |
@@ -0,0 +1 @@
|
||||
tabler-a-b-off-symbolic.svg
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#222222"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path fill="none" d="M3 16v-5.5a2.5 2.5 0 0 1 5 0v5.5m0 -4h-5" />
|
||||
<path fill="none" d="M12 6l0 12" />
|
||||
<path fill="none" d="M16 16v-8h3a2 2 0 0 1 0 4h-3m3 0a2 2 0 0 1 0 4h-3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 386 B |