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