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
This commit is contained in:
2026-03-03 21:18:37 +02:00
parent 773dae4684
commit 10a76e3003
10102 changed files with 108019 additions and 1335 deletions

617
Cargo.lock generated
View File

@@ -8,6 +8,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "aes" name = "aes"
version = "0.8.4" version = "0.8.4"
@@ -46,6 +55,40 @@ dependencies = [
"derive_arbitrary", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -92,12 +135,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
@@ -130,12 +167,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@@ -173,7 +204,7 @@ version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags",
"cairo-sys-rs", "cairo-sys-rs",
"glib", "glib",
"libc", "libc",
@@ -263,12 +294,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -279,6 +304,15 @@ dependencies = [
"memchr", "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]] [[package]]
name = "const_fn" name = "const_fn"
version = "0.4.11" version = "0.4.11"
@@ -537,12 +571,79 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 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]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -556,13 +657,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fdeflate" name = "fastrand"
version = "0.3.7" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "field-offset" name = "field-offset"
@@ -649,6 +747,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 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]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.32" version = "0.3.32"
@@ -759,7 +870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1c422344482708cb32db843cf3f55f27918cd24fec7b505bde895a1e8702c34" checksum = "a1c422344482708cb32db843cf3f55f27918cd24fec7b505bde895a1e8702c34"
dependencies = [ dependencies = [
"derive_more", "derive_more",
"lopdf", "lopdf 0.26.0",
"printpdf", "printpdf",
"rusttype", "rusttype",
] ]
@@ -827,7 +938,7 @@ version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16877c6e619447e0bcb6de326a42a8bd02b36328cfeeda210135425e576efa3d" checksum = "16877c6e619447e0bcb6de326a42a8bd02b36328cfeeda210135425e576efa3d"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor", "futures-executor",
@@ -1024,6 +1135,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.12.1" version = "0.12.1"
@@ -1261,20 +1378,6 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@@ -1354,12 +1457,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.91" version = "0.3.91"
@@ -1370,6 +1467,19 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "libadwaita" name = "libadwaita"
version = "0.9.1" version = "0.9.1"
@@ -1424,6 +1534,12 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
@@ -1450,10 +1566,28 @@ dependencies = [
"linked-hash-map", "linked-hash-map",
"log", "log",
"lzw", "lzw",
"pom", "pom 3.4.0",
"time 0.2.27", "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]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
@@ -1487,6 +1621,16 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084" 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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@@ -1508,6 +1652,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -1529,6 +1679,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.0" version = "0.2.0"
@@ -1565,6 +1725,16 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "outlay-core" name = "outlay-core"
version = "0.1.0" version = "0.1.0"
@@ -1572,6 +1742,8 @@ dependencies = [
"chrono", "chrono",
"csv", "csv",
"genpdf", "genpdf",
"pdf-extract",
"rand 0.8.5",
"reqwest", "reqwest",
"rusqlite", "rusqlite",
"serde", "serde",
@@ -1585,12 +1757,13 @@ dependencies = [
name = "outlay-gtk" name = "outlay-gtk"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cairo-rs",
"chrono", "chrono",
"gdk4", "gdk4",
"gtk4", "gtk4",
"ksni",
"libadwaita", "libadwaita",
"outlay-core", "outlay-core",
"plotters",
"tokio", "tokio",
] ]
@@ -1618,6 +1791,18 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "pbkdf2" name = "pbkdf2"
version = "0.12.2" version = "0.12.2"
@@ -1628,6 +1813,21 @@ dependencies = [
"hmac", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -1653,46 +1853,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]] [[package]]
name = "plotters" name = "pom"
version = "0.3.7" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6"
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",
]
[[package]] [[package]]
name = "pom" name = "pom"
@@ -1703,6 +1867,12 @@ dependencies = [
"bstr", "bstr",
] ]
[[package]]
name = "postscript"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -1734,7 +1904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2472a184bcb128d0e3db65b59ebd11d010259a5e14fd9d048cba8f2c9302d4" checksum = "1a2472a184bcb128d0e3db65b59ebd11d010259a5e14fd9d048cba8f2c9302d4"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"lopdf", "lopdf 0.26.0",
"rusttype", "rusttype",
"time 0.2.27", "time 0.2.27",
] ]
@@ -1793,7 +1963,7 @@ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
"lru-slab", "lru-slab",
"rand", "rand 0.9.2",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
@@ -1834,14 +2004,35 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 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]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha 0.9.0",
"rand_core", "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]] [[package]]
@@ -1851,7 +2042,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
@@ -1863,6 +2063,12 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rangemap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@@ -1939,7 +2145,7 @@ version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags",
"fallible-iterator", "fallible-iterator",
"fallible-streaming-iterator", "fallible-streaming-iterator",
"hashlink", "hashlink",
@@ -1972,6 +2178,19 @@ dependencies = [
"semver 1.0.27", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.37" version = "0.23.37"
@@ -2094,7 +2313,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@@ -2175,6 +2394,17 @@ dependencies = [
"zmij", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.0.4" version = "1.0.4"
@@ -2216,6 +2446,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 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]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.8" version = "0.3.8"
@@ -2383,7 +2623,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"system-configuration-sys", "system-configuration-sys",
] ]
@@ -2417,6 +2657,19 @@ version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" 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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -2467,7 +2720,7 @@ dependencies = [
"libc", "libc",
"standback", "standback",
"stdweb", "stdweb",
"time-macros", "time-macros 0.1.1",
"version_check", "version_check",
"winapi", "winapi",
] ]
@@ -2479,10 +2732,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa 1.0.17",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
"time-macros 0.2.27",
] ]
[[package]] [[package]]
@@ -2501,6 +2756,16 @@ dependencies = [
"time-macros-impl", "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]] [[package]]
name = "time-macros-impl" name = "time-macros-impl"
version = "0.1.2" version = "0.1.2"
@@ -2549,8 +2814,10 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"tracing",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -2660,7 +2927,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@@ -2691,9 +2958,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "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]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -2709,18 +2988,47 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 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]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 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]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -2745,6 +3053,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 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]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@@ -2885,6 +3204,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -3261,6 +3586,62 @@ dependencies = [
"synstructure", "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]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.40" version = "0.8.40"
@@ -3430,3 +3811,43 @@ dependencies = [
"cc", "cc",
"pkg-config", "pkg-config",
] ]
[[package]]
name = "zvariant"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"winnow",
]

