diff --git a/libdrcr/.gitignore b/libdrcr/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/libdrcr/.gitignore @@ -0,0 +1 @@ +/target diff --git a/libdrcr/Cargo.lock b/libdrcr/Cargo.lock new file mode 100644 index 0000000..f81a1ac --- /dev/null +++ b/libdrcr/Cargo.lock @@ -0,0 +1,1987 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast-rs" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf" + +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "dyn-eq" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388" + +[[package]] +name = "dyn-hash" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "hashbrown" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libdrcr" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "downcast-rs", + "dyn-clone", + "dyn-eq", + "dyn-hash", + "indexmap", + "serde", + "serde_json", + "sqlx", + "tokio", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.52.0", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "whoami" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +dependencies = [ + "redox_syscall", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ec44dc15085cea82cf9c78f85a9114c463a369786585ad2882d1ff0b0acf40" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b895b5356fc36103d0f64dd1e94dfa7ac5633f1c9dd6e80fe9ec4adef69e09d" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a7ab927b2637c19b3dbe0965e75d8f2d30bdd697a1516191cad2ec4df8fb28a" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/libdrcr/Cargo.toml b/libdrcr/Cargo.toml new file mode 100644 index 0000000..1546094 --- /dev/null +++ b/libdrcr/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "libdrcr" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1.88" +chrono = "0.4.41" +downcast-rs = "2.0.1" +dyn-clone = "1.0.19" +dyn-eq = "0.1.3" +dyn-hash = "0.2.2" +indexmap = "2.9.0" +serde = "1.0.219" +serde_json = "1.0.140" +sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite" ] } +tokio = { version = "1.45.0", features = ["full"] } diff --git a/libdrcr/rustfmt.toml b/libdrcr/rustfmt.toml new file mode 100644 index 0000000..218e203 --- /dev/null +++ b/libdrcr/rustfmt.toml @@ -0,0 +1 @@ +hard_tabs = true diff --git a/libdrcr/src/account_config.rs b/libdrcr/src/account_config.rs new file mode 100644 index 0000000..c17e5d6 --- /dev/null +++ b/libdrcr/src/account_config.rs @@ -0,0 +1,43 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use std::collections::HashMap; + +pub struct AccountConfiguration { + pub id: Option, + pub account: String, + pub kind: String, + pub data: Option, +} + +/// Convert [`Vec`] into a [HashMap] mapping account names to account kinds +pub fn kinds_for_account( + account_configurations: Vec, +) -> HashMap> { + let mut result = HashMap::new(); + + for account_configuration in account_configurations { + // Record the account kind + result + .entry(account_configuration.account) + .or_insert_with(|| Vec::new()) + .push(account_configuration.kind); + } + + result +} diff --git a/libdrcr/src/db.rs b/libdrcr/src/db.rs new file mode 100644 index 0000000..bfee6ec --- /dev/null +++ b/libdrcr/src/db.rs @@ -0,0 +1,266 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use std::collections::HashMap; + +use chrono::{NaiveDate, NaiveDateTime}; +use sqlx::sqlite::SqliteRow; +use sqlx::{Connection, Row, SqliteConnection}; + +use crate::account_config::AccountConfiguration; +use crate::model::assertions::BalanceAssertion; +use crate::model::statements::StatementLine; +use crate::model::transaction::{Posting, Transaction, TransactionWithPostings}; +use crate::{util::format_date, QuantityInt}; + +pub struct DbConnection { + url: String, + metadata: DbMetadata, +} + +impl DbConnection { + pub async fn new(url: &str) -> Self { + let mut connection = SqliteConnection::connect(url).await.expect("SQL error"); + let metadata = DbMetadata::from_database(&mut connection).await; + + Self { + url: url.to_string(), + metadata, + } + } + + pub fn metadata(&self) -> &DbMetadata { + &self.metadata + } + + pub async fn connect(&self) -> SqliteConnection { + SqliteConnection::connect(&self.url) + .await + .expect("SQL error") + } + + /// Get account configurations from the database + pub async fn get_account_configurations(&self) -> Vec { + let mut connection = self.connect().await; + + let mut account_configurations = + sqlx::query("SELECT id, account, kind, data FROM account_configurations") + .map(|r: SqliteRow| AccountConfiguration { + id: r.get("id"), + account: r.get("account"), + kind: r.get("kind"), + data: r.get("data"), + }) + .fetch_all(&mut connection) + .await + .expect("SQL error"); + + // System accounts + account_configurations.push(AccountConfiguration { + id: None, + account: crate::CURRENT_YEAR_EARNINGS.to_string(), + kind: "drcr.equity".to_string(), + data: None, + }); + account_configurations.push(AccountConfiguration { + id: None, + account: crate::RETAINED_EARNINGS.to_string(), + kind: "drcr.equity".to_string(), + data: None, + }); + + account_configurations + } + + /// Get balance assertions from the database + pub async fn get_balance_assertions(&self) -> Vec { + let mut connection = self.connect().await; + + let balance_assertions = sqlx::query( + "SELECT id, dt, description, account, quantity, commodity + FROM balance_assertions + ORDER BY dt DESC, id DESC", + ) + .map(|r: SqliteRow| BalanceAssertion { + id: r.get("id"), + dt: NaiveDateTime::parse_from_str(r.get("dt"), "%Y-%m-%d %H:%M:%S.%6f") + .expect("Invalid balance_assertions.dt"), + description: r.get("description"), + account: r.get("account"), + quantity: r.get("quantity"), + commodity: r.get("commodity"), + }) + .fetch_all(&mut connection) + .await + .expect("SQL error"); + + balance_assertions + } + + /// Get account balances from the database + pub async fn get_balances(&self, date: NaiveDate) -> HashMap { + let mut connection = self.connect().await; + + let rows = sqlx::query( + "-- Get last transaction for each account + WITH max_dt_by_account AS ( + SELECT account, max(dt) AS max_dt + FROM joined_transactions + WHERE DATE(dt) <= DATE($1) + GROUP BY account + ), + max_tid_by_account AS ( + SELECT max_dt_by_account.account, max(transaction_id) AS max_tid + FROM max_dt_by_account + JOIN joined_transactions ON max_dt_by_account.account = joined_transactions.account AND max_dt_by_account.max_dt = joined_transactions.dt + GROUP BY max_dt_by_account.account + ) + -- Get running balance at last transaction for each account + SELECT max_tid_by_account.account, running_balance AS quantity + FROM max_tid_by_account + JOIN transactions_with_running_balances ON max_tid = transactions_with_running_balances.transaction_id AND max_tid_by_account.account = transactions_with_running_balances.account" + ).bind(format_date(date)).fetch_all(&mut connection).await.expect("SQL error"); + + let mut balances = HashMap::new(); + for row in rows { + balances.insert(row.get("account"), row.get("quantity")); + } + + balances + } + + /// Get transactions from the database + pub async fn get_transactions(&self) -> Vec { + let mut connection = self.connect().await; + + let rows = sqlx::query( + "SELECT transaction_id, dt, transaction_description, id, description, account, quantity, commodity, quantity_ascost + FROM transactions_with_quantity_ascost + ORDER BY dt, transaction_id, id" + ).fetch_all(&mut connection).await.expect("SQL error"); + + // Un-flatten transaction list + let mut transactions: Vec = Vec::new(); + + for row in rows { + if transactions.is_empty() + || transactions.last().unwrap().transaction.id != row.get("transaction_id") + { + // New transaction + transactions.push(TransactionWithPostings { + transaction: Transaction { + id: row.get("transaction_id"), + dt: NaiveDateTime::parse_from_str(row.get("dt"), "%Y-%m-%d %H:%M:%S.%6f") + .expect("Invalid transactions.dt"), + description: row.get("transaction_description"), + }, + postings: Vec::new(), + }); + } + + transactions.last_mut().unwrap().postings.push(Posting { + id: row.get("id"), + transaction_id: row.get("transaction_id"), + description: row.get("description"), + account: row.get("account"), + quantity: row.get("quantity"), + commodity: row.get("commodity"), + quantity_ascost: row.get("quantity_ascost"), + }); + } + + transactions + } + + /// Get unreconciled statement lines from the database + pub async fn get_unreconciled_statement_lines(&self) -> Vec { + let mut connection = self.connect().await; + + let rows = sqlx::query( + // On testing, JOIN is much faster than WHERE NOT EXISTS + "SELECT statement_lines.* FROM statement_lines + LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id + WHERE statement_line_reconciliations.id IS NULL" + ).map(|r: SqliteRow| StatementLine { + id: Some(r.get("id")), + source_account: r.get("source_account"), + dt: NaiveDateTime::parse_from_str(r.get("dt"), "%Y-%m-%d").expect("Invalid statement_lines.dt"), + description: r.get("description"), + quantity: r.get("quantity"), + balance: r.get("balance"), + commodity: r.get("commodity"), + }).fetch_all(&mut connection).await.expect("SQL error"); + + rows + } +} + +/// Container for cached database-related metadata +pub struct DbMetadata { + pub version: u32, + pub eofy_date: NaiveDate, + pub reporting_commodity: String, + pub dps: u32, +} + +impl DbMetadata { + /// Initialise [DbMetadata] with values from the metadata database table + async fn from_database(connection: &mut SqliteConnection) -> Self { + let version = sqlx::query("SELECT value FROM metadata WHERE key = 'version'") + .map(|r: SqliteRow| { + r.get::(0) + .parse() + .expect("Invalid metadata.version") + }) + .fetch_one(&mut *connection) + .await + .expect("SQL error"); + + let eofy_date = sqlx::query("SELECT value FROM metadata WHERE key ='eofy_date'") + .map(|r: SqliteRow| { + NaiveDate::parse_from_str(r.get(0), "%Y-%m-%d").expect("Invalid metadata.eofy_date") + }) + .fetch_one(&mut *connection) + .await + .expect("SQL error"); + + let reporting_commodity = + sqlx::query("SELECT value FROM metadata WHERE key = 'reporting_commodity'") + .map(|r: SqliteRow| r.get(0)) + .fetch_one(&mut *connection) + .await + .expect("SQL error"); + + let dps = sqlx::query("SELECT value FROM metadata WHERE key = 'amount_dps'") + .map(|r: SqliteRow| { + r.get::(0) + .parse() + .expect("Invalid metadata.amount_dps") + }) + .fetch_one(&mut *connection) + .await + .expect("SQL error"); + + DbMetadata { + version, + eofy_date, + reporting_commodity, + dps, + } + } +} diff --git a/libdrcr/src/lib.rs b/libdrcr/src/lib.rs new file mode 100644 index 0000000..300942e --- /dev/null +++ b/libdrcr/src/lib.rs @@ -0,0 +1,14 @@ +pub mod account_config; +pub mod db; +pub mod model; +pub mod reporting; +pub mod serde; +pub mod util; + +/// Data type used to represent transaction and account quantities +pub type QuantityInt = i64; + +// Magic strings +// TODO: Make this configurable +pub const CURRENT_YEAR_EARNINGS: &'static str = "Current Year Earnings"; +pub const RETAINED_EARNINGS: &'static str = "Retained Earnings"; diff --git a/libdrcr/src/main.rs b/libdrcr/src/main.rs new file mode 100644 index 0000000..79acabb --- /dev/null +++ b/libdrcr/src/main.rs @@ -0,0 +1,237 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use std::sync::Arc; + +use chrono::NaiveDate; +use libdrcr::db::DbConnection; +use libdrcr::reporting::builders::register_dynamic_builders; +use libdrcr::reporting::calculator::{steps_as_graphviz, steps_for_targets}; +use libdrcr::reporting::dynamic_report::DynamicReport; +use libdrcr::reporting::generate_report; +use libdrcr::reporting::steps::register_lookup_fns; +use libdrcr::reporting::types::{ + DateArgs, DateStartDateEndArgs, MultipleDateArgs, MultipleDateStartDateEndArgs, + ReportingContext, ReportingProductId, ReportingProductKind, VoidArgs, +}; + +#[tokio::main] +async fn main() { + const YEAR: i32 = 2025; + + // Connect to database + let db_connection = DbConnection::new("sqlite:drcr_testing.db").await; + + // Initialise ReportingContext + let mut context = ReportingContext::new( + db_connection, + NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + "$".to_string(), + ); + register_lookup_fns(&mut context); + register_dynamic_builders(&mut context); + + let context = Arc::new(context); + + // Print Graphviz + + let targets = vec![ + ReportingProductId { + name: "CalculateIncomeTax", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + // ReportingProductId { + // name: "AllTransactionsExceptEarningsToEquity", + // kind: ReportingProductKind::Transactions, + // args: Box::new(DateArgs { + // date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + // }), + // }, + ReportingProductId { + name: "BalanceSheet", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateArgs { + dates: vec![DateArgs { + date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }], + }), + }, + ReportingProductId { + name: "IncomeStatement", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateStartDateEndArgs { + dates: vec![DateStartDateEndArgs { + date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(), + date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }], + }), + }, + ]; + let (sorted_steps, dependencies) = steps_for_targets(targets, &context).unwrap(); + + println!("Graphviz:"); + println!("{}", steps_as_graphviz(&sorted_steps, &dependencies)); + + // Get income statement + + let targets = vec![ + ReportingProductId { + name: "CalculateIncomeTax", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesBetween, + args: Box::new(DateStartDateEndArgs { + date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(), + date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }), + }, + ]; + + let products = generate_report(targets, Arc::clone(&context)) + .await + .unwrap(); + let result = products + .get_or_err(&ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesBetween, + args: Box::new(DateStartDateEndArgs { + date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(), + date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }), + }) + .unwrap(); + + println!("Income statement:"); + println!("{:?}", result); + + // Get balance sheet + + let targets = vec![ + ReportingProductId { + name: "CalculateIncomeTax", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + ReportingProductId { + name: "BalanceSheet", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateArgs { + dates: vec![DateArgs { + date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }], + }), + }, + ]; + + let products = generate_report(targets, Arc::clone(&context)) + .await + .unwrap(); + let result = products + .get_or_err(&ReportingProductId { + name: "BalanceSheet", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateArgs { + dates: vec![DateArgs { + date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }], + }), + }) + .unwrap(); + + println!("Balance sheet:"); + println!( + "{}", + result.downcast_ref::().unwrap().to_json() + ); + + // Get trial balance + + let targets = vec![ + ReportingProductId { + name: "CalculateIncomeTax", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + ReportingProductId { + name: "TrialBalance", + kind: ReportingProductKind::Generic, + args: Box::new(DateArgs { + date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }), + }, + ]; + + let products = generate_report(targets, Arc::clone(&context)) + .await + .unwrap(); + let result = products + .get_or_err(&ReportingProductId { + name: "TrialBalance", + kind: ReportingProductKind::Generic, + args: Box::new(DateArgs { + date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }), + }) + .unwrap(); + + println!("Trial balance:"); + println!( + "{}", + result.downcast_ref::().unwrap().to_json() + ); + + // Get all transactions + + /*let targets = vec![ + ReportingProductId { + name: "CalculateIncomeTax", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::Transactions, + args: Box::new(DateArgs { + date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }), + }, + ]; + + let products = generate_report(targets, Arc::clone(&context)) + .await + .unwrap(); + let result = products + .get_or_err(&ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::Transactions, + args: Box::new(DateArgs { + date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }), + }) + .unwrap(); + + println!("All transactions:"); + println!( + "{}", + result.downcast_ref::().unwrap().to_json() + );*/ +} diff --git a/libdrcr/src/model/assertions.rs b/libdrcr/src/model/assertions.rs new file mode 100644 index 0000000..59e8eb4 --- /dev/null +++ b/libdrcr/src/model/assertions.rs @@ -0,0 +1,33 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; + +use crate::QuantityInt; + +#[derive(Deserialize, Serialize)] +pub struct BalanceAssertion { + pub id: Option, + #[serde(with = "crate::serde::naivedatetime_to_js")] + pub dt: NaiveDateTime, + pub description: String, + pub account: String, + pub quantity: QuantityInt, + pub commodity: String, +} diff --git a/libdrcr/src/model/mod.rs b/libdrcr/src/model/mod.rs new file mode 100644 index 0000000..fc27c3d --- /dev/null +++ b/libdrcr/src/model/mod.rs @@ -0,0 +1,21 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +pub mod assertions; +pub mod statements; +pub mod transaction; diff --git a/libdrcr/src/model/statements.rs b/libdrcr/src/model/statements.rs new file mode 100644 index 0000000..87a4123 --- /dev/null +++ b/libdrcr/src/model/statements.rs @@ -0,0 +1,31 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use chrono::NaiveDateTime; + +use crate::QuantityInt; + +pub struct StatementLine { + pub id: Option, + pub source_account: String, + pub dt: NaiveDateTime, + pub description: String, + pub quantity: QuantityInt, + pub balance: QuantityInt, + pub commodity: String, +} diff --git a/libdrcr/src/model/transaction.rs b/libdrcr/src/model/transaction.rs new file mode 100644 index 0000000..4386fbc --- /dev/null +++ b/libdrcr/src/model/transaction.rs @@ -0,0 +1,67 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use std::collections::HashMap; + +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; + +use crate::QuantityInt; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Transaction { + pub id: Option, + #[serde(with = "crate::serde::naivedatetime_to_js")] + pub dt: NaiveDateTime, + pub description: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TransactionWithPostings { + #[serde(flatten)] + pub transaction: Transaction, + pub postings: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Posting { + pub id: Option, + pub transaction_id: Option, + pub description: Option, + pub account: String, + pub quantity: QuantityInt, + pub commodity: String, + pub quantity_ascost: Option, + //pub running_balance: Option, +} + +pub(crate) fn update_balances_from_transactions< + 'a, + I: Iterator, +>( + balances: &mut HashMap, + transactions: I, +) { + for transaction in transactions { + for posting in transaction.postings.iter() { + // FIXME: Do currency conversion + let running_balance = balances.get(&posting.account).unwrap_or(&0) + posting.quantity; + balances.insert(posting.account.clone(), running_balance); + } + } +} diff --git a/libdrcr/src/reporting/builders.rs b/libdrcr/src/reporting/builders.rs new file mode 100644 index 0000000..6620e62 --- /dev/null +++ b/libdrcr/src/reporting/builders.rs @@ -0,0 +1,855 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +//! This module contains implementations of dynamic step builders +//! +//! See [ReportingContext::register_dynamic_builder][super::types::ReportingContext::register_dynamic_builder]. + +use std::collections::HashMap; +use std::fmt::Display; + +use async_trait::async_trait; +use tokio::sync::RwLock; + +use crate::model::transaction::update_balances_from_transactions; + +use super::calculator::{has_step_or_can_build, HasStepOrCanBuild, ReportingGraphDependencies}; +use super::executor::ReportingExecutionError; +use super::types::{ + BalancesAt, BalancesBetween, DateArgs, DateStartDateEndArgs, ReportingContext, + ReportingProductId, ReportingProductKind, ReportingProducts, ReportingStep, ReportingStepArgs, + ReportingStepDynamicBuilder, ReportingStepId, Transactions, VoidArgs, +}; + +/// Call [ReportingContext::register_dynamic_builder] for all dynamic builders provided by this module +pub fn register_dynamic_builders(context: &mut ReportingContext) { + GenerateBalances::register_dynamic_builder(context); + UpdateBalancesBetween::register_dynamic_builder(context); + UpdateBalancesAt::register_dynamic_builder(context); + + // This is the least efficient way of generating BalancesBetween so put at the end + BalancesAtToBalancesBetween::register_dynamic_builder(context); +} + +/// This dynamic builder automatically generates a [BalancesBetween] by subtracting [BalancesAt] between two dates +#[derive(Debug)] +pub struct BalancesAtToBalancesBetween { + step_name: &'static str, + args: DateStartDateEndArgs, +} + +impl BalancesAtToBalancesBetween { + // Implements BalancesAt, BalancesAt -> BalancesBetween + + fn register_dynamic_builder(context: &mut ReportingContext) { + context.register_dynamic_builder(ReportingStepDynamicBuilder { + name: "BalancesAtToBalancesBetween", + can_build: Self::can_build, + build: Self::build, + }); + } + + fn can_build( + name: &'static str, + kind: ReportingProductKind, + args: &Box, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + context: &ReportingContext, + ) -> bool { + // Check for BalancesAt, BalancesAt -> BalancesBetween + if kind == ReportingProductKind::BalancesBetween { + if !args.is::() { + return false; + } + + let args = args.downcast_ref::().unwrap(); + + match has_step_or_can_build( + &ReportingProductId { + name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: args.date_start.clone(), + }), + }, + steps, + dependencies, + context, + ) { + HasStepOrCanBuild::HasStep(_) + | HasStepOrCanBuild::CanLookup(_) + | HasStepOrCanBuild::CanBuild(_) => { + return true; + } + HasStepOrCanBuild::None => {} + } + } + return false; + } + + fn build( + name: &'static str, + _kind: ReportingProductKind, + args: Box, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _context: &ReportingContext, + ) -> Box { + Box::new(BalancesAtToBalancesBetween { + step_name: name, + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for BalancesAtToBalancesBetween { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{} {{BalancesAtToBalancesBetween}}", + self.id() + )) + } +} + +#[async_trait] +impl ReportingStep for BalancesAtToBalancesBetween { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: self.step_name, + product_kinds: &[ReportingProductKind::BalancesBetween], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + // BalancesAtToBalancesBetween depends on BalancesAt at both time points + vec![ + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: self.args.date_start.pred_opt().unwrap(), // Opening balance is the closing balance of the preceding day + }), + }, + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: self.args.date_end, + }), + }, + ] + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Get balances at dates + let balances_start = &products + .get_or_err(&ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: self.args.date_start.pred_opt().unwrap(), // Opening balance is the closing balance of the preceding day + }), + })? + .downcast_ref::() + .unwrap() + .balances; + + let balances_end = &products + .get_or_err(&ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: self.args.date_end, + }), + })? + .downcast_ref::() + .unwrap() + .balances; + + // Compute balances_end - balances_start + let mut balances = BalancesBetween { + balances: balances_end.clone(), + }; + + for (account, balance) in balances_start.iter() { + let running_balance = balances.balances.get(account).unwrap_or(&0) - balance; + balances.balances.insert(account.clone(), running_balance); + } + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::BalancesBetween, + args: Box::new(self.args.clone()), + }, + Box::new(balances), + ); + Ok(result) + } +} + +/// This dynamic builder automatically generates a [BalancesAt] from a step which has no dependencies and generates [Transactions] (e.g. [PostUnreconciledStatementLines][super::steps::PostUnreconciledStatementLines]) +#[derive(Debug)] +pub struct GenerateBalances { + step_name: &'static str, + args: DateArgs, +} + +impl GenerateBalances { + fn register_dynamic_builder(context: &mut ReportingContext) { + context.register_dynamic_builder(ReportingStepDynamicBuilder { + name: "GenerateBalances", + can_build: Self::can_build, + build: Self::build, + }); + } + + fn can_build( + name: &'static str, + kind: ReportingProductKind, + args: &Box, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + context: &ReportingContext, + ) -> bool { + // Check for Transactions -> BalancesAt + if kind == ReportingProductKind::BalancesAt { + // Try DateArgs + match has_step_or_can_build( + &ReportingProductId { + name, + kind: ReportingProductKind::Transactions, + args: args.clone(), + }, + steps, + dependencies, + context, + ) { + HasStepOrCanBuild::HasStep(step) => { + // Check for () -> Transactions + if dependencies.dependencies_for_step(&step.id()).len() == 0 { + return true; + } + } + HasStepOrCanBuild::CanLookup(lookup_fn) => { + // Check for () -> Transactions + let step = lookup_fn(args.clone()); + if step.requires(context).len() == 0 { + return true; + } + } + HasStepOrCanBuild::CanBuild(_) | HasStepOrCanBuild::None => {} + } + + // Try VoidArgs + match has_step_or_can_build( + &ReportingProductId { + name, + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + steps, + dependencies, + context, + ) { + HasStepOrCanBuild::HasStep(step) => { + // Check for () -> Transactions + if dependencies.dependencies_for_step(&step.id()).len() == 0 { + return true; + } + } + HasStepOrCanBuild::CanLookup(lookup_fn) => { + // Check for () -> Transactions + let step = lookup_fn(args.clone()); + if step.requires(context).len() == 0 { + return true; + } + } + HasStepOrCanBuild::CanBuild(_) | HasStepOrCanBuild::None => {} + } + } + return false; + } + + fn build( + name: &'static str, + _kind: ReportingProductKind, + args: Box, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _context: &ReportingContext, + ) -> Box { + Box::new(GenerateBalances { + step_name: name, + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for GenerateBalances { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{} {{GenerateBalances}}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for GenerateBalances { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: self.step_name, + product_kinds: &[ReportingProductKind::BalancesAt], + args: Box::new(self.args.clone()), + } + } + + fn init_graph( + &self, + steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + context: &ReportingContext, + ) { + // Add a dependency on the Transactions result + // Look up that step, so we can extract the appropriate args + + // Try DateArgs + match has_step_or_can_build( + &ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + }, + steps, + dependencies, + context, + ) { + HasStepOrCanBuild::HasStep(_) + | HasStepOrCanBuild::CanLookup(_) + | HasStepOrCanBuild::CanBuild(_) => { + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + }, + ); + return; + } + HasStepOrCanBuild::None => (), + } + + // Must be VoidArgs (as checked in can_build) + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + ); + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Get the transactions + let transactions_product = &dependencies.dependencies_for_step(&self.id())[0].product; + let transactions = &products + .get_or_err(transactions_product)? + .downcast_ref::() + .unwrap() + .transactions; + + // Sum balances + let mut balances = BalancesAt { + balances: HashMap::new(), + }; + update_balances_from_transactions(&mut balances.balances, transactions.iter()); + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + }, + Box::new(balances), + ); + Ok(result) + } +} + +/// This dynamic builder automatically generates a [BalancesAt] from: +/// - a step which generates [Transactions] from [BalancesAt], or +/// - a step which generates [Transactions] from [BalancesBetween], and for which a [BalancesAt] is also available +#[derive(Debug)] +pub struct UpdateBalancesAt { + step_name: &'static str, + args: DateArgs, +} + +impl UpdateBalancesAt { + // Implements (BalancesAt -> Transactions) -> BalancesAt + + fn register_dynamic_builder(context: &mut ReportingContext) { + context.register_dynamic_builder(ReportingStepDynamicBuilder { + name: "UpdateBalancesAt", + can_build: Self::can_build, + build: Self::build, + }); + } + + fn can_build( + name: &'static str, + kind: ReportingProductKind, + args: &Box, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + context: &ReportingContext, + ) -> bool { + if !args.is::() { + return false; + } + + // Check for Transactions -> BalancesAt + if kind == ReportingProductKind::BalancesAt { + // Initially no need to check args + if let Some(step) = steps.iter().find(|s| { + s.id().name == name + && s.id() + .product_kinds + .contains(&ReportingProductKind::Transactions) + }) { + // Check for BalancesAt -> Transactions + let dependencies_for_step = dependencies.dependencies_for_step(&step.id()); + if dependencies_for_step.len() == 1 + && dependencies_for_step[0].product.kind == ReportingProductKind::BalancesAt + { + return true; + } + + // Check if BalancesBetween -> Transactions and BalancesAt is available + if dependencies_for_step.len() == 1 + && dependencies_for_step[0].product.kind + == ReportingProductKind::BalancesBetween + { + match has_step_or_can_build( + &ReportingProductId { + name: dependencies_for_step[0].product.name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: args.downcast_ref::().unwrap().date, + }), + }, + steps, + dependencies, + context, + ) { + HasStepOrCanBuild::HasStep(_) + | HasStepOrCanBuild::CanLookup(_) + | HasStepOrCanBuild::CanBuild(_) => { + return true; + } + HasStepOrCanBuild::None => {} + } + } + } + } + return false; + } + + fn build( + name: &'static str, + _kind: ReportingProductKind, + args: Box, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _context: &ReportingContext, + ) -> Box { + Box::new(UpdateBalancesAt { + step_name: name, + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for UpdateBalancesAt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{} {{UpdateBalancesAt}}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for UpdateBalancesAt { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: self.step_name, + product_kinds: &[ReportingProductKind::BalancesAt], + args: Box::new(self.args.clone()), + } + } + + fn init_graph( + &self, + steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + _context: &ReportingContext, + ) { + // Add a dependency on the Transactions result + // Look up that step, so we can extract the appropriate args + let parent_step = steps + .iter() + .find(|s| { + s.id().name == self.step_name + && s.id() + .product_kinds + .contains(&ReportingProductKind::Transactions) + }) + .unwrap(); // Existence is checked in can_build + + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::Transactions, + args: parent_step.id().args.clone(), + }, + ); + + // Look up the BalancesAt step + let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id()); + let dependency = &dependencies_for_step[0].product; // Existence and uniqueness checked in can_build + + if dependency.kind == ReportingProductKind::BalancesAt { + // Directly depends on BalancesAt -> Transaction + // Do not need to add extra dependencies + } else { + // As checked in can_build, must depend on BalancesBetween -> Transaction with a BalancesAt available + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: dependency.name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: self.args.date, + }), + }, + ); + } + } + + async fn execute( + &self, + _context: &ReportingContext, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Look up the parent step, so we can extract the appropriate args + let parent_step = steps + .iter() + .find(|s| { + s.id().name == self.step_name + && s.id() + .product_kinds + .contains(&ReportingProductKind::Transactions) + }) + .unwrap(); // Existence is checked in can_build + + // Get transactions + let transactions = &products + .get_or_err(&ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::Transactions, + args: parent_step.id().args, + })? + .downcast_ref::() + .unwrap() + .transactions; + + // Look up the BalancesAt step + let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id()); + let dependency = &dependencies_for_step[0].product; // Existence and uniqueness checked in can_build + + let opening_balances_at; + + if dependency.kind == ReportingProductKind::BalancesAt { + // Directly depends on BalancesAt -> Transaction + opening_balances_at = products + .get_or_err(&dependency)? + .downcast_ref::() + .unwrap(); + } else { + // As checked in can_build, must depend on BalancesBetween -> Transaction with a BalancesAt available + opening_balances_at = products + .get_or_err(&ReportingProductId { + name: dependency.name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: self.args.date, + }), + })? + .downcast_ref() + .unwrap(); + } + + // Sum balances + let mut balances = BalancesAt { + balances: opening_balances_at.balances.clone(), + }; + update_balances_from_transactions( + &mut balances.balances, + transactions + .iter() + .filter(|t| t.transaction.dt.date() <= self.args.date), + ); + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + }, + Box::new(balances), + ); + Ok(result) + } +} + +/// This dynamic builder automatically generates a [BalancesBetween] from a step which generates [Transactions] from [BalancesBetween] +#[derive(Debug)] +pub struct UpdateBalancesBetween { + step_name: &'static str, + args: DateStartDateEndArgs, +} + +impl UpdateBalancesBetween { + fn register_dynamic_builder(context: &mut ReportingContext) { + context.register_dynamic_builder(ReportingStepDynamicBuilder { + name: "UpdateBalancesBetween", + can_build: Self::can_build, + build: Self::build, + }); + } + + fn can_build( + name: &'static str, + kind: ReportingProductKind, + _args: &Box, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + _context: &ReportingContext, + ) -> bool { + // Check for Transactions -> BalancesBetween + if kind == ReportingProductKind::BalancesBetween { + // Initially no need to check args + if let Some(step) = steps.iter().find(|s| { + s.id().name == name + && s.id() + .product_kinds + .contains(&ReportingProductKind::Transactions) + }) { + // Check for BalancesBetween -> Transactions + let dependencies_for_step = dependencies.dependencies_for_step(&step.id()); + if dependencies_for_step.len() == 1 + && dependencies_for_step[0].product.kind + == ReportingProductKind::BalancesBetween + { + return true; + } + } + } + return false; + } + + fn build( + name: &'static str, + _kind: ReportingProductKind, + args: Box, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _context: &ReportingContext, + ) -> Box { + Box::new(UpdateBalancesBetween { + step_name: name, + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for UpdateBalancesBetween { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{} {{UpdateBalancesBetween}}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for UpdateBalancesBetween { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: self.step_name, + product_kinds: &[ReportingProductKind::BalancesBetween], + args: Box::new(self.args.clone()), + } + } + + fn init_graph( + &self, + steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + _context: &ReportingContext, + ) { + // Add a dependency on the Transactions result + // Look up that step, so we can extract the appropriate args + let parent_step = steps + .iter() + .find(|s| { + s.id().name == self.step_name + && s.id() + .product_kinds + .contains(&ReportingProductKind::Transactions) + }) + .unwrap(); // Existence is checked in can_build + + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::Transactions, + args: parent_step.id().args, + }, + ); + + // Look up the BalancesBetween step + let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id()); + let balances_between_product = &dependencies_for_step[0].product; // Existence and uniqueness checked in can_build + + if *balances_between_product + .args + .downcast_ref::() + .unwrap() == self.args + { + // Directly depends on BalanceBetween -> Transaction with appropriate date + // Do not need to add extra dependencies + } else { + // Depends on BalanceBetween with appropriate date + dependencies.add_dependency( + self.id(), + ReportingProductId { + name: balances_between_product.name, + kind: ReportingProductKind::BalancesBetween, + args: Box::new(self.args.clone()), + }, + ); + } + } + + async fn execute( + &self, + _context: &ReportingContext, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Look up the parent step, so we can extract the appropriate args + let parent_step = steps + .iter() + .find(|s| { + s.id().name == self.step_name + && s.id() + .product_kinds + .contains(&ReportingProductKind::Transactions) + }) + .unwrap(); // Existence is checked in can_build + + // Get transactions + let transactions = &products + .get_or_err(&ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::Transactions, + args: parent_step.id().args, + })? + .downcast_ref::() + .unwrap() + .transactions; + + // Look up the BalancesBetween step + let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id()); + let balances_between_product = &dependencies_for_step[0].product; // Existence and uniqueness is checked in can_build + + // Get opening balances + let opening_balances = &products + .get_or_err(&ReportingProductId { + name: balances_between_product.name, + kind: ReportingProductKind::BalancesBetween, + args: Box::new(self.args.clone()), + })? + .downcast_ref::() + .unwrap() + .balances; + + // Sum balances + let mut balances = BalancesBetween { + balances: opening_balances.clone(), + }; + update_balances_from_transactions( + &mut balances.balances, + transactions.iter().filter(|t| { + t.transaction.dt.date() >= self.args.date_start + && t.transaction.dt.date() <= self.args.date_end + }), + ); + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.step_name, + kind: ReportingProductKind::BalancesBetween, + args: Box::new(self.args.clone()), + }, + Box::new(balances), + ); + Ok(result) + } +} diff --git a/libdrcr/src/reporting/calculator.rs b/libdrcr/src/reporting/calculator.rs new file mode 100644 index 0000000..06549f7 --- /dev/null +++ b/libdrcr/src/reporting/calculator.rs @@ -0,0 +1,429 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +//! This module implements the dependency resolution for [ReportingStep]s + +use super::types::{ + ReportingContext, ReportingProductId, ReportingStep, ReportingStepDynamicBuilder, + ReportingStepFromArgsFn, ReportingStepId, +}; + +/// List of dependencies between [ReportingStep]s and [ReportingProduct][super::types::ReportingProduct]s +#[derive(Debug)] +pub struct ReportingGraphDependencies { + vec: Vec, +} + +impl ReportingGraphDependencies { + /// Get the list of [Dependency]s + pub fn vec(&self) -> &Vec { + &self.vec + } + + /// Record that the [ReportingStep] depends on the [ReportingProduct][super::types::ReportingProduct] + pub fn add_dependency(&mut self, step: ReportingStepId, product: ReportingProductId) { + if !self + .vec + .iter() + .any(|d| d.step == step && d.product == product) + { + self.vec.push(Dependency { step, product }); + } + } + + /// Get the [Dependency]s for the given [ReportingStep] + pub fn dependencies_for_step(&self, step: &ReportingStepId) -> Vec<&Dependency> { + return self.vec.iter().filter(|d| d.step == *step).collect(); + } +} + +/// Represents that a [ReportingStep] depends on a [ReportingProduct][super::types::ReportingProduct] +#[derive(Debug)] +pub struct Dependency { + pub step: ReportingStepId, + pub product: ReportingProductId, +} + +/// Indicates an error during dependency resolution in [steps_for_targets] +#[derive(Debug)] +pub enum ReportingCalculationError { + UnknownStep { message: String }, + NoStepForProduct { message: String }, + CircularDependencies, +} + +pub enum HasStepOrCanBuild<'a, 'b> { + HasStep(&'a Box), + CanLookup(ReportingStepFromArgsFn), + CanBuild(&'b ReportingStepDynamicBuilder), + None, +} + +/// Determines whether the [ReportingProduct][super::types::ReportingProduct] is generated by a known step, or can be generated by a lookup function or dynamic builder +pub fn has_step_or_can_build<'a, 'b>( + product: &ReportingProductId, + steps: &'a Vec>, + dependencies: &ReportingGraphDependencies, + context: &'b ReportingContext, +) -> HasStepOrCanBuild<'a, 'b> { + if let Some(step) = steps.iter().find(|s| { + s.id().name == product.name + && s.id().args == product.args + && s.id().product_kinds.contains(&product.kind) + }) { + return HasStepOrCanBuild::HasStep(step); + } + + // Try lookup function + if let Some(lookup_key) = context + .step_lookup_fn + .keys() + .find(|(name, kinds)| *name == product.name && kinds.contains(&product.kind)) + { + let (takes_args_fn, from_args_fn) = context.step_lookup_fn.get(lookup_key).unwrap(); + if takes_args_fn(&product.args) { + return HasStepOrCanBuild::CanLookup(*from_args_fn); + } + } + + // No explicit step for product - try builders + for builder in context.step_dynamic_builders.iter() { + if (builder.can_build)( + product.name, + product.kind, + &product.args, + steps, + dependencies, + context, + ) { + return HasStepOrCanBuild::CanBuild(builder); + } + } + + return HasStepOrCanBuild::None; +} + +/// Generates a new step which generates the requested [ReportingProduct][super::types::ReportingProduct], using a lookup function or dynamic builder +/// +/// Panics if a known step already generates the requested [ReportingProduct][super::types::ReportingProduct]. +fn build_step_for_product( + product: &ReportingProductId, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + context: &ReportingContext, +) -> Option> { + let new_step; + match has_step_or_can_build(product, steps, dependencies, context) { + HasStepOrCanBuild::HasStep(_) => { + panic!("Attempted to call build_step_for_product for already existing step") + } + HasStepOrCanBuild::CanLookup(from_args_fn) => { + new_step = from_args_fn(product.args.clone()); + + // Check new step meets the dependency + if new_step.id().name != product.name { + panic!( + "Unexpected step returned from lookup function (expected name {}, got {})", + product.name, + new_step.id().name + ); + } + if new_step.id().args != product.args { + panic!( + "Unexpected step returned from lookup function {} (expected args {:?}, got {:?})", + product.name, + product.args, + new_step.id().args + ); + } + if !new_step.id().product_kinds.contains(&product.kind) { + panic!( + "Unexpected step returned from lookup function {} (expected kind {:?}, got {:?})", + product.name, + product.kind, + new_step.id().product_kinds + ); + } + } + HasStepOrCanBuild::CanBuild(builder) => { + new_step = (builder.build)( + product.name, + product.kind, + product.args.clone(), + &steps, + &dependencies, + &context, + ); + + // Check new step meets the dependency + if new_step.id().name != product.name { + panic!( + "Unexpected step returned from builder {} (expected name {}, got {})", + builder.name, + product.name, + new_step.id().name + ); + } + if new_step.id().args != product.args { + panic!( + "Unexpected step returned from builder {} for {} (expected args {:?}, got {:?})", + builder.name, + product.name, + product.args, + new_step.id().args + ); + } + if !new_step.id().product_kinds.contains(&product.kind) { + panic!( + "Unexpected step returned from builder {} for {} (expected kind {:?}, got {:?})", + builder.name, + product.name, + product.kind, + new_step.id().product_kinds + ); + } + } + HasStepOrCanBuild::None => { + return None; + } + } + + Some(new_step) +} + +/// Check whether the [ReportingStep] would be ready to execute, if the given previous steps have already completed +pub(crate) fn would_be_ready_to_execute( + step: &Box, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + previous_steps: &Vec, +) -> bool { + 'check_each_dependency: for dependency in dependencies.vec.iter() { + if dependency.step == step.id() { + // Check if the dependency has been produced by a previous step + for previous_step in previous_steps { + if steps[*previous_step].id().name == dependency.product.name + && steps[*previous_step].id().args == dependency.product.args + && steps[*previous_step] + .id() + .product_kinds + .contains(&dependency.product.kind) + { + continue 'check_each_dependency; + } + } + + // Dependency is not met + return false; + } + } + true +} + +/// Recursively resolve the dependencies of the target [ReportingProductId]s and return a sorted [Vec] of [ReportingStep]s +pub fn steps_for_targets( + targets: Vec, + context: &ReportingContext, +) -> Result<(Vec>, ReportingGraphDependencies), ReportingCalculationError> { + let mut steps: Vec> = Vec::new(); + let mut dependencies = ReportingGraphDependencies { vec: Vec::new() }; + + // Process initial targets + for target in targets.iter() { + if !steps.iter().any(|s| { + s.id().name == target.name + && s.id().args == target.args + && s.id().product_kinds.contains(&target.kind) + }) { + // No current step generates the product - try to lookup or build + if let Some(new_step) = build_step_for_product(&target, &steps, &dependencies, context) + { + steps.push(new_step); + let new_step = steps.last().unwrap(); + for dependency in new_step.requires(&context) { + dependencies.add_dependency(new_step.id(), dependency); + } + new_step.init_graph(&steps, &mut dependencies, &context); + } else { + return Err(ReportingCalculationError::NoStepForProduct { + message: format!("No step builds target product {}", target), + }); + } + } + } + + // Call after_init_graph + for step in steps.iter() { + step.as_ref() + .after_init_graph(&steps, &mut dependencies, &context); + } + + // Recursively process dependencies + loop { + let mut new_steps = Vec::new(); + + for dependency in dependencies.vec.iter() { + if !steps.iter().any(|s| s.id() == dependency.step) { + // Unknown step for which a dependency has been declared + // FIXME: Call the lookup function + todo!(); + } + if !steps.iter().any(|s| { + s.id().name == dependency.product.name + && s.id().args == dependency.product.args + && s.id().product_kinds.contains(&dependency.product.kind) + }) { + // No current step generates the product - try to lookup or build + if let Some(new_step) = + build_step_for_product(&dependency.product, &steps, &dependencies, context) + { + new_steps.push(new_step); + } + } + } + + if new_steps.len() == 0 { + break; + } + + // Initialise new steps + let mut new_step_indexes = Vec::new(); + for new_step in new_steps { + new_step_indexes.push(steps.len()); + steps.push(new_step); + let new_step = steps.last().unwrap(); + for dependency in new_step.requires(&context) { + dependencies.add_dependency(new_step.id(), dependency); + } + new_step + .as_ref() + .init_graph(&steps, &mut dependencies, &context); + } + + // Call after_init_graph on all steps + for step in steps.iter() { + step.as_ref() + .after_init_graph(&steps, &mut dependencies, &context); + } + } + + // Check all dependencies satisfied + for dependency in dependencies.vec.iter() { + if !steps.iter().any(|s| s.id() == dependency.step) { + return Err(ReportingCalculationError::UnknownStep { + message: format!( + "No implementation for step {} which {} is a dependency of", + dependency.step, dependency.product + ), + }); + } + if !steps.iter().any(|s| { + s.id().name == dependency.product.name + && s.id().args == dependency.product.args + && s.id().product_kinds.contains(&dependency.product.kind) + }) { + return Err(ReportingCalculationError::NoStepForProduct { + message: format!( + "No step builds product {} wanted by {}", + dependency.product, dependency.step + ), + }); + } + } + + // Sort + let mut sorted_step_indexes = Vec::new(); + let mut steps_remaining = steps.iter().enumerate().collect::>(); + + 'loop_until_all_sorted: while !steps_remaining.is_empty() { + for (cur_index, (orig_index, step)) in steps_remaining.iter().enumerate() { + if would_be_ready_to_execute(step, &steps, &dependencies, &sorted_step_indexes) { + sorted_step_indexes.push(*orig_index); + steps_remaining.remove(cur_index); + continue 'loop_until_all_sorted; + } + } + + // No steps to execute - must be circular dependency + return Err(ReportingCalculationError::CircularDependencies); + } + + let mut sort_mapping = vec![0_usize; sorted_step_indexes.len()]; + for i in 0..sorted_step_indexes.len() { + sort_mapping[sorted_step_indexes[i]] = i; + } + + // TODO: This can be done in place + let mut sorted_steps = steps.into_iter().zip(sort_mapping).collect::>(); + sorted_steps.sort_unstable_by_key(|(_s, order)| *order); + let sorted_steps = sorted_steps + .into_iter() + .map(|(s, _idx)| s) + .collect::>(); + + Ok((sorted_steps, dependencies)) +} + +/// Generate graphviz code representing the dependency tree +/// +/// Useful for debugging or visualisation. Can be compiled using e.g. `dot -Tpdf -O output.gv`. +pub fn steps_as_graphviz( + steps: &Vec>, + dependencies: &ReportingGraphDependencies, +) -> String { + let mut result = String::from("strict digraph drcr {\n"); + + // Output all steps + for step in steps.iter() { + let step_display_name = step.to_string(); + if step_display_name.contains("{") { + // Bodge: Detect dynamic step builders + result.push_str(&format!( + "\"{}\" [shape=box, style=dashed, label=\"{}\"];\n", + step.id(), + step_display_name + )); + } else { + result.push_str(&format!("\"{}\" [shape=box];\n", step.id())); + } + + // Output the products of the step + for product_kind in step.id().product_kinds.iter() { + result.push_str(&format!( + "\"{}\" -> \"{}\";\n", + step.id(), + ReportingProductId { + name: step.id().name, + kind: *product_kind, + args: step.id().args + } + )); + } + } + + // Output all dependencies + for dependency in dependencies.vec().iter() { + result.push_str(&format!( + "\"{}\" -> \"{}\";\n", + dependency.product, dependency.step + )); + } + + result.push_str("}"); + result +} diff --git a/libdrcr/src/reporting/dynamic_report.rs b/libdrcr/src/reporting/dynamic_report.rs new file mode 100644 index 0000000..d2149c8 --- /dev/null +++ b/libdrcr/src/reporting/dynamic_report.rs @@ -0,0 +1,562 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +// FIXME: Tidy up this file + +use std::cell::RefCell; +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::QuantityInt; + +use super::types::ReportingProduct; + +/// Represents a dynamically generated report composed of [CalculatableDynamicReportEntry] +#[derive(Clone, Debug)] +pub struct CalculatableDynamicReport { + pub title: String, + pub columns: Vec, + // This must use RefCell as, during calculation, we iterate while mutating the report + pub entries: Vec>, +} + +impl CalculatableDynamicReport { + pub fn new( + title: String, + columns: Vec, + entries: Vec, + ) -> Self { + Self { + title, + columns, + entries: entries.into_iter().map(|e| RefCell::new(e)).collect(), + } + } + + /// Recursively calculate all [CalculatedRow] entries + pub fn calculate(self) -> DynamicReport { + let mut calculated_entries = Vec::new(); + + for (entry_idx, entry) in self.entries.iter().enumerate() { + let entry_ref = entry.borrow(); + + match &*entry_ref { + CalculatableDynamicReportEntry::CalculatableSection(section) => { + // Clone first, in case calculation needs to take reference to the section + let updated_section = section.clone().calculate(&self); + + drop(entry_ref); // Drop entry_ref so we can borrow mutably + let mut entry_mut = self.entries[entry_idx].borrow_mut(); + *entry_mut = CalculatableDynamicReportEntry::Section(updated_section.clone()); + + calculated_entries.push(DynamicReportEntry::Section(updated_section)); + } + CalculatableDynamicReportEntry::Section(section) => { + calculated_entries.push(DynamicReportEntry::Section(section.clone())); + } + CalculatableDynamicReportEntry::LiteralRow(row) => { + calculated_entries.push(DynamicReportEntry::LiteralRow(row.clone())); + } + CalculatableDynamicReportEntry::CalculatedRow(row) => { + let updated_row = row.calculate(&self); + + drop(entry_ref); // Drop entry_ref so we can borrow mutably + let mut entry_mut = self.entries[entry_idx].borrow_mut(); + *entry_mut = CalculatableDynamicReportEntry::LiteralRow(updated_row.clone()); + + calculated_entries.push(DynamicReportEntry::LiteralRow(updated_row)); + } + CalculatableDynamicReportEntry::Spacer => { + calculated_entries.push(DynamicReportEntry::Spacer); + } + } + } + + DynamicReport { + title: self.title, + columns: self.columns, + entries: calculated_entries, + } + } + + /// Look up [CalculatableDynamicReportEntry] by id + /// + /// Returns a cloned copy of the [CalculatableDynamicReportEntry]. This is necessary because the entry may be within a [Section], and [RefCell] semantics cannot express this type of nested borrow. + pub fn by_id(&self, id: &str) -> Option { + // Manually iterate over self.entries rather than self.entries() + // To catch the situation where entry is already mutably borrowed + for entry in self.entries.iter() { + match entry.try_borrow() { + Ok(entry) => match &*entry { + CalculatableDynamicReportEntry::CalculatableSection(section) => { + if let Some(i) = §ion.id { + if i == id { + return Some(entry.clone()); + } + } + if let Some(e) = section.by_id(id) { + return Some(e); + } + } + CalculatableDynamicReportEntry::Section(section) => { + if let Some(i) = §ion.id { + if i == id { + return Some(entry.clone()); + } + } + if let Some(e) = section.by_id(id) { + return Some(match e { + DynamicReportEntry::Section(section) => { + CalculatableDynamicReportEntry::Section(section.clone()) + } + DynamicReportEntry::LiteralRow(row) => { + CalculatableDynamicReportEntry::LiteralRow(row.clone()) + } + DynamicReportEntry::Spacer => { + CalculatableDynamicReportEntry::Spacer + } + }); + } + } + CalculatableDynamicReportEntry::LiteralRow(row) => { + if let Some(i) = &row.id { + if i == id { + return Some(entry.clone()); + } + } + } + CalculatableDynamicReportEntry::CalculatedRow(_) => (), + CalculatableDynamicReportEntry::Spacer => (), + }, + Err(err) => panic!( + "Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}", + err + ), + } + } + + None + } + + /// Calculate the subtotals for the [Section] with the given id + pub fn subtotal_for_id(&self, id: &str) -> Vec { + let entry = self.by_id(id).expect("Invalid id"); + if let CalculatableDynamicReportEntry::CalculatableSection(section) = entry { + section.subtotal(&self) + } else { + panic!("Called subtotal_for_id on non-Section"); + } + } + + // Return the quantities for the [LiteralRow] with the given id + pub fn quantity_for_id(&self, id: &str) -> Vec { + let entry = self.by_id(id).expect("Invalid id"); + if let CalculatableDynamicReportEntry::LiteralRow(row) = entry { + row.quantity + } else { + panic!("Called quantity_for_id on non-LiteralRow"); + } + } +} + +/// Represents a dynamically generated report composed of [DynamicReportEntry], with no [CalculatedRow]s +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DynamicReport { + pub title: String, + pub columns: Vec, + pub entries: Vec, +} + +impl DynamicReport { + pub fn new(title: String, columns: Vec, entries: Vec) -> Self { + Self { + title, + columns, + entries, + } + } + + /// Remove all entries from the report where auto_hide is enabled and quantity is zero + pub fn auto_hide(&mut self) { + self.entries.retain_mut(|e| match e { + DynamicReportEntry::Section(section) => { + section.auto_hide_children(); + if section.can_auto_hide_self() { + false + } else { + true + } + } + DynamicReportEntry::LiteralRow(row) => { + if row.can_auto_hide() { + false + } else { + true + } + } + DynamicReportEntry::Spacer => true, + }); + } + + /// Serialise the report (as JSON) using serde + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap() + } +} + +impl ReportingProduct for DynamicReport {} + +#[derive(Clone, Debug)] +pub enum CalculatableDynamicReportEntry { + CalculatableSection(CalculatableSection), + Section(Section), + LiteralRow(LiteralRow), + CalculatedRow(CalculatedRow), + Spacer, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum DynamicReportEntry { + Section(Section), + LiteralRow(LiteralRow), + Spacer, +} + +#[derive(Clone, Debug)] +pub struct CalculatableSection { + pub text: String, + pub id: Option, + pub visible: bool, + pub auto_hide: bool, + pub entries: Vec>, +} + +impl CalculatableSection { + pub fn new( + text: String, + id: Option, + visible: bool, + auto_hide: bool, + entries: Vec, + ) -> Self { + Self { + text, + id, + visible, + auto_hide, + entries: entries.into_iter().map(|e| RefCell::new(e)).collect(), + } + } + + /// Recursively calculate all [CalculatedRow] entries + pub fn calculate(&mut self, report: &CalculatableDynamicReport) -> Section { + let mut calculated_entries = Vec::new(); + + for (entry_idx, entry) in self.entries.iter().enumerate() { + let entry_ref = entry.borrow(); + + match &*entry_ref { + CalculatableDynamicReportEntry::CalculatableSection(section) => { + let updated_section = section.clone().calculate(&report); + + drop(entry_ref); // Drop entry_ref so we can borrow mutably + let mut entry_mut = self.entries[entry_idx].borrow_mut(); + *entry_mut = CalculatableDynamicReportEntry::Section(updated_section.clone()); + + calculated_entries.push(DynamicReportEntry::Section(updated_section)); + } + CalculatableDynamicReportEntry::Section(section) => { + calculated_entries.push(DynamicReportEntry::Section(section.clone())); + } + CalculatableDynamicReportEntry::LiteralRow(row) => { + calculated_entries.push(DynamicReportEntry::LiteralRow(row.clone())); + } + CalculatableDynamicReportEntry::CalculatedRow(row) => { + let updated_row = row.calculate(&report); + + drop(entry_ref); // Drop entry_ref so we can borrow mutably + let mut entry_mut = self.entries[entry_idx].borrow_mut(); + *entry_mut = CalculatableDynamicReportEntry::LiteralRow(updated_row.clone()); + + calculated_entries.push(DynamicReportEntry::LiteralRow(updated_row)); + } + CalculatableDynamicReportEntry::Spacer => (), + } + } + + Section { + text: self.text.clone(), + id: self.id.clone(), + visible: self.visible, + auto_hide: self.auto_hide, + entries: calculated_entries, + } + } + + /// Look up [CalculatableDynamicReportEntry] by id + /// + /// Returns a cloned copy of the [CalculatableDynamicReportEntry]. + pub fn by_id(&self, id: &str) -> Option { + // Manually iterate over self.entries rather than self.entries() + // To catch the situation where entry is already mutably borrowed + for entry in self.entries.iter() { + match entry.try_borrow() { + Ok(entry) => match &*entry { + CalculatableDynamicReportEntry::CalculatableSection(section) => { + if let Some(i) = §ion.id { + if i == id { + return Some(entry.clone()); + } + } + if let Some(e) = section.by_id(id) { + return Some(e); + } + } + CalculatableDynamicReportEntry::Section(_) => todo!(), + CalculatableDynamicReportEntry::LiteralRow(row) => { + if let Some(i) = &row.id { + if i == id { + return Some(entry.clone()); + } + } + } + CalculatableDynamicReportEntry::CalculatedRow(_) => (), + CalculatableDynamicReportEntry::Spacer => (), + }, + Err(err) => panic!( + "Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}", + err + ), + } + } + + None + } + + /// Calculate the subtotals for this [CalculatableSection] + pub fn subtotal(&self, report: &CalculatableDynamicReport) -> Vec { + let mut subtotals = vec![0; report.columns.len()]; + for entry in self.entries.iter() { + match &*entry.borrow() { + CalculatableDynamicReportEntry::CalculatableSection(section) => { + for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() { + subtotals[col_idx] += subtotal; + } + } + CalculatableDynamicReportEntry::Section(section) => { + for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() { + subtotals[col_idx] += subtotal; + } + } + CalculatableDynamicReportEntry::LiteralRow(row) => { + for (col_idx, subtotal) in row.quantity.iter().enumerate() { + subtotals[col_idx] += subtotal; + } + } + CalculatableDynamicReportEntry::CalculatedRow(_) => (), + CalculatableDynamicReportEntry::Spacer => (), + } + } + subtotals + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Section { + pub text: String, + pub id: Option, + pub visible: bool, + pub auto_hide: bool, + pub entries: Vec, +} + +impl Section { + fn auto_hide_children(&mut self) { + self.entries.retain_mut(|e| match e { + DynamicReportEntry::Section(section) => { + section.auto_hide_children(); + if section.can_auto_hide_self() { + false + } else { + true + } + } + DynamicReportEntry::LiteralRow(row) => { + if row.can_auto_hide() { + false + } else { + true + } + } + DynamicReportEntry::Spacer => true, + }); + } + + fn can_auto_hide_self(&self) -> bool { + self.auto_hide + && self.entries.iter().all(|e| match e { + DynamicReportEntry::Section(section) => section.can_auto_hide_self(), + DynamicReportEntry::LiteralRow(row) => row.can_auto_hide(), + DynamicReportEntry::Spacer => true, + }) + } + + /// Look up [DynamicReportEntry] by id + /// + /// Returns a cloned copy of the [DynamicReportEntry]. + pub fn by_id(&self, id: &str) -> Option { + // Manually iterate over self.entries rather than self.entries() + // To catch the situation where entry is already mutably borrowed + for entry in self.entries.iter() { + match entry { + DynamicReportEntry::Section(section) => { + if let Some(i) = §ion.id { + if i == id { + return Some(entry.clone()); + } + } + if let Some(e) = section.by_id(id) { + return Some(e); + } + } + DynamicReportEntry::LiteralRow(row) => { + if let Some(i) = &row.id { + if i == id { + return Some(entry.clone()); + } + } + } + DynamicReportEntry::Spacer => (), + } + } + + None + } + + /// Calculate the subtotals for this [Section] + pub fn subtotal(&self, report: &CalculatableDynamicReport) -> Vec { + let mut subtotals = vec![0; report.columns.len()]; + for entry in self.entries.iter() { + match entry { + DynamicReportEntry::Section(section) => { + for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() { + subtotals[col_idx] += subtotal; + } + } + DynamicReportEntry::LiteralRow(row) => { + for (col_idx, subtotal) in row.quantity.iter().enumerate() { + subtotals[col_idx] += subtotal; + } + } + DynamicReportEntry::Spacer => (), + } + } + subtotals + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LiteralRow { + pub text: String, + pub quantity: Vec, + pub id: Option, + pub visible: bool, + pub auto_hide: bool, + pub link: Option, + pub heading: bool, + pub bordered: bool, +} + +impl LiteralRow { + /// Returns whether the row has auto_hide enabled and all quantities are zero + fn can_auto_hide(&self) -> bool { + self.auto_hide && self.quantity.iter().all(|q| *q == 0) + } +} + +#[derive(Clone, Debug)] +pub struct CalculatedRow { + //pub text: String, + pub calculate_fn: fn(report: &CalculatableDynamicReport) -> LiteralRow, + //pub id: Option, + //pub visible: bool, + //pub auto_hide: bool, + //pub link: Option, + //pub heading: bool, + //pub bordered: bool, +} + +impl CalculatedRow { + fn calculate(&self, report: &CalculatableDynamicReport) -> LiteralRow { + (self.calculate_fn)(report) + } +} + +pub fn entries_for_kind( + kind: &str, + invert: bool, + balances: &Vec<&HashMap>, + kinds_for_account: &HashMap>, +) -> Vec { + // Get accounts of specified kind + let mut accounts = kinds_for_account + .iter() + .filter_map(|(a, k)| { + if k.iter().any(|k| k == kind) { + Some(a) + } else { + None + } + }) + .collect::>(); + + accounts.sort(); + + let mut entries = Vec::new(); + for account in accounts { + let quantities = balances + .iter() + .map(|b| b.get(account).unwrap_or(&0) * if invert { -1 } else { 1 }) + .collect::>(); + + // Some exceptions for the link + let link; + if account == crate::CURRENT_YEAR_EARNINGS { + link = Some("/income-statement".to_string()); + } else if account == crate::RETAINED_EARNINGS { + link = None + } else { + link = Some(format!("/transactions/{}", account)); + } + + let entry = LiteralRow { + text: account.to_string(), + quantity: quantities, + id: None, + visible: true, + auto_hide: true, + link, + heading: false, + bordered: false, + }; + entries.push(CalculatableDynamicReportEntry::LiteralRow(entry)); + } + + entries +} diff --git a/libdrcr/src/reporting/executor.rs b/libdrcr/src/reporting/executor.rs new file mode 100644 index 0000000..dd42c55 --- /dev/null +++ b/libdrcr/src/reporting/executor.rs @@ -0,0 +1,120 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use std::sync::Arc; + +use tokio::{sync::RwLock, task::JoinSet}; + +use super::{ + calculator::{would_be_ready_to_execute, ReportingGraphDependencies}, + types::{ReportingContext, ReportingProducts, ReportingStep}, +}; + +#[derive(Debug)] +pub enum ReportingExecutionError { + DependencyNotAvailable { message: String }, +} + +async fn execute_step( + step_idx: usize, + steps: Arc>>, + dependencies: Arc, + context: Arc, + products: Arc>, +) -> (usize, Result) { + let step = &steps[step_idx]; + let result = step + .execute(&*context, &*steps, &*dependencies, &*products) + .await; + + (step_idx, result) +} + +pub async fn execute_steps( + steps: Vec>, + dependencies: ReportingGraphDependencies, + context: Arc, +) -> Result { + let products = Arc::new(RwLock::new(ReportingProducts::new())); + + // Prepare for async + let steps = Arc::new(steps); + let dependencies = Arc::new(dependencies); + + // Execute steps asynchronously + let mut handles = JoinSet::new(); + let mut steps_done = Vec::new(); + let mut steps_remaining = (0..steps.len()).collect::>(); + + while steps_done.len() != steps.len() { + // Execute each step which is ready to run + for step_idx in steps_remaining.iter().copied().collect::>() { + // Check if ready to run + if would_be_ready_to_execute(&steps[step_idx], &steps, &dependencies, &steps_done) { + // Spawn new task + // Unfortunately the compiler cannot guarantee lifetimes are correct, so we must pass Arc across thread boundaries + handles.spawn(execute_step( + step_idx, + Arc::clone(&steps), + Arc::clone(&dependencies), + Arc::clone(&context), + Arc::clone(&products), + )); + steps_remaining + .remove(steps_remaining.iter().position(|i| *i == step_idx).unwrap()); + } + } + + // Join next result + let (step_idx, result) = handles.join_next().await.unwrap().unwrap(); + let step = &steps[step_idx]; + steps_done.push(step_idx); + + let mut new_products = result?; + + // Sanity check the new products + for (product_id, _product) in new_products.map().iter() { + if product_id.name != step.id().name { + panic!( + "Unexpected product name {} from step {}", + product_id, + step.id() + ); + } + if !step.id().product_kinds.contains(&product_id.kind) { + panic!( + "Unexpected product kind {} from step {}", + product_id, + step.id() + ); + } + if product_id.args != step.id().args { + panic!( + "Unexpected product args {} from step {}", + product_id, + step.id() + ); + } + } + + // Insert the new products + products.write().await.append(&mut new_products); + } + + Ok(Arc::into_inner(products).unwrap().into_inner()) +} diff --git a/libdrcr/src/reporting/mod.rs b/libdrcr/src/reporting/mod.rs new file mode 100644 index 0000000..310e076 --- /dev/null +++ b/libdrcr/src/reporting/mod.rs @@ -0,0 +1,64 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use std::sync::Arc; + +use calculator::{steps_for_targets, ReportingCalculationError}; +use executor::{execute_steps, ReportingExecutionError}; +use types::{ReportingContext, ReportingProductId, ReportingProducts}; + +pub mod builders; +pub mod calculator; +pub mod dynamic_report; +pub mod executor; +pub mod steps; +pub mod types; + +#[derive(Debug)] +pub enum ReportingError { + ReportingCalculationError(ReportingCalculationError), + ReportingExecutionError(ReportingExecutionError), +} + +impl From for ReportingError { + fn from(err: ReportingCalculationError) -> Self { + ReportingError::ReportingCalculationError(err) + } +} + +impl From for ReportingError { + fn from(err: ReportingExecutionError) -> Self { + ReportingError::ReportingExecutionError(err) + } +} + +/// Calculate the steps required to generate the requested [ReportingProductId]s and then execute them +/// +/// Helper function to call [steps_for_targets] followed by [execute_steps]. +pub async fn generate_report( + targets: Vec, + context: Arc, +) -> Result { + // Solve dependencies + let (sorted_steps, dependencies) = steps_for_targets(targets, &*context)?; + + // Execute steps + let products = execute_steps(sorted_steps, dependencies, context).await?; + + Ok(products) +} diff --git a/libdrcr/src/reporting/steps.rs b/libdrcr/src/reporting/steps.rs new file mode 100644 index 0000000..37a9b86 --- /dev/null +++ b/libdrcr/src/reporting/steps.rs @@ -0,0 +1,1729 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +//! This module contains concrete [ReportingStep] implementations + +use std::collections::HashMap; +use std::fmt::Display; + +use async_trait::async_trait; +use chrono::Datelike; +use tokio::sync::RwLock; + +use crate::account_config::kinds_for_account; +use crate::model::transaction::{ + update_balances_from_transactions, Posting, Transaction, TransactionWithPostings, +}; +use crate::reporting::types::{BalancesAt, DateStartDateEndArgs, ReportingProductId, Transactions}; +use crate::util::{get_eofy, sofy_from_eofy}; +use crate::QuantityInt; + +use super::calculator::ReportingGraphDependencies; +use super::dynamic_report::{ + entries_for_kind, CalculatableDynamicReport, CalculatableDynamicReportEntry, + CalculatableSection, CalculatedRow, DynamicReport, DynamicReportEntry, LiteralRow, +}; +use super::executor::ReportingExecutionError; +use super::types::{ + BalancesBetween, DateArgs, MultipleDateArgs, MultipleDateStartDateEndArgs, ReportingContext, + ReportingProductKind, ReportingProducts, ReportingStep, ReportingStepArgs, ReportingStepId, + VoidArgs, +}; + +/// Call [ReportingContext::register_lookup_fn] for all steps provided by this module +pub fn register_lookup_fns(context: &mut ReportingContext) { + AllTransactionsExceptEarningsToEquity::register_lookup_fn(context); + AllTransactionsExceptEarningsToEquityBalances::register_lookup_fn(context); + AllTransactionsIncludingEarningsToEquity::register_lookup_fn(context); + BalanceSheet::register_lookup_fn(context); + CalculateIncomeTax::register_lookup_fn(context); + CombineOrdinaryTransactions::register_lookup_fn(context); + CombineOrdinaryTransactionsBalances::register_lookup_fn(context); + CurrentYearEarningsToEquity::register_lookup_fn(context); + DBBalances::register_lookup_fn(context); + DBTransactions::register_lookup_fn(context); + IncomeStatement::register_lookup_fn(context); + PostUnreconciledStatementLines::register_lookup_fn(context); + RetainedEarningsToEquity::register_lookup_fn(context); + TrialBalance::register_lookup_fn(context); +} + +/// Target representing all transactions except charging current year and retained earnings to equity (returns transaction list) +/// +/// By default, this is [CombineOrdinaryTransactions] and, if requested, [CalculateIncomeTax]. +/// +/// Used as the basis for the income statement. +#[derive(Debug)] +pub struct AllTransactionsExceptEarningsToEquity { + pub args: DateArgs, +} + +impl AllTransactionsExceptEarningsToEquity { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "AllTransactionsExceptEarningsToEquity", + &[ReportingProductKind::Transactions], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(AllTransactionsExceptEarningsToEquity { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for AllTransactionsExceptEarningsToEquity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for AllTransactionsExceptEarningsToEquity { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "AllTransactionsExceptEarningsToEquity", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + // AllTransactionsExceptEarningsToEquity always depends on CombineOrdinaryTransactions at least + vec![ReportingProductId { + name: "CombineOrdinaryTransactions", + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + }] + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + combine_transactions_of_all_dependencies(self.id(), dependencies, products).await + } +} + +/// Target representing all transactions except charging current year and retained earnings to equity (returns balances) +/// +/// By default, this is [CombineOrdinaryTransactions] and, if requested, [CalculateIncomeTax]. +/// +/// Used as the basis for the income statement. +#[derive(Debug)] +pub struct AllTransactionsExceptEarningsToEquityBalances { + pub product_kinds: &'static [ReportingProductKind; 1], // Must have single member - represented as static array for compatibility with ReportingStepId + pub args: Box, +} + +impl AllTransactionsExceptEarningsToEquityBalances { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "AllTransactionsExceptEarningsToEquity", + &[ReportingProductKind::BalancesAt], + Self::takes_args, + |a| Self::from_args(&[ReportingProductKind::BalancesAt], a), + ); + + context.register_lookup_fn( + "AllTransactionsExceptEarningsToEquity", + &[ReportingProductKind::BalancesBetween], + Self::takes_args, + |a| Self::from_args(&[ReportingProductKind::BalancesBetween], a), + ); + } + + fn takes_args(_args: &Box) -> bool { + true + } + + fn from_args( + product_kinds: &'static [ReportingProductKind; 1], + args: Box, + ) -> Box { + Box::new(AllTransactionsExceptEarningsToEquityBalances { + product_kinds, + args, + }) + } +} + +impl Display for AllTransactionsExceptEarningsToEquityBalances { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for AllTransactionsExceptEarningsToEquityBalances { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "AllTransactionsExceptEarningsToEquity", + product_kinds: self.product_kinds, + args: self.args.clone(), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + // AllTransactionsExceptEarningsToEquity always depends on CombineOrdinaryTransactions at least + vec![ReportingProductId { + name: "CombineOrdinaryTransactions", + kind: self.product_kinds[0], + args: self.args.clone(), + }] + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Get all dependencies + let step_dependencies = dependencies.dependencies_for_step(&self.id()); + + // Identify the product_kind dependency most recently generated + // TODO: Make this deterministic - parallel execution may cause the order to vary + let product_kind = self.product_kinds[0]; + + for (product_id, product) in products.map().iter().rev() { + if step_dependencies.iter().any(|d| d.product == *product_id) { + // Store the result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: product_kind, + args: self.args.clone(), + }, + product.clone(), + ); + return Ok(result); + } + } + + // No dependencies?! - this is likely a mistake + panic!( + "Requested {:?} but no available dependencies to provide it", + self.product_kinds[0] + ); + } +} + +/// Target representing all transactions including charging current year and retained earnings to equity +/// +/// In other words, this is [AllTransactionsExceptEarningsToEquity], [CurrentYearEarningsToEquity] and [RetainedEarningsToEquity]. +/// +/// Used as the basis for the balance sheet. +#[derive(Debug)] +pub struct AllTransactionsIncludingEarningsToEquity { + pub args: DateArgs, +} + +impl AllTransactionsIncludingEarningsToEquity { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "AllTransactionsIncludingEarningsToEquity", + &[ReportingProductKind::BalancesAt], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(AllTransactionsIncludingEarningsToEquity { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for AllTransactionsIncludingEarningsToEquity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for AllTransactionsIncludingEarningsToEquity { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "AllTransactionsIncludingEarningsToEquity", + product_kinds: &[ReportingProductKind::BalancesAt], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + vec![ + // AllTransactionsIncludingEarningsToEquity requires AllTransactionsExceptEarningsToEquity + ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + }, + // AllTransactionsIncludingEarningsToEquity requires CurrentYearEarningsToEquity + ReportingProductId { + name: "CurrentYearEarningsToEquity", + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + }, + // AllTransactionsIncludingEarningsToEquity requires RetainedEarningsToEquity + ReportingProductId { + name: "RetainedEarningsToEquity", + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + }, + ] + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Get opening balances from AllTransactionsExceptEarningsToEquity + let opening_balances = products + .get_or_err(&ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + })? + .downcast_ref::() + .unwrap(); + + // Get CurrentYearEarningsToEquity transactions + let transactions_current = products + .get_or_err(&ReportingProductId { + name: "CurrentYearEarningsToEquity", + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + })? + .downcast_ref::() + .unwrap(); + + // Get RetainedEarningsToEquity transactions + let transactions_retained = products + .get_or_err(&ReportingProductId { + name: "RetainedEarningsToEquity", + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + })? + .downcast_ref::() + .unwrap(); + + // Update balances + let mut balances = BalancesAt { + balances: opening_balances.balances.clone(), + }; + update_balances_from_transactions( + &mut balances.balances, + transactions_current.transactions.iter(), + ); + update_balances_from_transactions( + &mut balances.balances, + transactions_retained.transactions.iter(), + ); + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + }, + Box::new(balances), + ); + Ok(result) + } +} + +/// Generates a balance sheet [DynamicReport] +#[derive(Debug)] +pub struct BalanceSheet { + pub args: MultipleDateArgs, +} + +impl BalanceSheet { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "BalanceSheet", + &[ReportingProductKind::Generic], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(BalanceSheet { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for BalanceSheet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for BalanceSheet { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "BalanceSheet", + product_kinds: &[ReportingProductKind::Generic], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + let mut result = Vec::new(); + + // BalanceSheet depends on AllTransactionsIncludingEarningsToEquity in each requested period + for date_args in self.args.dates.iter() { + result.push(ReportingProductId { + name: "AllTransactionsIncludingEarningsToEquity", + kind: ReportingProductKind::BalancesAt, + args: Box::new(date_args.clone()), + }); + } + + result + } + + async fn execute( + &self, + context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Get balances for each period + let mut balances: Vec<&HashMap> = Vec::new(); + for date_args in self.args.dates.iter() { + let product = products.get_or_err(&ReportingProductId { + name: "AllTransactionsIncludingEarningsToEquity", + kind: ReportingProductKind::BalancesAt, + args: Box::new(date_args.clone()), + })?; + + balances.push(&product.downcast_ref::().unwrap().balances); + } + + // Get names of all balance sheet accounts + let kinds_for_account = + kinds_for_account(context.db_connection.get_account_configurations().await); + + // Init report + let report = CalculatableDynamicReport::new( + "Balance sheet".to_string(), + self.args.dates.iter().map(|d| d.date.to_string()).collect(), + vec![ + CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new( + "Assets".to_string(), + Some("assets".to_string()), + true, + false, + { + let mut entries = + entries_for_kind("drcr.asset", false, &balances, &kinds_for_account); + entries.push(CalculatableDynamicReportEntry::CalculatedRow( + CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total assets".to_string(), + quantity: report.subtotal_for_id("assets"), + id: Some("total_assets".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + }, + )); + entries + }, + )), + CalculatableDynamicReportEntry::Spacer, + CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new( + "Liabilities".to_string(), + Some("liabilities".to_string()), + true, + false, + { + let mut entries = + entries_for_kind("drcr.liability", true, &balances, &kinds_for_account); + entries.push(CalculatableDynamicReportEntry::CalculatedRow( + CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total liabilities".to_string(), + quantity: report.subtotal_for_id("liabilities"), + id: Some("total_liabilities".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + }, + )); + entries + }, + )), + CalculatableDynamicReportEntry::Spacer, + CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new( + "Equity".to_string(), + Some("equity".to_string()), + true, + false, + { + let mut entries = + entries_for_kind("drcr.equity", true, &balances, &kinds_for_account); + entries.push(CalculatableDynamicReportEntry::CalculatedRow( + CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total equity".to_string(), + quantity: report.subtotal_for_id("equity"), + id: Some("total_equity".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + }, + )); + entries + }, + )), + ], + ); + + let mut report = report.calculate(); + report.auto_hide(); + + // Store the result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: "BalanceSheet", + kind: ReportingProductKind::Generic, + args: Box::new(self.args.clone()), + }, + Box::new(report), + ); + Ok(result) + } +} + +/// Calculates income tax +#[derive(Debug)] +pub struct CalculateIncomeTax {} + +impl CalculateIncomeTax { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "CalculateIncomeTax", + &[ReportingProductKind::Transactions], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(_args: Box) -> Box { + Box::new(CalculateIncomeTax {}) + } +} + +impl Display for CalculateIncomeTax { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for CalculateIncomeTax { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "CalculateIncomeTax", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(VoidArgs {}), + } + } + + fn requires(&self, context: &ReportingContext) -> Vec { + // CalculateIncomeTax depends on CombineOrdinaryTransactions + vec![ReportingProductId { + name: "CombineOrdinaryTransactions", + kind: ReportingProductKind::BalancesBetween, + args: Box::new(DateStartDateEndArgs { + date_start: sofy_from_eofy(context.eofy_date), + date_end: context.eofy_date.clone(), + }), + }] + } + + fn after_init_graph( + &self, + steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + _context: &ReportingContext, + ) { + for other in steps { + if let Some(other) = + other.downcast_ref::() + { + // AllTransactionsExceptEarningsToEquity depends on CalculateIncomeTax + dependencies.add_dependency( + other.id(), + ReportingProductId { + name: self.id().name, + kind: other.product_kinds[0], + args: if other.product_kinds[0] == ReportingProductKind::Transactions { + Box::new(VoidArgs {}) + } else { + other.id().args + }, + }, + ); + } + } + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _products: &RwLock, + ) -> Result { + eprintln!("Stub: CalculateIncomeTax.execute"); + + let transactions = Transactions { + transactions: Vec::new(), + }; + + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + Box::new(transactions), + ); + Ok(result) + } +} + +/// Combines all steps producing ordinary transactions (returns transaction list) +/// +/// By default, these are [DBTransactions] and [PostUnreconciledStatementLines]. +#[derive(Debug)] +pub struct CombineOrdinaryTransactions { + pub args: DateArgs, +} + +impl CombineOrdinaryTransactions { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "CombineOrdinaryTransactions", + &[ReportingProductKind::Transactions], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(CombineOrdinaryTransactions { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for CombineOrdinaryTransactions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for CombineOrdinaryTransactions { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "CombineOrdinaryTransactions", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + vec![ + // CombineOrdinaryTransactions depends on DBTransactions + ReportingProductId { + name: "DBTransactions", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + // CombineOrdinaryTransactions depends on PostUnreconciledStatementLines + ReportingProductId { + name: "PostUnreconciledStatementLines", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + ] + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + combine_transactions_of_all_dependencies(self.id(), dependencies, products).await + } +} + +/// Combines all steps producing ordinary transactions (returns balances) +/// +/// By default, these are [DBBalances] and [PostUnreconciledStatementLines]. +#[derive(Debug)] +pub struct CombineOrdinaryTransactionsBalances { + pub args: DateArgs, +} + +impl CombineOrdinaryTransactionsBalances { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "CombineOrdinaryTransactions", + &[ReportingProductKind::BalancesAt], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(CombineOrdinaryTransactionsBalances { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for CombineOrdinaryTransactionsBalances { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for CombineOrdinaryTransactionsBalances { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "CombineOrdinaryTransactions", + product_kinds: &[ReportingProductKind::BalancesAt], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + vec![ + // CombineOrdinaryTransactions depends on DBBalances + ReportingProductId { + name: "DBBalances", + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + }, + // CombineOrdinaryTransactions depends on PostUnreconciledStatementLines + ReportingProductId { + name: "PostUnreconciledStatementLines", + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + }, + ] + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Sum balances of all dependencies + + let mut balances = BalancesAt { + balances: HashMap::new(), + }; + + for dependency in dependencies.dependencies_for_step(&self.id()) { + let dependency_balances = &products + .get_or_err(&dependency.product)? + .downcast_ref::() + .unwrap() + .balances; + for (account, balance) in dependency_balances.iter() { + let running_balance = balances.balances.get(account).unwrap_or(&0) + balance; + balances.balances.insert(account.clone(), running_balance); + } + } + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + }, + Box::new(balances), + ); + Ok(result) + } +} + +/// Transfer year-to-date balances in income and expense accounts (as at the requested date) to the current year earnings equity account +#[derive(Debug)] +pub struct CurrentYearEarningsToEquity { + pub args: DateArgs, +} + +impl CurrentYearEarningsToEquity { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "CurrentYearEarningsToEquity", + &[ReportingProductKind::Transactions], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(CurrentYearEarningsToEquity { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for CurrentYearEarningsToEquity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for CurrentYearEarningsToEquity { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "CurrentYearEarningsToEquity", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, context: &ReportingContext) -> Vec { + // CurrentYearEarningsToEquity depends on AllTransactionsExceptEarningsToEquity + vec![ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesBetween, + args: Box::new(DateStartDateEndArgs { + date_start: sofy_from_eofy(get_eofy(&self.args.date, &context.eofy_date)), + date_end: self.args.date, + }), + }] + } + + async fn execute( + &self, + context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Get balances for this financial year + let balances = products + .get_or_err(&ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesBetween, + args: Box::new(DateStartDateEndArgs { + date_start: sofy_from_eofy(get_eofy(&self.args.date, &context.eofy_date)), + date_end: self.args.date, + }), + })? + .downcast_ref::() + .unwrap(); + + // Get income and expense accounts + let kinds_for_account = + kinds_for_account(context.db_connection.get_account_configurations().await); + + // Transfer income and expense balances to current year earnings + let mut transactions = Transactions { + transactions: Vec::new(), + }; + + for (account, balance) in balances.balances.iter() { + if let Some(kinds) = kinds_for_account.get(account) { + if kinds + .iter() + .any(|k| k == "drcr.income" || k == "drcr.expense") + { + transactions.transactions.push(TransactionWithPostings { + transaction: Transaction { + id: None, + dt: self.args.date.and_hms_opt(0, 0, 0).unwrap(), + description: "Current year earnings".to_string(), + }, + postings: vec![ + Posting { + id: None, + transaction_id: None, + description: None, + account: account.clone(), + quantity: -balance, + commodity: context.reporting_commodity.clone(), + quantity_ascost: None, + }, + Posting { + id: None, + transaction_id: None, + description: None, + account: crate::CURRENT_YEAR_EARNINGS.to_string(), + quantity: *balance, + commodity: context.reporting_commodity.clone(), + quantity_ascost: None, + }, + ], + }) + } + } + } + + // Store product + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + }, + Box::new(transactions), + ); + Ok(result) + } +} + +/// Look up account balances from the database +#[derive(Debug)] +pub struct DBBalances { + pub args: DateArgs, +} + +impl DBBalances { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "DBBalances", + &[ReportingProductKind::BalancesAt], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(DBBalances { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for DBBalances { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for DBBalances { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "DBBalances", + product_kinds: &[ReportingProductKind::BalancesAt], + args: Box::new(self.args.clone()), + } + } + + async fn execute( + &self, + context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _products: &RwLock, + ) -> Result { + // Get balances from DB + let balances = BalancesAt { + balances: context.db_connection.get_balances(self.args.date).await, + }; + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + }, + Box::new(balances), + ); + Ok(result) + } +} + +/// Look up transactions from the database +#[derive(Debug)] +pub struct DBTransactions {} + +impl DBTransactions { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "DBTransactions", + &[ReportingProductKind::Transactions], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(_args: Box) -> Box { + Box::new(DBTransactions {}) + } +} + +impl Display for DBTransactions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for DBTransactions { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "DBTransactions", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(VoidArgs {}), + } + } + + async fn execute( + &self, + context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _products: &RwLock, + ) -> Result { + // Get transactions from DB + let transactions = Transactions { + transactions: context.db_connection.get_transactions().await, + }; + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + Box::new(transactions), + ); + Ok(result) + } +} + +/// Generates an income statement [DynamicReport] +#[derive(Debug)] +pub struct IncomeStatement { + pub args: MultipleDateStartDateEndArgs, +} + +impl IncomeStatement { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "IncomeStatement", + &[ReportingProductKind::Generic], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(IncomeStatement { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for IncomeStatement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for IncomeStatement { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "IncomeStatement", + product_kinds: &[ReportingProductKind::Generic], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + let mut result = Vec::new(); + + // IncomeStatement depends on AllTransactionsExceptEarningsToEquity in each requested period + for date_args in self.args.dates.iter() { + result.push(ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesBetween, + args: Box::new(date_args.clone()), + }); + } + + result + } + + async fn execute( + &self, + context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Get balances for each period + let mut balances: Vec<&HashMap> = Vec::new(); + for date_args in self.args.dates.iter() { + let product = products.get_or_err(&ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesBetween, + args: Box::new(date_args.clone()), + })?; + + balances.push(&product.downcast_ref::().unwrap().balances); + } + + // Get names of all income statement accounts + let kinds_for_account = + kinds_for_account(context.db_connection.get_account_configurations().await); + + // Init report + let report = CalculatableDynamicReport::new( + "Income statement".to_string(), + self.args + .dates + .iter() + .map(|d| d.date_end.to_string()) + .collect(), + vec![ + CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new( + "Income".to_string(), + Some("income".to_string()), + true, + false, + { + let mut entries = + entries_for_kind("drcr.income", true, &balances, &kinds_for_account); + entries.push(CalculatableDynamicReportEntry::CalculatedRow( + CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total income".to_string(), + quantity: report.subtotal_for_id("income"), + id: Some("total_income".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + }, + )); + entries + }, + )), + CalculatableDynamicReportEntry::Spacer, + CalculatableDynamicReportEntry::CalculatableSection(CalculatableSection::new( + "Expenses".to_string(), + Some("expenses".to_string()), + true, + false, + { + let mut entries = + entries_for_kind("drcr.expense", false, &balances, &kinds_for_account); + entries.push(CalculatableDynamicReportEntry::CalculatedRow( + CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total expenses".to_string(), + quantity: report.subtotal_for_id("expenses"), + id: Some("total_expenses".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + }, + )); + entries + }, + )), + CalculatableDynamicReportEntry::Spacer, + CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Net surplus (deficit)".to_string(), + quantity: report + .quantity_for_id("total_income") // Get total income row + .iter() + .zip(report.quantity_for_id("total_expenses").iter()) // Zip with total expenses row + .map(|(i, e)| i - e) // Compute net surplus + .collect(), + id: Some("net_surplus".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + }), + ], + ); + + let mut report = report.calculate(); + report.auto_hide(); + + // Store the result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: "IncomeStatement", + kind: ReportingProductKind::Generic, + args: Box::new(self.args.clone()), + }, + Box::new(report), + ); + Ok(result) + } +} + +/// Generate transactions for unreconciled statement lines +#[derive(Debug)] +pub struct PostUnreconciledStatementLines {} + +impl PostUnreconciledStatementLines { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "PostUnreconciledStatementLines", + &[ReportingProductKind::Transactions], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(_args: Box) -> Box { + Box::new(PostUnreconciledStatementLines {}) + } +} + +impl Display for PostUnreconciledStatementLines { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for PostUnreconciledStatementLines { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "PostUnreconciledStatementLines", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(VoidArgs {}), + } + } + + async fn execute( + &self, + context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _products: &RwLock, + ) -> Result { + let unreconciled_statement_lines = context + .db_connection + .get_unreconciled_statement_lines() + .await; + + // Post unreconciled statement lines + let mut transactions = Transactions { + transactions: Vec::new(), + }; + + for line in unreconciled_statement_lines { + let unclassified_account = if line.quantity >= 0 { + "Unclassified Statement Line Debits" + } else { + "Unclassified Statement Line Credits" + }; + transactions.transactions.push(TransactionWithPostings { + transaction: Transaction { + id: None, + dt: line.dt, + description: line.description.clone(), + }, + postings: vec![ + Posting { + id: None, + transaction_id: None, + description: None, + account: line.source_account.clone(), + quantity: line.quantity, + commodity: line.commodity.clone(), + quantity_ascost: None, + }, + Posting { + id: None, + transaction_id: None, + description: None, + account: unclassified_account.to_string(), + quantity: -line.quantity, + commodity: line.commodity.clone(), + quantity_ascost: None, + }, + ], + }); + } + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + Box::new(transactions), + ); + Ok(result) + } +} + +/// Transfer historical balances in income and expense accounts to the retained earnings equity account +#[derive(Debug)] +pub struct RetainedEarningsToEquity { + pub args: DateArgs, +} + +impl RetainedEarningsToEquity { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "RetainedEarningsToEquity", + &[ReportingProductKind::Transactions], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(RetainedEarningsToEquity { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for RetainedEarningsToEquity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for RetainedEarningsToEquity { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "RetainedEarningsToEquity", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, context: &ReportingContext) -> Vec { + let eofy_date = get_eofy(&self.args.date, &context.eofy_date); + let last_eofy_date = eofy_date.with_year(eofy_date.year() - 1).unwrap(); + + // RetainedEarningsToEquity depends on CombineOrdinaryTransactions for last financial year + vec![ReportingProductId { + name: "CombineOrdinaryTransactions", + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: last_eofy_date, + }), + }] + } + + async fn execute( + &self, + context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + let eofy_date = get_eofy(&self.args.date, &context.eofy_date); + let last_eofy_date = eofy_date.with_year(eofy_date.year() - 1).unwrap(); + + // Get balances at end of last financial year + let balances_last_eofy = products + .get_or_err(&ReportingProductId { + name: "CombineOrdinaryTransactions", + kind: ReportingProductKind::BalancesAt, + args: Box::new(DateArgs { + date: last_eofy_date.clone(), + }), + })? + .downcast_ref::() + .unwrap(); + + // Get income and expense accounts + let kinds_for_account = + kinds_for_account(context.db_connection.get_account_configurations().await); + + // Transfer income and expense balances to retained earnings + let mut transactions = Transactions { + transactions: Vec::new(), + }; + + for (account, balance) in balances_last_eofy.balances.iter() { + if let Some(kinds) = kinds_for_account.get(account) { + if kinds + .iter() + .any(|k| k == "drcr.income" || k == "drcr.expense") + { + transactions.transactions.push(TransactionWithPostings { + transaction: Transaction { + id: None, + dt: last_eofy_date.and_hms_opt(0, 0, 0).unwrap(), + description: "Retained earnings".to_string(), + }, + postings: vec![ + Posting { + id: None, + transaction_id: None, + description: None, + account: account.clone(), + quantity: -balance, + commodity: context.reporting_commodity.clone(), + quantity_ascost: None, + }, + Posting { + id: None, + transaction_id: None, + description: None, + account: crate::RETAINED_EARNINGS.to_string(), + quantity: *balance, + commodity: context.reporting_commodity.clone(), + quantity_ascost: None, + }, + ], + }) + } + } + } + + // Store product + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + }, + Box::new(transactions), + ); + Ok(result) + } +} + +/// Generates a trial balance [DynamicReport] +#[derive(Debug)] +pub struct TrialBalance { + pub args: DateArgs, +} + +impl TrialBalance { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "TrialBalance", + &[ReportingProductKind::Generic], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(TrialBalance { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for TrialBalance { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for TrialBalance { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "TrialBalance", + product_kinds: &[ReportingProductKind::Generic], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + let mut result = Vec::new(); + + // TrialBalance depends on AllTransactionsExceptEarningsToEquity at the requested date + result.push(ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + }); + + result + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + let products = products.read().await; + + // Get balances for each period + let balances = &products + .get_or_err(&ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesAt, + args: Box::new(self.args.clone()), + })? + .downcast_ref::() + .unwrap() + .balances; + + // Get sorted list of accounts + let mut accounts = balances.keys().collect::>(); + accounts.sort(); + + // Get total debits and credits + let total_dr = balances.values().filter(|b| **b >= 0).sum::(); + let total_cr = -balances.values().filter(|b| **b < 0).sum::(); + + // Init report + let mut report = DynamicReport::new( + "Trial balance".to_string(), + vec!["Dr".to_string(), "Cr".to_string()], + { + let mut entries = Vec::new(); + + // Entry for each account + for account in accounts { + entries.push(DynamicReportEntry::LiteralRow(LiteralRow { + text: account.clone(), + quantity: vec![ + // Dr cell + if balances[account] >= 0 { + balances[account] + } else { + 0 + }, + // Cr cell + if balances[account] < 0 { + -balances[account] + } else { + 0 + }, + ], + id: None, + visible: true, + auto_hide: true, + link: Some(format!("/transactions/{}", account)), + heading: false, + bordered: false, + })); + } + + // Total row + entries.push(DynamicReportEntry::LiteralRow(LiteralRow { + text: "Totals".to_string(), + quantity: vec![total_dr, total_cr], + id: Some("totals".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + })); + + entries + }, + ); + + report.auto_hide(); + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: "TrialBalance", + kind: ReportingProductKind::Generic, + args: Box::new(self.args.clone()), + }, + Box::new(report), + ); + Ok(result) + } +} + +/// Combines the transactions of all dependencies and returns [Transactions] as [ReportingProducts] for the given step +/// +/// Used to implement [CombineOrdinaryTransactions] and [AllTransactionsExceptEarningsToEquity]. +async fn combine_transactions_of_all_dependencies( + step_id: ReportingStepId, + dependencies: &ReportingGraphDependencies, + products: &RwLock, +) -> Result { + let products = products.read().await; + + // Combine transactions of all dependencies + + let mut transactions = Transactions { + transactions: Vec::new(), + }; + + for dependency in dependencies.dependencies_for_step(&step_id) { + let dependency_transactions = &products + .get_or_err(&dependency.product)? + .downcast_ref::() + .unwrap() + .transactions; + + for transaction in dependency_transactions.iter() { + transactions.transactions.push(transaction.clone()); + } + } + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: step_id.name, + kind: ReportingProductKind::Transactions, + args: step_id.args, + }, + Box::new(transactions), + ); + + Ok(result) +} diff --git a/libdrcr/src/reporting/types.rs b/libdrcr/src/reporting/types.rs new file mode 100644 index 0000000..13891c2 --- /dev/null +++ b/libdrcr/src/reporting/types.rs @@ -0,0 +1,431 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use std::collections::HashMap; +use std::fmt::{Debug, Display}; +use std::hash::Hash; + +use async_trait::async_trait; +use chrono::NaiveDate; +use downcast_rs::Downcast; +use dyn_clone::DynClone; +use dyn_eq::DynEq; +use dyn_hash::DynHash; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use crate::db::DbConnection; +use crate::model::transaction::TransactionWithPostings; +use crate::QuantityInt; + +use super::calculator::ReportingGraphDependencies; +use super::executor::ReportingExecutionError; + +// ----------------- +// REPORTING CONTEXT + +/// Records the context for a single reporting job +pub struct ReportingContext { + // Configuration + pub db_connection: DbConnection, + pub eofy_date: NaiveDate, + pub reporting_commodity: String, + + // State + pub(crate) step_lookup_fn: HashMap< + (&'static str, &'static [ReportingProductKind]), + (ReportingStepTakesArgsFn, ReportingStepFromArgsFn), + >, + pub(crate) step_dynamic_builders: Vec, +} + +impl ReportingContext { + /// Initialise a new [ReportingContext] + pub fn new( + db_connection: DbConnection, + eofy_date: NaiveDate, + reporting_commodity: String, + ) -> Self { + Self { + db_connection, + eofy_date, + reporting_commodity, + step_lookup_fn: HashMap::new(), + step_dynamic_builders: Vec::new(), + } + } + + /// Register a lookup function + /// + /// A lookup function generates concrete [ReportingStep]s from a [ReportingStepId]. + pub fn register_lookup_fn( + &mut self, + name: &'static str, + product_kinds: &'static [ReportingProductKind], + takes_args_fn: ReportingStepTakesArgsFn, + from_args_fn: ReportingStepFromArgsFn, + ) { + self.step_lookup_fn + .insert((name, product_kinds), (takes_args_fn, from_args_fn)); + } + + /// Register a dynamic builder + /// + /// Dynamic builders are called when no concrete [ReportingStep] is implemented, and can dynamically generate a [ReportingStep]. Dynamic builders are implemented in [super::builders]. + pub fn register_dynamic_builder(&mut self, builder: ReportingStepDynamicBuilder) { + if !self + .step_dynamic_builders + .iter() + .any(|b| b.name == builder.name) + { + self.step_dynamic_builders.push(builder); + } + } +} + +/// Function which determines whether the [ReportingStepArgs] are valid arguments for a given [ReportingStep] +/// +/// See [ReportingContext::register_lookup_fn]. +pub type ReportingStepTakesArgsFn = fn(args: &Box) -> bool; + +/// Function which builds a concrete [ReportingStep] from the given [ReportingStepArgs] +/// +/// See [ReportingContext::register_lookup_fn]. +pub type ReportingStepFromArgsFn = fn(args: Box) -> Box; + +// ------------------------------- +// REPORTING STEP DYNAMIC BUILDERS + +/// Represents a reporting step dynamic builder +/// +/// See [ReportingContext::register_dynamic_builder]. +pub struct ReportingStepDynamicBuilder { + pub name: &'static str, + pub can_build: fn( + name: &'static str, + kind: ReportingProductKind, + args: &Box, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + context: &ReportingContext, + ) -> bool, + pub build: fn( + name: &'static str, + kind: ReportingProductKind, + args: Box, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + context: &ReportingContext, + ) -> Box, +} + +// ------------------ +// REPORTING PRODUCTS + +/// Identifies a [ReportingProduct] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ReportingProductId { + pub name: &'static str, + pub kind: ReportingProductKind, + pub args: Box, +} + +impl Display for ReportingProductId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}.{:?}({})", self.name, self.kind, self.args)) + } +} + +/// Identifies a type of [ReportingProduct] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ReportingProductKind { + Transactions, + BalancesAt, + BalancesBetween, + Generic, +} + +/// Represents the result of a [ReportingStep] +pub trait ReportingProduct: Debug + Downcast + DynClone + Send + Sync {} + +downcast_rs::impl_downcast!(ReportingProduct); +dyn_clone::clone_trait_object!(ReportingProduct); + +/// Records a list of transactions generated by a [ReportingStep] +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Transactions { + pub transactions: Vec, +} + +impl ReportingProduct for Transactions {} + +/// Records cumulative account balances at a particular point in time +#[derive(Clone, Debug)] +pub struct BalancesAt { + pub balances: HashMap, +} + +impl ReportingProduct for BalancesAt {} + +/// Records the total value of transactions in each account between two points in time +#[derive(Clone, Debug)] +pub struct BalancesBetween { + pub balances: HashMap, +} + +impl ReportingProduct for BalancesBetween {} + +/// Map from [ReportingProductId] to [ReportingProduct] +#[derive(Clone, Debug)] +pub struct ReportingProducts { + // This needs to be an IndexMap not HashMap, because sometimes we query which product is more up to date + map: IndexMap>, +} + +impl ReportingProducts { + pub fn new() -> Self { + Self { + map: IndexMap::new(), + } + } + + /// Returns a reference to the underlying [IndexMap] + pub fn map(&self) -> &IndexMap> { + &self.map + } + + /// Insert a key-value pair in the map + /// + /// See [IndexMap::insert]. + pub fn insert(&mut self, key: ReportingProductId, value: Box) { + self.map.insert(key, value); + } + + /// Moves all key-value pairs from `other` into `self`, leaving `other` empty + /// + /// See [IndexMap::append]. + pub fn append(&mut self, other: &mut ReportingProducts) { + self.map.append(&mut other.map); + } + + pub fn get_or_err( + &self, + key: &ReportingProductId, + ) -> Result<&Box, ReportingExecutionError> { + match self.map.get(key) { + Some(value) => Ok(value), + None => Err(ReportingExecutionError::DependencyNotAvailable { + message: format!("Product {} not available when expected", key), + }), + } + } + + pub fn get_owned_or_err( + mut self, + key: &ReportingProductId, + ) -> Result, ReportingExecutionError> { + match self.map.swap_remove(key) { + Some(value) => Ok(value), + None => Err(ReportingExecutionError::DependencyNotAvailable { + message: format!("Product {} not available when expected", key), + }), + } + } +} + +impl Display for ReportingProducts { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "ReportingProducts {{\n{}\n}}", + self.map + .iter() + .map(|(k, v)| format!(" {}: {:?}", k, v)) + .collect::>() + .join(",\n") + )) + } +} + +// --------------- +// REPORTING STEPS + +/// Identifies a [ReportingStep] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReportingStepId { + pub name: &'static str, + pub product_kinds: &'static [ReportingProductKind], + pub args: Box, +} + +impl Display for ReportingStepId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{}{:?}({})", + self.name, self.product_kinds, self.args + )) + } +} + +/// Represents a step in a reporting job +#[async_trait] +pub trait ReportingStep: Debug + Display + Downcast + Send + Sync { + /// Get the [ReportingStepId] for this [ReportingStep] + fn id(&self) -> ReportingStepId; + + /// Return a list of statically defined dependencies for this [ReportingStep] + #[allow(unused_variables)] + fn requires(&self, context: &ReportingContext) -> Vec { + vec![] + } + + /// Called when the [ReportingStep] is initialised in [super::calculator::steps_for_targets] + #[allow(unused_variables)] + fn init_graph( + &self, + steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + context: &ReportingContext, + ) { + } + + /// Called when new [ReportingStep]s are initialised in [super::calculator::steps_for_targets] + /// + /// This callback can be used to dynamically declare dependencies between [ReportingStep]s that are not known at initialisation. + #[allow(unused_variables)] + fn after_init_graph( + &self, + steps: &Vec>, + dependencies: &mut ReportingGraphDependencies, + context: &ReportingContext, + ) { + } + + /// Called to generate the [ReportingProduct] for this [ReportingStep] + /// + /// Returns a [ReportingProducts] containing (only) the new [ReportingProduct]s. + #[allow(unused_variables)] + async fn execute( + &self, + context: &ReportingContext, + steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + todo!("{}", self); + } +} + +downcast_rs::impl_downcast!(ReportingStep); + +// ------------------------ +// REPORTING STEP ARGUMENTS + +/// Represents arguments to a [ReportingStep] +pub trait ReportingStepArgs: + Debug + Display + Downcast + DynClone + DynEq + DynHash + Send + Sync +{ +} + +downcast_rs::impl_downcast!(ReportingStepArgs); +dyn_clone::clone_trait_object!(ReportingStepArgs); +dyn_eq::eq_trait_object!(ReportingStepArgs); +dyn_hash::hash_trait_object!(ReportingStepArgs); + +/// [ReportingStepArgs] implementation which takes no arguments +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct VoidArgs {} + +impl ReportingStepArgs for VoidArgs {} + +impl Display for VoidArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("")) + } +} + +/// [ReportingStepArgs] implementation which takes a single date +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct DateArgs { + pub date: NaiveDate, +} + +impl ReportingStepArgs for DateArgs {} + +impl Display for DateArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.date)) + } +} + +/// [ReportingStepArgs] implementation which takes a date range +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct DateStartDateEndArgs { + pub date_start: NaiveDate, + pub date_end: NaiveDate, +} + +impl ReportingStepArgs for DateStartDateEndArgs {} + +impl Display for DateStartDateEndArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}, {}", self.date_start, self.date_end)) + } +} + +/// [ReportingStepArgs] implementation which takes multiple [DateArgs] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct MultipleDateArgs { + pub dates: Vec, +} + +impl ReportingStepArgs for MultipleDateArgs {} + +impl Display for MultipleDateArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{}", + self.dates + .iter() + .map(|a| a.to_string()) + .collect::>() + .join(", ") + )) + } +} + +/// [ReportingStepArgs] implementation which takes multiple [DateStartDateEndArgs] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct MultipleDateStartDateEndArgs { + pub dates: Vec, +} + +impl ReportingStepArgs for MultipleDateStartDateEndArgs {} + +impl Display for MultipleDateStartDateEndArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{}", + self.dates + .iter() + .map(|a| format!("({})", a)) + .collect::>() + .join(", ") + )) + } +} diff --git a/libdrcr/src/serde.rs b/libdrcr/src/serde.rs new file mode 100644 index 0000000..bbeaafb --- /dev/null +++ b/libdrcr/src/serde.rs @@ -0,0 +1,62 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +/// Serialises [chrono::NaiveDateTime] in database format +/// +/// Use as `#[serde(with = "crate::serde::naivedatetime_to_js")]`, etc. +pub mod naivedatetime_to_js { + use std::fmt; + + use chrono::NaiveDateTime; + use serde::{ + de::{self, Unexpected, Visitor}, + Deserializer, Serializer, + }; + + pub(crate) fn serialize( + dt: &NaiveDateTime, + serializer: S, + ) -> Result { + serializer.serialize_str(&dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string()) + } + + struct DateVisitor; + impl<'de> Visitor<'de> for DateVisitor { + type Value = NaiveDateTime; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a date string") + } + + fn visit_str(self, s: &str) -> Result + where + E: de::Error, + { + match NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.6f") { + Ok(dt) => Ok(dt), + Err(_) => Err(de::Error::invalid_value(Unexpected::Str(s), &self)), + } + } + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + deserializer.deserialize_str(DateVisitor) + } +} diff --git a/libdrcr/src/util.rs b/libdrcr/src/util.rs new file mode 100644 index 0000000..1d6102b --- /dev/null +++ b/libdrcr/src/util.rs @@ -0,0 +1,43 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use chrono::{Datelike, NaiveDate}; + +/// Return the end date of the current financial year for the given date +pub fn get_eofy(date: &NaiveDate, eofy_date: &NaiveDate) -> NaiveDate { + let date_eofy = eofy_date.with_year(date.year()).unwrap(); + if date_eofy >= *date { + date_eofy + } else { + date_eofy.with_year(date_eofy.year() + 1).unwrap() + } +} + +/// Return the start date of the financial year, given the end date of the financial year +pub fn sofy_from_eofy(eofy_date: NaiveDate) -> NaiveDate { + eofy_date + .with_year(eofy_date.year() - 1) + .unwrap() + .succ_opt() + .unwrap() +} + +/// Format the [NaiveDate] as a string +pub fn format_date(date: NaiveDate) -> String { + date.format("%Y-%m-%d 00:00:00.000000").to_string() +}