diff --git a/THIRD_PARTY_LICENSES_RUST.txt b/THIRD_PARTY_LICENSES_RUST.txt index 98314dd..469f992 100644 --- a/THIRD_PARTY_LICENSES_RUST.txt +++ b/THIRD_PARTY_LICENSES_RUST.txt @@ -115,6 +115,10 @@ brotli-decompressor 5.0.3 License: BSD-3-Clause OR MIT Source: https://github.com/dropbox/rust-brotli-decompressor +bumpalo 3.20.3 +License: MIT OR Apache-2.0 +Source: https://github.com/fitzgen/bumpalo + byteorder 1.5.0 License: Unlicense OR MIT Source: https://github.com/BurntSushi/byteorder @@ -903,6 +907,10 @@ rfd 0.17.2 License: MIT Source: https://github.com/PolyMeilex/rfd +rust_xlsxwriter 0.97.1 +License: MIT OR Apache-2.0 +Source: https://github.com/jmcnamara/rust_xlsxwriter + rustc-hash 2.1.3 License: Apache-2.0 OR MIT Source: https://github.com/rust-lang/rustc-hash @@ -1299,6 +1307,10 @@ try-lock 0.2.5 License: MIT Source: https://github.com/seanmonstar/try-lock +typed-path 0.12.3 +License: MIT OR Apache-2.0 +Source: https://github.com/chipsenkbeil/typed-path + typeid 1.0.3 License: MIT OR Apache-2.0 Source: https://github.com/dtolnay/typeid @@ -1563,10 +1575,22 @@ zerovec-derive 0.11.4 License: Unicode-3.0 Source: https://github.com/unicode-org/icu4x +zip 8.6.0 +License: MIT +Source: https://github.com/zip-rs/zip2 + +zlib-rs 0.6.7 +License: Zlib +Source: https://github.com/trifectatechfoundation/zlib-rs + zmij 1.0.23 License: MIT Source: https://github.com/dtolnay/zmij +zopfli 0.8.3 +License: Apache-2.0 +Source: https://github.com/zopfli-rs/zopfli + License texts and notices ------------------------- BSD Zero Clause License @@ -2444,6 +2468,213 @@ Apache License 2.0 limitations under the License. +------------------------------------------------------------------------------- +Apache License 2.0 +- zopfli 0.8.3 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2011 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ------------------------------------------------------------------------------- Apache License 2.0 - crc-catalog 2.5.0 @@ -2651,6 +2882,213 @@ Apache License 2.0 limitations under the License. +------------------------------------------------------------------------------- +Apache License 2.0 +- rust_xlsxwriter 0.97.1 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022-2026 John McNamara + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ------------------------------------------------------------------------------- Apache License 2.0 - zerocopy 0.8.56 @@ -6827,6 +7265,7 @@ Apache License 2.0 - base64 0.22.1 - bitflags 1.3.2 - bitflags 2.13.1 +- bumpalo 3.20.3 - camino 1.2.5 - cc 1.4.2 - cfg-if 1.0.4 @@ -9878,6 +10317,7 @@ Apache License 2.0 - time-core 0.1.9 - time-macros 0.2.32 - time 0.3.55 +- typed-path 0.12.3 - typeid 1.0.3 - unic-char-property 0.9.0 - unic-char-range 0.9.0 @@ -11493,6 +11933,33 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------- +MIT License +- zip 8.6.0 + +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ------------------------------------------------------------------------------- MIT License - phf 0.13.1 @@ -13120,6 +13587,31 @@ Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. +------------------------------------------------------------------------------- +zlib License +- zlib-rs 0.6.7 + +(C) 2024 Trifecta Tech Foundation + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. + + ------------------------------------------------------------------------------- zlib License - foldhash 0.2.0 diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 5edf5e2..5676e47 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "2df5d7ae948cf4a53a183fa7683c5ab26fc856bc581cbc691401a6572849682b", + "compatibility_surface_sha256": "431d9bdd19da89efafb9397f01a55b368840077f05a3fc11ae16833645d0d5e6", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 4b85648..c8e005b 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -95,11 +95,11 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "e1f2c3c2d2cc09a5f78a22874bd32942e93a935d54db51001ca4568fe4ab6fc7" + "sha256": "15d064b88c4d2cb68bcbc8a727c55702f2ad9b894363be8f8f945f79c9536357" }, { "path": "src-tauri/Cargo.toml", - "sha256": "00843c54594b173fe5524d4236f0a9842773eb06d2efe5da88c81089ff8ac8f1" + "sha256": "5c809e9170457a41ab18de4188c0af9ff06cc741e0facd293ae26f0949de2351" }, { "path": "src-tauri/crates/bridge-tally-core/Cargo.toml", @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "9ec65e68c073cc113510a5cfb11b7b973e0f8a5cc50e8ceb98954a3304e99ab4" + "sha256": "3fd0e744e22a4078694e7c80b538a293bf4921776393d160c1d209078f03812c" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -303,7 +303,7 @@ }, { "path": "src-tauri/src/lib.rs", - "sha256": "5856c632b3b1c51a3d4e7b6cd408503348c487b653abe32ce71f0e5d18c6e34b" + "sha256": "33bdd1cf9cf4a1e945db9b7eaec0f037b1122e5bafd013c208254b9ad3817196" }, { "path": "src-tauri/src/sync/coordinator.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "2df5d7ae948cf4a53a183fa7683c5ab26fc856bc581cbc691401a6572849682b" + "manifest_sha256": "431d9bdd19da89efafb9397f01a55b368840077f05a3fc11ae16833645d0d5e6" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ad037c5..4e9cde4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -422,6 +422,7 @@ dependencies = [ "quick-xml", "reqwest", "rfd", + "rust_xlsxwriter", "serde", "serde_json", "sha2 0.11.0", @@ -1413,6 +1414,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -3805,6 +3807,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust_xlsxwriter" +version = "0.97.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "210c9bee5c2d1df2c3a4fa1fccf3647a37bea205a35d5775491731b271bff19b" +dependencies = [ + "zip", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -5471,6 +5482,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -6601,12 +6618,44 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.14.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 934cd24..4769b85 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -69,6 +69,7 @@ quick-xml = { version = "0.41", features = ["serialize"] } reqwest = { version = "0.13", features = ["json", "stream"] } # Bridge supports Windows and macOS; rfd's defaults add Linux portal/Wayland code. rfd = { version = "0.17", default-features = false } +rust_xlsxwriter = { version = "0.97.1", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7ea9557..81ea4f6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,6 +9,8 @@ use crate::db::tally_mirror::{ WriteFixtureEnrollmentInput, WriteFixtureEnrollmentStatus, }; use crate::gst::{GstDraftRequest, GstReturnDraft}; +use crate::reports::party_statement::{build_party_statement, PartyStatementError}; +use crate::reports::party_statement_xlsx::render_party_statement_xlsx; use crate::sync::coordinator::{SnapshotCoordinator, SnapshotJobStatus}; use crate::sync::reconciliation::ExternalReferenceCatalog; use crate::sync::snapshot::{ @@ -21,11 +23,11 @@ use crate::tally::validators::{ }; use crate::tally::{ company_source_identity, core_snapshot_start_authorized, source_lineage, - CachedProbeReservation, ConnectionStatus, EndpointKey, OutstandingsCurrencyAssertion, - OutstandingsLoadResult, RuntimeTallyConnector, SelectedReadObservation, - SelectedReadScopeEvidence, TallyCompany, TallyConfig, TallyLedger, TallyRuntime, - TallySessionSnapshot, TallyTelemetryPreviewExport, TallyVoucher, - SELECTED_LEDGER_QUERY_PROFILE_ID, SELECTED_VOUCHER_QUERY_PROFILE_ID, + CachedProbeReservation, ConnectionStatus, EndpointKey, OpenBillRow, + OutstandingsCurrencyAssertion, OutstandingsLoadResult, RuntimeTallyConnector, + SelectedReadObservation, SelectedReadScopeEvidence, TallyCompany, TallyConfig, TallyLedger, + TallyRuntime, TallySessionSnapshot, TallyTelemetryPreviewExport, TallyVoucher, + UnallocatedParty, SELECTED_LEDGER_QUERY_PROFILE_ID, SELECTED_VOUCHER_QUERY_PROFILE_ID, }; use bridge_tally_core::{ CapabilityEvidence, CapabilityFeatureId, CapabilityPackId, CapabilityState, @@ -2839,6 +2841,218 @@ pub async fn reveal_exported_file(path: String) -> Result<(), String> { .map_err(|error| format!("Bridge could not open the folder: {error}")) } +/// Wire counterpart of [`OpenBillRow`] for this command's argument only. +/// +/// `OpenBillRow::kind` is `&'static str`, which makes `OpenBillRow` itself +/// unable to derive `Deserialize` -- embedding it in another struct's derive +/// would need a `'de: 'static` bound the Tauri IPC deserializer (borrowing +/// from a short-lived request buffer) cannot satisfy. This struct carries +/// `kind` as a plain `String` instead and is validated into an `OpenBillRow` +/// by `into_open_bill_row` below. +#[derive(Debug, Deserialize)] +pub struct OpenBillRowInput { + pub party: String, + pub reference: String, + pub bill_date: String, + pub due_date: String, + pub amount: bridge_tally_core::ExactDecimal, + pub age_days: Option, + pub kind: String, +} + +fn into_open_bill_row(input: OpenBillRowInput) -> Result { + let kind: &'static str = match input.kind.as_str() { + "receivable" => "receivable", + "payable" => "payable", + other => { + return Err(format!( + "Bridge received an unrecognised bill kind ({other}) and could not build the statement." + )) + } + }; + Ok(OpenBillRow { + party: input.party, + reference: input.reference, + bill_date: input.bill_date, + due_date: input.due_date, + amount: input.amount, + age_days: input.age_days, + kind, + }) +} + +#[derive(Debug, Deserialize)] +pub struct ExportPartyStatementRequest { + pub company: String, + pub as_of_yyyymmdd: String, + pub party: String, + /// The `open_bills`/`unallocated_by_party` rows the frontend already + /// holds from `fetch_tally_outstandings`. This command reads no Tally + /// endpoint of its own -- `OutstandingsLoadResult::Complete` already + /// carries every fact a statement needs. + pub open_bills: Vec, + pub unallocated_by_party: Vec, +} + +/// Builds one party's aged-bills statement as an `.xlsx` workbook and writes +/// it to the user's Downloads folder. +/// +/// Mirrors `save_report_download` exactly: Tauri's own path resolver, the +/// same filename-traversal guard, and the full written path returned so the +/// UI can say where the file went instead of leaving the operator to guess. +#[tauri::command] +pub async fn export_party_statement( + app: tauri::AppHandle, + request: ExportPartyStatementRequest, +) -> Result { + use tauri::Manager as _; + + let open_bills = request + .open_bills + .into_iter() + .map(into_open_bill_row) + .collect::, _>>()?; + + let statement = build_party_statement( + &request.company, + &request.as_of_yyyymmdd, + &request.party, + &open_bills, + &request.unallocated_by_party, + ) + .map_err(|error| match error { + PartyStatementError::PartyNotFound => { + "Bridge no longer has exposure on record for this party — refresh and try again." + .to_string() + } + PartyStatementError::ArithmeticOverflow => { + "Bridge could not total this party's statement exactly.".to_string() + } + })?; + + let bytes = render_party_statement_xlsx(&statement) + .map_err(|error| format!("Bridge could not build the statement: {error}"))?; + + let mut slug = statement_filename_slug(&statement.party); + slug.truncate(150); + let stem = format!("statement-{slug}-{}", statement.as_of_yyyymmdd); + + let downloads = app + .path() + .download_dir() + .or_else(|_| app.path().home_dir()) + .map_err(|_| "Bridge could not locate a folder to save into.".to_string())?; + let path = write_unique_statement_file(&downloads, &stem, "xlsx", &bytes)?; + Ok(path.to_string_lossy().into_owned()) +} + +/// Writes a new statement filename without ever replacing an earlier export. +/// `create_new` closes the race between checking a candidate and writing it; +/// a repeat export becomes `-2`, `-3`, and so on rather than a silent loss. +fn write_unique_statement_file( + destination: &std::path::Path, + stem: &str, + extension: &str, + bytes: &[u8], +) -> Result { + if stem.is_empty() + || stem.len() > 190 + || std::path::Path::new(stem).components().count() != 1 + || extension.is_empty() + || extension.contains('.') + { + return Err("Bridge could not build a safe file name for this export.".to_string()); + } + for sequence in 1..=10_000_u32 { + let suffix = if sequence == 1 { + String::new() + } else { + format!("-{sequence}") + }; + let path = destination.join(format!("{stem}{suffix}.{extension}")); + let mut file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "Bridge could not create the statement export: {error}" + )) + } + }; + if let Err(error) = std::io::Write::write_all(&mut file, bytes) { + drop(file); + let _ = std::fs::remove_file(&path); + return Err(format!( + "Bridge could not finish writing the statement export: {error}" + )); + } + return Ok(path); + } + Err("Bridge could not find an unused statement filename after 10,000 attempts.".to_string()) +} + +/// Lower-cases and hyphenates a party name into a filesystem-safe slug, +/// collapsing runs of punctuation/whitespace into a single `-` rather than +/// leaving `statement----------20260808.xlsx`. +fn statement_filename_slug(party: &str) -> String { + let mut slug = String::with_capacity(party.len()); + let mut previous_was_dash = false; + for ch in party.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + previous_was_dash = false; + } else if !previous_was_dash { + slug.push('-'); + previous_was_dash = true; + } + } + let trimmed = slug.trim_matches('-'); + if trimmed.is_empty() { + "party".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod statement_export_tests { + use super::*; + + #[test] + fn repeated_statement_exports_get_distinct_new_files() { + let destination = tempfile::tempdir().expect("synthetic destination"); + let first = write_unique_statement_file( + destination.path(), + "statement-party-20260809", + "xlsx", + b"one", + ) + .expect("first statement"); + let second = write_unique_statement_file( + destination.path(), + "statement-party-20260809", + "xlsx", + b"two", + ) + .expect("second statement"); + + assert_eq!( + first.file_name().and_then(|name| name.to_str()), + Some("statement-party-20260809.xlsx") + ); + assert_eq!( + second.file_name().and_then(|name| name.to_str()), + Some("statement-party-20260809-2.xlsx") + ); + assert_eq!(std::fs::read(first).expect("first bytes"), b"one"); + assert_eq!(std::fs::read(second).expect("second bytes"), b"two"); + } +} + #[derive(Debug, Deserialize)] pub struct BaseCurrencyRequest { pub config: TallyConfig, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a277330..12e9790 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ pub mod gst; // Crate-internal only: the previously separate `bridge-tally-observability` crate had exactly // one consumer inside this crate, so it does not need to be reachable from outside `bridge_lib`. mod observability; +pub mod reports; pub mod sync; pub mod tally; @@ -88,6 +89,7 @@ pub fn run() { commands::revoke_tally_write_fixture_enrollment, commands::save_report_download, commands::reveal_exported_file, + commands::export_party_statement, commands::fetch_tally_outstandings_all_companies, commands::detect_tally_base_currency, commands::tally_persisted_company_profiles, diff --git a/src-tauri/src/reports/mod.rs b/src-tauri/src/reports/mod.rs new file mode 100644 index 0000000..da0366c --- /dev/null +++ b/src-tauri/src/reports/mod.rs @@ -0,0 +1,5 @@ +//! Report exports built from data Bridge already holds after +//! `fetch_tally_outstandings` -- no module here issues a Tally request. + +pub mod party_statement; +pub mod party_statement_xlsx; diff --git a/src-tauri/src/reports/party_statement.rs b/src-tauri/src/reports/party_statement.rs new file mode 100644 index 0000000..04f4d7b --- /dev/null +++ b/src-tauri/src/reports/party_statement.rs @@ -0,0 +1,370 @@ +//! Builds one party's statement from data Bridge already holds. +//! +//! `fetch_tally_outstandings` already returns every open bill and every +//! party's unallocated balance in `OutstandingsLoadResult::Complete`. This +//! module issues no Tally request of its own -- it only reshapes rows the UI +//! already has in hand into the numbers for one party's statement. + +use bridge_tally_core::ExactDecimal; + +use crate::tally::{ExposureDirection, OpenBillRow, UnallocatedParty}; + +/// Which ageing bucket a bill's age falls into. Boundaries match +/// `bridge_tally_protocol::native_outstandings::compute` exactly, so a +/// statement's bucket for a bill agrees with the ageing panel the bill was +/// drilled down from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgeingBucket { + Days0To30, + Days31To60, + Days61To90, + Days90Plus, +} + +impl AgeingBucket { + fn for_age(age_days: u32) -> Self { + match age_days { + 0..=30 => Self::Days0To30, + 31..=60 => Self::Days31To60, + 61..=90 => Self::Days61To90, + _ => Self::Days90Plus, + } + } + + /// Short label for both the bill table's Bucket column and any + /// per-bucket summary. + pub fn label(self) -> &'static str { + match self { + Self::Days0To30 => "0-30 days", + Self::Days31To60 => "31-60 days", + Self::Days61To90 => "61-90 days", + Self::Days90Plus => "90+ days", + } + } +} + +/// One bill on a party's statement, carrying the bucket it was sorted into +/// so the spreadsheet writer never has to recompute it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatementBill { + pub reference: String, + pub bill_date: String, + pub due_date: String, + pub amount: ExactDecimal, + pub age_days: Option, + /// `receivable` or `payable` -- see `OpenBillRow::kind`. + pub kind: &'static str, + pub bucket: Option, +} + +/// Per-bucket subtotals across a party's bill table. Sums to exactly the +/// bill total -- every bill lands in exactly one bucket -- which +/// `party_statement_bucket_subtotals_sum_to_bill_total` below holds Bridge to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgeingSubtotals { + pub days_0_30: ExactDecimal, + pub days_31_60: ExactDecimal, + pub days_61_90: ExactDecimal, + pub days_90_plus: ExactDecimal, +} + +/// One party's statement: its bills, their ageing subtotals, its +/// unallocated exposure, and the grand total of the two. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PartyStatement { + pub company: String, + pub party: String, + pub as_of_yyyymmdd: String, + /// Oldest bill first (largest `age_days` first), matching the order the + /// party's drill-down panel already shows. + pub bills: Vec, + pub subtotals: AgeingSubtotals, + /// Sum of every row in `bills`. Excludes `unallocated` deliberately -- + /// see `PartyStatement::grand_total`. + pub bill_total: ExactDecimal, + /// Exposure on this party's ledger with no bill reference. Zero when the + /// party has none, distinguished from "unknown" because the caller + /// always has the full `unallocated_by_party` slice in hand -- there is + /// no "not computed" case at this layer, unlike the report-level total. + pub unallocated: ExactDecimal, + pub unallocated_direction: Option, + /// `bill_total + unallocated`. Kept as a field, not left for a caller to + /// recompute, so every consumer of a `PartyStatement` sees the same + /// figure the exact-decimal addition actually produced. + pub grand_total: ExactDecimal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PartyStatementError { + /// Neither an open bill nor an unallocated balance names this party -- + /// nothing to put in a statement. Most likely a stale party name from a + /// UI that has since refreshed. + #[error("Bridge has no recorded exposure for this party")] + PartyNotFound, + /// An amount could not be totalled without exceeding `ExactDecimal`'s + /// bound. Not expected on any book actually observed, but the addition + /// is checked rather than assumed to succeed. + #[error("a statement amount could not be totalled exactly")] + ArithmeticOverflow, +} + +/// Builds one party's statement from the `open_bills` and +/// `unallocated_by_party` slices `OutstandingsLoadResult::Complete` already +/// carries. Pure: no I/O, no Tally request, and no reliance on anything +/// beyond its arguments. +pub fn build_party_statement( + company: &str, + as_of_yyyymmdd: &str, + party: &str, + open_bills: &[OpenBillRow], + unallocated_by_party: &[UnallocatedParty], +) -> Result { + let mut bills: Vec = open_bills + .iter() + .filter(|row| row.party == party) + .map(|row| StatementBill { + reference: row.reference.clone(), + bill_date: row.bill_date.clone(), + due_date: row.due_date.clone(), + amount: row.amount.clone(), + age_days: row.age_days, + kind: row.kind, + bucket: row.age_days.map(AgeingBucket::for_age), + }) + .collect(); + // Oldest first: largest age first, then the same tie-breaks + // `open_bill_rows` uses so a party's statement lists bills in the same + // order its drill-down panel already showed them in. + bills.sort_by(|left, right| { + right + .age_days + .cmp(&left.age_days) + .then_with(|| left.due_date.cmp(&right.due_date)) + .then_with(|| left.reference.cmp(&right.reference)) + }); + + let (unallocated, unallocated_direction) = unallocated_by_party + .iter() + .find(|entry| entry.party == party) + .map(|entry| (entry.amount.clone(), Some(entry.direction))) + .unwrap_or_else(|| (ExactDecimal::zero(), None)); + + if bills.is_empty() && unallocated.is_zero() { + return Err(PartyStatementError::PartyNotFound); + } + + let mut subtotals = AgeingSubtotals { + days_0_30: ExactDecimal::zero(), + days_31_60: ExactDecimal::zero(), + days_61_90: ExactDecimal::zero(), + days_90_plus: ExactDecimal::zero(), + }; + let mut bill_total = ExactDecimal::zero(); + for bill in &bills { + bill_total = bill_total + .checked_add(&bill.amount) + .map_err(|_| PartyStatementError::ArithmeticOverflow)?; + if let Some(bucket) = bill.bucket { + let bucket_subtotal = match bucket { + AgeingBucket::Days0To30 => &mut subtotals.days_0_30, + AgeingBucket::Days31To60 => &mut subtotals.days_31_60, + AgeingBucket::Days61To90 => &mut subtotals.days_61_90, + AgeingBucket::Days90Plus => &mut subtotals.days_90_plus, + }; + *bucket_subtotal = bucket_subtotal + .checked_add(&bill.amount) + .map_err(|_| PartyStatementError::ArithmeticOverflow)?; + } + } + + let grand_total = bill_total + .checked_add(&unallocated) + .map_err(|_| PartyStatementError::ArithmeticOverflow)?; + + Ok(PartyStatement { + company: company.to_string(), + party: party.to_string(), + as_of_yyyymmdd: as_of_yyyymmdd.to_string(), + bills, + subtotals, + bill_total, + unallocated, + unallocated_direction, + grand_total, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bill( + party: &str, + reference: &str, + amount: &str, + age_days: u32, + kind: &'static str, + ) -> OpenBillRow { + OpenBillRow { + party: party.to_string(), + reference: reference.to_string(), + bill_date: "20260101".to_string(), + due_date: "20260201".to_string(), + amount: ExactDecimal::parse(amount).unwrap(), + age_days: Some(age_days), + kind, + } + } + + fn unallocated(party: &str, amount: &str) -> UnallocatedParty { + UnallocatedParty { + party: party.to_string(), + amount: ExactDecimal::parse(amount).unwrap(), + direction: ExposureDirection::Receivable, + } + } + + /// Sums decimal literals through the same `checked_add` accumulation + /// `build_party_statement` uses, so an expectation here lands in + /// whatever canonical form the accumulator itself produces (e.g. + /// trailing fractional zeros dropped) instead of a hand-normalised + /// guess that can drift from `ExactDecimal`'s actual output. + fn exact_sum(values: &[&str]) -> ExactDecimal { + values.iter().fold(ExactDecimal::zero(), |total, value| { + total + .checked_add(&ExactDecimal::parse(*value).unwrap()) + .unwrap() + }) + } + + #[test] + fn builds_a_statement_sorted_oldest_first_and_filtered_to_the_party() { + let bills = vec![ + bill("Aarav Textiles", "INV-3", "1000.00", 10, "receivable"), + bill("Aarav Textiles", "INV-1", "2500.50", 95, "receivable"), + bill("Aarav Textiles", "INV-2", "300.00", 45, "receivable"), + bill("Other Party", "INV-9", "999.00", 200, "receivable"), + ]; + let unallocated_rows = vec![unallocated("Aarav Textiles", "150.25")]; + + let statement = build_party_statement( + "Lab Co", + "20260808", + "Aarav Textiles", + &bills, + &unallocated_rows, + ) + .expect("party has exposure"); + + assert_eq!(statement.company, "Lab Co"); + assert_eq!(statement.party, "Aarav Textiles"); + assert_eq!(statement.as_of_yyyymmdd, "20260808"); + assert_eq!( + statement + .bills + .iter() + .map(|row| row.reference.as_str()) + .collect::>(), + vec!["INV-1", "INV-2", "INV-3"], + ); + assert_eq!( + statement.bill_total, + exact_sum(&["1000.00", "2500.50", "300.00"]) + ); + assert_eq!( + statement.unallocated, + ExactDecimal::parse("150.25").unwrap() + ); + assert_eq!( + statement.grand_total, + exact_sum(&["1000.00", "2500.50", "300.00", "150.25"]), + ); + } + + #[test] + fn bucket_subtotals_sum_to_exactly_the_bill_total() { + let bills = vec![ + bill("Party", "A", "10.10", 5, "receivable"), + bill("Party", "B", "20.20", 30, "receivable"), + bill("Party", "C", "30.30", 31, "receivable"), + bill("Party", "D", "40.40", 60, "receivable"), + bill("Party", "E", "50.50", 61, "receivable"), + bill("Party", "F", "60.60", 90, "receivable"), + bill("Party", "G", "70.70", 91, "receivable"), + bill("Party", "H", "80.80", 500, "receivable"), + ]; + let statement = build_party_statement("Lab Co", "20260808", "Party", &bills, &[]) + .expect("party has exposure"); + + assert_eq!( + statement.subtotals.days_0_30, + exact_sum(&["10.10", "20.20"]) + ); + assert_eq!( + statement.subtotals.days_31_60, + exact_sum(&["30.30", "40.40"]) + ); + assert_eq!( + statement.subtotals.days_61_90, + exact_sum(&["50.50", "60.60"]) + ); + assert_eq!( + statement.subtotals.days_90_plus, + exact_sum(&["70.70", "80.80"]) + ); + + let summed_buckets = statement + .subtotals + .days_0_30 + .checked_add(&statement.subtotals.days_31_60) + .and_then(|value| value.checked_add(&statement.subtotals.days_61_90)) + .and_then(|value| value.checked_add(&statement.subtotals.days_90_plus)) + .unwrap(); + assert_eq!(summed_buckets, statement.bill_total); + assert_eq!( + statement.bill_total, + exact_sum(&["10.10", "20.20", "30.30", "40.40", "50.50", "60.60", "70.70", "80.80",]), + ); + assert_eq!(statement.grand_total, statement.bill_total); + assert!(statement.unallocated.is_zero()); + } + + #[test] + fn a_party_with_only_unallocated_exposure_and_no_bills_still_builds() { + let unallocated_rows = vec![unallocated("On Account Only", "42.00")]; + let statement = build_party_statement( + "Lab Co", + "20260808", + "On Account Only", + &[], + &unallocated_rows, + ) + .expect("party has unallocated exposure"); + assert!(statement.bills.is_empty()); + assert!(statement.bill_total.is_zero()); + assert_eq!(statement.unallocated, ExactDecimal::parse("42.00").unwrap()); + assert_eq!(statement.grand_total, exact_sum(&["42.00"])); + } + + #[test] + fn an_unknown_party_is_rejected_rather_than_producing_an_empty_statement() { + let bills = vec![bill("Known Party", "INV-1", "10.00", 5, "receivable")]; + let error = + build_party_statement("Lab Co", "20260808", "Unknown Party", &bills, &[]).unwrap_err(); + assert_eq!(error, PartyStatementError::PartyNotFound); + } + + #[test] + fn a_party_with_a_zero_unallocated_residual_is_treated_as_having_none() { + // `unallocated_by_party` already drops zero residuals upstream (see + // `top_unallocated_parties`), but this guards the statement builder + // itself against ever surfacing a zero as if it were real exposure. + let bills = vec![bill("Party", "INV-1", "10.00", 5, "receivable")]; + let unallocated_rows = vec![unallocated("Party", "0")]; + let statement = + build_party_statement("Lab Co", "20260808", "Party", &bills, &unallocated_rows) + .unwrap(); + assert!(statement.unallocated.is_zero()); + assert_eq!(statement.grand_total, statement.bill_total); + } +} diff --git a/src-tauri/src/reports/party_statement_xlsx.rs b/src-tauri/src/reports/party_statement_xlsx.rs new file mode 100644 index 0000000..bd7821e --- /dev/null +++ b/src-tauri/src/reports/party_statement_xlsx.rs @@ -0,0 +1,377 @@ +//! Renders a [`PartyStatement`] as an `.xlsx` workbook. +//! +//! Amounts are written with [`Worksheet::write_number_with_format`], never as +//! text: a rupee figure landing in a cell as a string breaks every downstream +//! `SUM`, which is the single most common way an accounting export turns out +//! to be useless to the person who received it. Bill and due dates are real +//! date cells for the same reason -- a CA sorting or filtering the sheet by +//! date needs Excel's own date type, not a string that merely looks like one. + +use rust_xlsxwriter::{ExcelDateTime, Format, Workbook, XlsxError}; + +use super::party_statement::PartyStatement; +use crate::tally::ExposureDirection; + +/// Indian-grouping number format (lakh/crore, not thousands) -- the grouping +/// every figure on the Outstandings screen already uses. +const AMOUNT_NUM_FORMAT: &str = "##,##,##0.00"; +const DATE_NUM_FORMAT: &str = "dd-mmm-yyyy"; + +#[derive(Debug, thiserror::Error)] +pub enum PartyStatementXlsxError { + #[error("Bridge could not build the statement workbook: {0}")] + Workbook(#[from] XlsxError), + #[error("Bridge could not read a statement date for the spreadsheet ({0})")] + InvalidDate(String), + #[error("Bridge could not represent an amount in the spreadsheet ({0})")] + InvalidAmount(String), + #[error("Bridge could not classify a statement bill direction ({0})")] + InvalidDirection(String), + #[error("Bridge found an inconsistent statement age state")] + InvalidAgeState, +} + +/// Renders `statement` as an in-memory `.xlsx` file. +pub fn render_party_statement_xlsx( + statement: &PartyStatement, +) -> Result, PartyStatementXlsxError> { + let mut workbook = Workbook::new(); + let worksheet = workbook.add_worksheet(); + worksheet.set_name("Statement")?; + + let bold = Format::new().set_bold(); + let amount_format = Format::new().set_num_format(AMOUNT_NUM_FORMAT); + let bold_amount_format = Format::new().set_bold().set_num_format(AMOUNT_NUM_FORMAT); + let date_format = Format::new().set_num_format(DATE_NUM_FORMAT); + + let mut row = 0u32; + worksheet.write_string(row, 0, "Company")?; + worksheet.write_string(row, 1, statement.company.as_str())?; + row += 1; + worksheet.write_string(row, 0, "Party")?; + worksheet.write_string(row, 1, statement.party.as_str())?; + row += 1; + worksheet.write_string(row, 0, "As of")?; + worksheet.write_datetime_with_format( + row, + 1, + excel_date(&statement.as_of_yyyymmdd)?, + &date_format, + )?; + row += 1; + + let has_unallocated = !statement.unallocated.is_zero(); + if has_unallocated { + worksheet.write_string( + row, + 0, + "Also carries exposure with no bill reference -- shown separately below, not aged.", + )?; + row += 1; + } + + row += 1; // Blank row before the bill table. + let header_row = row; + for (col, label) in [ + "Reference", + "Bill date", + "Due date", + "Direction", + "Amount", + "Age (days)", + "Bucket", + ] + .into_iter() + .enumerate() + { + worksheet.write_string_with_format(header_row, col as u16, label, &bold)?; + } + row += 1; + + for bill in &statement.bills { + worksheet.write_string(row, 0, bill.reference.as_str())?; + worksheet.write_datetime_with_format(row, 1, excel_date(&bill.bill_date)?, &date_format)?; + worksheet.write_datetime_with_format(row, 2, excel_date(&bill.due_date)?, &date_format)?; + worksheet.write_string(row, 3, bill_direction_label(bill.kind)?)?; + worksheet.write_number_with_format( + row, + 4, + amount_to_f64(bill.amount.as_str())?, + &amount_format, + )?; + match (bill.age_days, bill.bucket) { + (Some(age_days), Some(bucket)) => { + worksheet.write_number(row, 5, age_days)?; + worksheet.write_string(row, 6, bucket.label())?; + } + (None, None) => { + worksheet.write_string(row, 5, "Not due")?; + worksheet.write_string(row, 6, "Unaged")?; + } + _ => return Err(PartyStatementXlsxError::InvalidAgeState), + } + row += 1; + } + + worksheet.write_string_with_format(row, 0, "Total bill magnitudes (not net)", &bold)?; + worksheet.write_number_with_format( + row, + 4, + amount_to_f64(statement.bill_total.as_str())?, + &bold_amount_format, + )?; + row += 1; + + if has_unallocated { + let direction = + statement + .unallocated_direction + .ok_or(PartyStatementXlsxError::InvalidDirection( + "unallocated direction missing".to_string(), + ))?; + worksheet.write_string( + row, + 0, + format!( + "Unallocated {} (no bill reference)", + exposure_direction_label(direction) + ), + )?; + worksheet.write_number_with_format( + row, + 4, + amount_to_f64(statement.unallocated.as_str())?, + &amount_format, + )?; + row += 1; + + worksheet.write_string_with_format(row, 0, "Grand total", &bold)?; + worksheet.write_number_with_format( + row, + 4, + amount_to_f64(statement.grand_total.as_str())?, + &bold_amount_format, + )?; + } + + // Freeze the column-header row so it stays visible once the bill table + // scrolls past the header block above it. + worksheet.set_freeze_panes(header_row + 1, 0)?; + + worksheet.set_column_width(0, 30)?; + worksheet.set_column_width(1, 13)?; + worksheet.set_column_width(2, 13)?; + worksheet.set_column_width(3, 27)?; + worksheet.set_column_width(4, 16)?; + worksheet.set_column_width(5, 11)?; + worksheet.set_column_width(6, 13)?; + + workbook + .save_to_buffer() + .map_err(PartyStatementXlsxError::from) +} + +/// `ExactDecimal` is validated to be plain-decimal ASCII digits with an +/// optional sign and fractional part, so this parse cannot fail on any value +/// that reached this module -- but the fallback keeps the conversion honest +/// rather than assuming it. Display-only: nothing here feeds back into +/// Bridge's own arithmetic, which stays on `ExactDecimal` throughout. +/// Converts an exact amount to the IEEE-754 value Excel's number cell requires. +/// +/// **This must fail rather than substitute.** An earlier revision returned +/// `unwrap_or(0.0)`, which would have written a real bill as zero into a +/// statement sent to a client and silently understated the total -- a wrong +/// number presented as a right one, which is the failure mode this codebase +/// exists to prevent. A statement that cannot be rendered exactly must not be +/// rendered at all. +/// +/// Bridge's own arithmetic never touches `f64`; this is the last step before +/// the cell, and Excel has no exact-decimal cell type to target instead. +fn amount_to_f64(text: &str) -> Result { + let value = text + .parse::() + .map_err(|_| PartyStatementXlsxError::InvalidAmount(text.to_string()))?; + if !value.is_finite() || !same_decimal_value(text, &value.to_string()) { + return Err(PartyStatementXlsxError::InvalidAmount(text.to_string())); + } + Ok(value) +} + +fn bill_direction_label(kind: &str) -> Result<&'static str, PartyStatementXlsxError> { + match kind { + "receivable" => Ok("Receivable (they owe you)"), + "payable" => Ok("Payable (you owe them)"), + _ => Err(PartyStatementXlsxError::InvalidDirection(kind.to_string())), + } +} + +fn exposure_direction_label(direction: ExposureDirection) -> &'static str { + match direction { + ExposureDirection::Receivable => "Receivable", + ExposureDirection::Payable => "Payable", + } +} + +/// `f64::to_string` emits the shortest decimal that round-trips to the binary +/// value. Comparing numeric decimal forms (rather than their spellings) keeps +/// harmless source scale such as `42.00`, while rejecting a value whose Excel +/// number cell would change the amount. +fn same_decimal_value(left: &str, right: &str) -> bool { + canonical_decimal_value(left) == canonical_decimal_value(right) +} + +fn canonical_decimal_value(value: &str) -> Option { + let (negative, unsigned) = value + .strip_prefix('-') + .map_or((false, value), |unsigned| (true, unsigned)); + let (whole, fraction) = unsigned.split_once('.').unwrap_or((unsigned, "")); + if whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || !fraction.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + let whole = whole.trim_start_matches('0'); + let fraction = fraction.trim_end_matches('0'); + if whole.is_empty() && fraction.is_empty() { + return Some("0".to_string()); + } + let mut canonical = String::with_capacity(value.len()); + if negative { + canonical.push('-'); + } + canonical.push_str(if whole.is_empty() { "0" } else { whole }); + if !fraction.is_empty() { + canonical.push('.'); + canonical.push_str(fraction); + } + Some(canonical) +} + +fn excel_date(yyyymmdd: &str) -> Result { + let invalid = || PartyStatementXlsxError::InvalidDate(yyyymmdd.to_string()); + if yyyymmdd.len() != 8 || !yyyymmdd.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid()); + } + let year = yyyymmdd[0..4].parse::().map_err(|_| invalid())?; + let month = yyyymmdd[4..6].parse::().map_err(|_| invalid())?; + let day = yyyymmdd[6..8].parse::().map_err(|_| invalid())?; + ExcelDateTime::from_ymd(year, month, day).map_err(|_| invalid()) +} + +#[cfg(test)] +mod tests { + + /// A statement that cannot be rendered exactly must FAIL, never render a + /// substituted figure. This pins the fail-closed behaviour of + /// `amount_to_f64`: an earlier revision returned `unwrap_or(0.0)`, which + /// would have written a real bill as zero into a document sent to a client. + #[test] + fn an_unrepresentable_amount_fails_instead_of_becoming_zero() { + // Sanity: a well-formed amount converts. + assert!(amount_to_f64("12").is_ok()); + + // Every malformed or non-finite value must be refused outright. The + // string is this private conversion boundary's direct input; an + // `ExactDecimal` has already rejected malformed source data earlier. + for unrepresentable in ["", "not-a-number", "1e999"] { + assert!(matches!( + amount_to_f64(unrepresentable), + Err(PartyStatementXlsxError::InvalidAmount(value)) if value == unrepresentable + )); + } + } + + #[test] + fn rejects_a_valid_decimal_that_excel_cannot_represent_exactly() { + assert!(matches!( + amount_to_f64("9007199254740993"), + Err(PartyStatementXlsxError::InvalidAmount(value)) if value == "9007199254740993" + )); + + assert_eq!( + amount_to_f64("9007199254740992").unwrap(), + 9007199254740992.0 + ); + assert_eq!(amount_to_f64("42.00").unwrap(), 42.0); + } + + #[test] + fn bill_direction_labels_make_mixed_party_amounts_unambiguous() { + assert_eq!( + bill_direction_label("receivable").unwrap(), + "Receivable (they owe you)" + ); + assert_eq!( + bill_direction_label("payable").unwrap(), + "Payable (you owe them)" + ); + assert!(matches!( + bill_direction_label("unknown"), + Err(PartyStatementXlsxError::InvalidDirection(_)) + )); + } + use super::*; + use crate::reports::party_statement::build_party_statement; + use crate::tally::{ExposureDirection, OpenBillRow, UnallocatedParty}; + use bridge_tally_core::ExactDecimal; + + fn bill(reference: &str, amount: &str, age_days: u32) -> OpenBillRow { + OpenBillRow { + party: "Aarav Textiles".to_string(), + reference: reference.to_string(), + bill_date: "20260101".to_string(), + due_date: "20260201".to_string(), + amount: ExactDecimal::parse(amount).unwrap(), + age_days: Some(age_days), + kind: "receivable", + } + } + + #[test] + fn renders_a_non_empty_workbook_for_a_billed_and_unallocated_party() { + let bills = vec![bill("INV-1", "1250.75", 40)]; + let unallocated = vec![UnallocatedParty { + party: "Aarav Textiles".to_string(), + amount: ExactDecimal::parse("300.00").unwrap(), + direction: ExposureDirection::Receivable, + }]; + let statement = + build_party_statement("Lab Co", "20260808", "Aarav Textiles", &bills, &unallocated) + .unwrap(); + let bytes = render_party_statement_xlsx(&statement).unwrap(); + // A well-formed xlsx is a zip archive; the local-file-header + // signature is the cheapest evidence this is real workbook bytes and + // not an empty or truncated buffer. + assert!(bytes.len() > 200); + assert_eq!(&bytes[0..2], b"PK"); + } + + #[test] + fn renders_a_workbook_for_a_party_with_no_bills() { + let unallocated = vec![UnallocatedParty { + party: "On Account Only".to_string(), + amount: ExactDecimal::parse("42.00").unwrap(), + direction: ExposureDirection::Receivable, + }]; + let statement = + build_party_statement("Lab Co", "20260808", "On Account Only", &[], &unallocated) + .unwrap(); + let bytes = render_party_statement_xlsx(&statement).unwrap(); + assert!(bytes.len() > 200); + } + + #[test] + fn an_invalid_date_is_rejected_rather_than_written_as_a_string() { + let statement = build_party_statement( + "Lab Co", + "not-a-date", + "Aarav Textiles", + &[bill("INV-1", "10.00", 5)], + &[], + ) + .unwrap(); + let error = render_party_statement_xlsx(&statement).unwrap_err(); + assert!(matches!(error, PartyStatementXlsxError::InvalidDate(_))); + } +}