View File

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

View File

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

View File

@@ -182,6 +182,7 @@ mod tests {
note: Some("Test transaction".to_string()), note: Some("Test transaction".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}; };
db.insert_transaction(&txn).unwrap(); db.insert_transaction(&txn).unwrap();

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@ pub fn export_transactions_csv<W: Write>(
let transactions = db.list_all_transactions(from, to)?; let transactions = db.list_all_transactions(from, to)?;
let mut wtr = Writer::from_writer(writer); 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 { for txn in &transactions {
let cat_name = db let cat_name = db
@@ -55,6 +55,7 @@ pub fn export_transactions_csv<W: Write>(
txn.currency.clone(), txn.currency.clone(),
format!("{:.4}", txn.exchange_rate), format!("{:.4}", txn.exchange_rate),
txn.note.clone().unwrap_or_default(), txn.note.clone().unwrap_or_default(),
txn.payee.clone().unwrap_or_default(),
])?; ])?;
} }
@@ -86,6 +87,7 @@ mod tests {
note: Some("Lunch".to_string()), note: Some("Lunch".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}; };
db.insert_transaction(&txn).unwrap(); db.insert_transaction(&txn).unwrap();
@@ -96,7 +98,7 @@ mod tests {
let output = String::from_utf8(buf).unwrap(); let output = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = output.trim().lines().collect(); let lines: Vec<&str> = output.trim().lines().collect();
assert_eq!(lines.len(), 2); 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("2026-03-01"));
assert!(lines[1].contains("expense")); assert!(lines[1].contains("expense"));
assert!(lines[1].contains("42.50")); assert!(lines[1].contains("42.50"));
@@ -131,6 +133,7 @@ mod tests {
note: None, note: None,
date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(), date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}; };
db.insert_transaction(&txn).unwrap(); db.insert_transaction(&txn).unwrap();
} }
@@ -162,6 +165,7 @@ mod tests {
note: None, note: None,
date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}; };
let txn2 = NewTransaction { let txn2 = NewTransaction {
amount: 1000.0, amount: 1000.0,
@@ -172,6 +176,7 @@ mod tests {
note: Some("Salary".to_string()), note: Some("Salary".to_string()),
date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}; };
db.insert_transaction(&txn1).unwrap(); db.insert_transaction(&txn1).unwrap();
db.insert_transaction(&txn2).unwrap(); db.insert_transaction(&txn2).unwrap();

View File

@@ -3,7 +3,7 @@ use crate::models::{Budget, Category, RecurringTransaction, Transaction};
use serde::Serialize; use serde::Serialize;
use std::io::Write; use std::io::Write;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, serde::Deserialize)]
pub struct ExportData { pub struct ExportData {
pub transactions: Vec<Transaction>, pub transactions: Vec<Transaction>,
pub categories: Vec<Category>, pub categories: Vec<Category>,
@@ -87,6 +87,7 @@ mod tests {
note: Some("Test".to_string()), note: Some("Test".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}; };
db.insert_transaction(&txn).unwrap(); db.insert_transaction(&txn).unwrap();
@@ -136,6 +137,7 @@ mod tests {
note: Some("Freelance".to_string()), note: Some("Freelance".to_string()),
date: NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(), date: NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}; };
db.insert_transaction(&txn).unwrap(); db.insert_transaction(&txn).unwrap();

View File

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

View File

@@ -312,6 +312,7 @@ mod tests {
note: Some("Groceries".to_string()), note: Some("Groceries".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}, },
NewTransaction { NewTransaction {
amount: 12.50, amount: 12.50,
@@ -322,6 +323,7 @@ mod tests {
note: Some("Coffee".to_string()), note: Some("Coffee".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 5).unwrap(), date: NaiveDate::from_ymd_opt(2026, 3, 5).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}, },
NewTransaction { NewTransaction {
amount: 3000.0, amount: 3000.0,
@@ -332,6 +334,7 @@ mod tests {
note: Some("Salary".to_string()), note: Some("Salary".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None, recurring_id: None,
payee: None,
}, },
]; ];
@@ -340,7 +343,7 @@ mod tests {
} }
// Set a budget // 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"); let tmp = std::env::temp_dir().join("outlay_test_report.pdf");
generate_monthly_report(&db, 2026, 3, "USD", &tmp).unwrap(); generate_monthly_report(&db, 2026, 3, "USD", &tmp).unwrap();

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,19 @@ pub mod db;
pub mod exchange; pub mod exchange;
pub mod export_csv; pub mod export_csv;
pub mod export_json; pub mod export_json;
pub mod export_ofx;
pub mod export_pdf; 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 backup;
pub mod recurring; pub mod recurring;
pub mod expr;
pub mod ocr;
pub mod notifications;
pub mod nlp;
pub mod sankey;
pub mod import_pdf;
pub mod seed;

View File

@@ -78,6 +78,7 @@ pub struct Category {
pub transaction_type: TransactionType, pub transaction_type: TransactionType,
pub is_default: bool, pub is_default: bool,
pub sort_order: i32, pub sort_order: i32,
pub parent_id: Option<i64>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -87,6 +88,7 @@ pub struct NewCategory {
pub color: Option<String>, pub color: Option<String>,
pub transaction_type: TransactionType, pub transaction_type: TransactionType,
pub sort_order: i32, pub sort_order: i32,
pub parent_id: Option<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -101,6 +103,7 @@ pub struct Transaction {
pub date: NaiveDate, pub date: NaiveDate,
pub created_at: String, pub created_at: String,
pub recurring_id: Option<i64>, pub recurring_id: Option<i64>,
pub payee: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -113,6 +116,7 @@ pub struct NewTransaction {
pub note: Option<String>, pub note: Option<String>,
pub date: NaiveDate, pub date: NaiveDate,
pub recurring_id: Option<i64>, pub recurring_id: Option<i64>,
pub payee: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -121,6 +125,7 @@ pub struct Budget {
pub category_id: i64, pub category_id: i64,
pub amount: f64, pub amount: f64,
pub month: String, pub month: String,
pub rollover: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -136,6 +141,10 @@ pub struct RecurringTransaction {
pub end_date: Option<NaiveDate>, pub end_date: Option<NaiveDate>,
pub last_generated: Option<NaiveDate>, pub last_generated: Option<NaiveDate>,
pub active: bool, pub active: bool,
pub resume_date: Option<NaiveDate>,
pub is_bill: bool,
pub reminder_days: i32,
pub subscription_id: Option<i64>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -148,6 +157,9 @@ pub struct NewRecurringTransaction {
pub frequency: Frequency, pub frequency: Frequency,
pub start_date: NaiveDate, pub start_date: NaiveDate,
pub end_date: Option<NaiveDate>, pub end_date: Option<NaiveDate>,
pub is_bill: bool,
pub reminder_days: i32,
pub subscription_id: Option<i64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -158,6 +170,240 @@ pub struct ExchangeRate {
pub fetched_at: String, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

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

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

View File

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

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

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

View File

@@ -1,13 +1,33 @@
use crate::db::Database; use crate::db::Database;
use crate::exchange::ExchangeRateService;
use crate::models::{Frequency, NewTransaction}; use crate::models::{Frequency, NewTransaction};
use chrono::{Datelike, Days, NaiveDate}; 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( pub fn generate_missed_transactions(
db: &Database, db: &Database,
today: NaiveDate, today: NaiveDate,
base_currency: &str,
) -> Result<usize, rusqlite::Error> { ) -> 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 recurring = db.list_recurring(true)?;
let mut count = 0; let mut count = 0;
let mut details = Vec::new();
let rate_service = ExchangeRateService::new(db);
for rec in &recurring { for rec in &recurring {
let from = match rec.last_generated { let from = match rec.last_generated {
@@ -22,19 +42,43 @@ pub fn generate_missed_transactions(
let dates = generate_dates(from, until, rec.frequency); 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 { for date in &dates {
let txn = NewTransaction { let txn = NewTransaction {
amount: rec.amount, amount: rec.amount,
transaction_type: rec.transaction_type, transaction_type: rec.transaction_type,
category_id: rec.category_id, category_id: rec.category_id,
currency: rec.currency.clone(), currency: rec.currency.clone(),
exchange_rate: 1.0, exchange_rate,
note: rec.note.clone(), note: rec.note.clone(),
date: *date, date: *date,
recurring_id: Some(rec.id), recurring_id: Some(rec.id),
payee: None,
}; };
db.insert_transaction(&txn)?; db.insert_transaction(&txn)?;
count += 1; count += 1;
details.push(GeneratedInfo {
description: desc.clone(),
amount: rec.amount,
currency: rec.currency.clone(),
});
} }
if let Some(&last) = dates.last() { 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 { 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> { fn generate_dates(from: NaiveDate, until: NaiveDate, freq: Frequency) -> Vec<NaiveDate> {
let mut dates = Vec::new(); let mut dates = Vec::new();
let mut current = from; let mut current = from;
@@ -65,7 +143,7 @@ fn generate_dates(from: NaiveDate, until: NaiveDate, freq: Frequency) -> Vec<Nai
dates 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 total_months = date.month0() + months;
let new_year = date.year() + (total_months / 12) as i32; let new_year = date.year() + (total_months / 12) as i32;
let new_month = (total_months % 12) + 1; let new_month = (total_months % 12) + 1;
@@ -113,13 +191,16 @@ mod tests {
frequency: Frequency::Daily, frequency: Frequency::Daily,
start_date: NaiveDate::from_ymd_opt(2026, 2, 24).unwrap(), start_date: NaiveDate::from_ymd_opt(2026, 2, 24).unwrap(),
end_date: None, end_date: None,
is_bill: false,
reminder_days: 3,
subscription_id: None,
}; };
let rec_id = db.insert_recurring(&rec).unwrap(); let rec_id = db.insert_recurring(&rec).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 3, 1).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(); 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 // Should generate Feb 27, Feb 28, Mar 1 = 3 transactions
assert_eq!(count, 3); assert_eq!(count, 3);
} }
@@ -139,13 +220,16 @@ mod tests {
frequency: Frequency::Monthly, frequency: Frequency::Monthly,
start_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), start_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
end_date: None, end_date: None,
is_bill: false,
reminder_days: 3,
subscription_id: None,
}; };
let rec_id = db.insert_recurring(&rec).unwrap(); let rec_id = db.insert_recurring(&rec).unwrap();
db.update_recurring_last_generated(rec_id, NaiveDate::from_ymd_opt(2026, 1, 15).unwrap()).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 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 // Should generate Feb 15 and Mar 15
assert_eq!(count, 2); assert_eq!(count, 2);
} }
@@ -165,11 +249,14 @@ mod tests {
frequency: Frequency::Daily, frequency: Frequency::Daily,
start_date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), start_date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
end_date: Some(NaiveDate::from_ymd_opt(2026, 1, 5).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(); db.insert_recurring(&rec).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 3, 1).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 // end_date is Jan 5, generates Jan 1-5 = 5 transactions
assert_eq!(count, 5); assert_eq!(count, 5);
} }
@@ -189,11 +276,14 @@ mod tests {
frequency: Frequency::Weekly, frequency: Frequency::Weekly,
start_date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), start_date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
end_date: None, end_date: None,
is_bill: false,
reminder_days: 3,
subscription_id: None,
}; };
db.insert_recurring(&rec).unwrap(); db.insert_recurring(&rec).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 2, 22).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 // From Feb 1 weekly: Feb 1, 8, 15, 22 = 4
assert_eq!(count, 4); assert_eq!(count, 4);
} }

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

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

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

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

View File

@@ -7,7 +7,8 @@ edition.workspace = true
outlay-core = { path = "../outlay-core" } outlay-core = { path = "../outlay-core" }
gtk = { package = "gtk4", version = "0.11", features = ["v4_10"] } gtk = { package = "gtk4", version = "0.11", features = ["v4_10"] }
adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] } adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] }
cairo = { package = "cairo-rs", version = "0.22", features = ["png"] }
chrono = "0.4" chrono = "0.4"
gdk = { package = "gdk4", version = "0.11" } 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"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
ksni = { version = "0.3", features = ["tokio"] }

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
tabler-shopping-cart-symbolic.svg

View File

@@ -0,0 +1 @@
tabler-shopping-cart.svg

View File

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

View File

@@ -0,0 +1 @@
tabler-a-b-2-symbolic.svg

View File

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

View File

@@ -0,0 +1 @@
tabler-a-b-off-symbolic.svg

View File

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

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