Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92904f169c | ||
|
|
f1b7502e9d | ||
|
|
7162dac6e7 | ||
|
|
cedf4839bd | ||
|
|
a014a907e4 | ||
|
|
8d4f8e666b | ||
|
|
40aa3e05d9 | ||
|
|
92ba3d9590 | ||
|
|
fbed18a0bd | ||
|
|
5597fb791d | ||
|
|
d6ec6b2a17 |
Generated
+1
-1
@@ -12,11 +12,11 @@
|
|||||||
"@carbon/react": "^1.117.0",
|
"@carbon/react": "^1.117.0",
|
||||||
"@carbon/styles": "^1.116.0",
|
"@carbon/styles": "^1.116.0",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@napi-rs/canvas": "*",
|
|
||||||
"@react-pdf/renderer": "^4.9.0",
|
"@react-pdf/renderer": "^4.9.0",
|
||||||
"@tauri-apps/api": "^2.12.1",
|
"@tauri-apps/api": "^2.12.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.8.1",
|
"@tauri-apps/plugin-dialog": "^2.8.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.7.0",
|
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||||
|
"fontkit": "2.0.4",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"react-is": "^19.3.0"
|
"react-is": "^19.3.0"
|
||||||
|
|||||||
+5
-1
@@ -13,7 +13,10 @@
|
|||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"app:dev": "tauri dev",
|
"app:dev": "tauri dev",
|
||||||
"app:build": "tauri build --features custom-protocol",
|
"app:build": "tauri build --features custom-protocol",
|
||||||
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol"
|
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol",
|
||||||
|
"app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'",
|
||||||
|
"fonts:verify": "node scripts/fonts/verify.mjs",
|
||||||
|
"fonts:manifest": "node scripts/fonts/build-manifest.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@carbon/icons-react": "^11.89.0",
|
"@carbon/icons-react": "^11.89.0",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"@tauri-apps/api": "^2.12.1",
|
"@tauri-apps/api": "^2.12.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.8.1",
|
"@tauri-apps/plugin-dialog": "^2.8.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.7.0",
|
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||||
|
"fontkit": "2.0.4",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"react-is": "^19.3.0"
|
"react-is": "^19.3.0"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ mkdir -p "$REPO"
|
|||||||
-l "proprietary" \
|
-l "proprietary" \
|
||||||
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
||||||
-m "Xavier Karma <xavier@bongbetic.com>" \
|
-m "Xavier Karma <xavier@bongbetic.com>" \
|
||||||
-D "libwebkit2gtk41>=2.40_1" \
|
-D "libwebkit2gtk41>=2.50_1 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" \
|
||||||
-t "office finance" \
|
-t "office finance" \
|
||||||
--compression zstd \
|
--compression zstd \
|
||||||
"$STAGE"
|
"$STAGE"
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ version=0.1.0
|
|||||||
revision=1
|
revision=1
|
||||||
archs="x86_64*"
|
archs="x86_64*"
|
||||||
hostmakedepends="rust nodejs pkg-config"
|
hostmakedepends="rust nodejs pkg-config"
|
||||||
makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel openssl-devel"
|
makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel"
|
||||||
depends="libwebkit2gtk41"
|
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
|
||||||
short_desc="Offline invoicing app by Bongbetic"
|
short_desc="Offline invoicing app by Bongbetic"
|
||||||
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
||||||
license="custom:proprietary"
|
license="custom:proprietary"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"families": [
|
||||||
|
{
|
||||||
|
"family": "IBM Plex Mono",
|
||||||
|
"folder": "ibm-plex-mono",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"licenseFile": "OFL.txt",
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"file": "IBMPlexMono-Italic.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "italic",
|
||||||
|
"sha256": "8ebe04c8c6cc82f0be19896ddc61d9935cdd0f027b0173c1945b8d247d7dfc2a",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexMono-Regular.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "fe11304a5fe956d5744e9b6a246cc83d90425245e75a62230044966ca96a7f50",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexMono-SemiBold.ttf",
|
||||||
|
"weight": 600,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "c9417148ce13f8fa7d2d5c9180bbc141f72aa0d814ffeb280f6904dc2b1bbd7a",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "IBM Plex Sans",
|
||||||
|
"folder": "ibm-plex-sans",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"licenseFile": "OFL.txt",
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Light.ttf",
|
||||||
|
"weight": 300,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "2218b5f3f1fc9d3a793343f45c3be5ee7eae9584a7a52391bb8cf2d37622b3e0",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 512,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Italic.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "italic",
|
||||||
|
"sha256": "a9c6ef9942c49e49d11e11a6dacc0b3a087978757e9b22a06b8ac22a6400fb15",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Regular.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 516,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Medium.ttf",
|
||||||
|
"weight": 500,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "331c8639d7598b2cde62a911a71db195e30cb655cd6bdf2e324a7e984955f907",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 520,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-SemiBold.ttf",
|
||||||
|
"weight": 600,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "a20caf8286023a6a7a85e40b1d2a4ae9fc3e3b1f9eda8f4c542dd4986af67bb1",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 522,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "IBMPlexSans-Bold.ttf",
|
||||||
|
"weight": 700,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 1025,
|
||||||
|
"descent": -275,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 698,
|
||||||
|
"xHeight": 525,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "Noto Sans Devanagari",
|
||||||
|
"folder": "noto-sans-devanagari",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"licenseFile": "OFL.txt",
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"file": "NotoSansDevanagari-Regular.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "306b53ecfb182a504dd8a7446093c316387d2fd8dc350d0792ed1753fe0996cd",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 896,
|
||||||
|
"descent": -408,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 622,
|
||||||
|
"xHeight": 536,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "NotoSansDevanagari-Bold.ttf",
|
||||||
|
"weight": 700,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "3ad8362a06271814869838dcc3d161b13c9fb97681b627af1f7f283ea9387d56",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 896,
|
||||||
|
"descent": -408,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 622,
|
||||||
|
"xHeight": 536,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "Noto Sans Kannada",
|
||||||
|
"folder": "noto-sans-kannada",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"licenseFile": "OFL.txt",
|
||||||
|
"fonts": [
|
||||||
|
{
|
||||||
|
"file": "NotoSansKannada-Regular.ttf",
|
||||||
|
"weight": 400,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "9ad74dc64838c6855b96f671fc08e425a58921b9d0c71712ea79c328a27e6e38",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 809,
|
||||||
|
"descent": -540,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 690,
|
||||||
|
"xHeight": 551,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "NotoSansKannada-Bold.ttf",
|
||||||
|
"weight": 700,
|
||||||
|
"style": "normal",
|
||||||
|
"sha256": "3db1507b3f856185fc7c00dc0addfd4797a8ea6955b86d1d21fa659cc9d5e849",
|
||||||
|
"fsType": 0,
|
||||||
|
"upm": 1000,
|
||||||
|
"ascent": 809,
|
||||||
|
"descent": -540,
|
||||||
|
"lineGap": 0,
|
||||||
|
"capHeight": 690,
|
||||||
|
"xHeight": 552,
|
||||||
|
"hasRupee": true,
|
||||||
|
"hasTabularNums": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License,
|
||||||
|
Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font
|
||||||
|
creation efforts of academic and linguistic communities, and to
|
||||||
|
provide a free and open framework in which fonts may be shared and
|
||||||
|
improved in partnership with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply to
|
||||||
|
any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software
|
||||||
|
components as distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to,
|
||||||
|
deleting, or substituting -- in part or in whole -- any of the
|
||||||
|
components of the Original Version, by changing formats or by porting
|
||||||
|
the Font Software to a new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||||
|
modify, redistribute, and sell modified and unmodified copies of the
|
||||||
|
Font Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components, in
|
||||||
|
Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the
|
||||||
|
corresponding Copyright Holder. This restriction only applies to the
|
||||||
|
primary font name as presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created using
|
||||||
|
the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License,
|
||||||
|
Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font
|
||||||
|
creation efforts of academic and linguistic communities, and to
|
||||||
|
provide a free and open framework in which fonts may be shared and
|
||||||
|
improved in partnership with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply to
|
||||||
|
any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software
|
||||||
|
components as distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to,
|
||||||
|
deleting, or substituting -- in part or in whole -- any of the
|
||||||
|
components of the Original Version, by changing formats or by porting
|
||||||
|
the Font Software to a new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||||
|
modify, redistribute, and sell modified and unmodified copies of the
|
||||||
|
Font Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components, in
|
||||||
|
Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the
|
||||||
|
corresponding Copyright Holder. This restriction only applies to the
|
||||||
|
primary font name as presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created using
|
||||||
|
the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Regenerates public/fonts/manifest.json from the TTFs under public/fonts/<folder>/.
|
||||||
|
// Run: node scripts/fonts/build-manifest.mjs
|
||||||
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { FONTS_DIR, MANIFEST_PATH, familyFolders, inspectFont, ttfFiles } from "./lib.mjs";
|
||||||
|
|
||||||
|
const families = [];
|
||||||
|
for (const folder of familyFolders()) {
|
||||||
|
const files = ttfFiles(folder);
|
||||||
|
if (files.length === 0) continue;
|
||||||
|
const inspected = files.map((file) => inspectFont(folder, file));
|
||||||
|
const names = new Set(inspected.map((f) => f.familyName));
|
||||||
|
if (names.size !== 1) {
|
||||||
|
throw new Error(`${folder}: mixed family names ${[...names].join(", ")}`);
|
||||||
|
}
|
||||||
|
const licenseFile = "OFL.txt";
|
||||||
|
if (!existsSync(path.join(FONTS_DIR, folder, licenseFile))) {
|
||||||
|
throw new Error(`${folder}: missing ${licenseFile}`);
|
||||||
|
}
|
||||||
|
const fonts = inspected
|
||||||
|
.map((f) => f.info)
|
||||||
|
.sort((a, b) => a.weight - b.weight || a.style.localeCompare(b.style) || a.file.localeCompare(b.file));
|
||||||
|
families.push({ family: [...names][0], folder, license: "OFL-1.1", licenseFile, fonts });
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(MANIFEST_PATH, `${JSON.stringify({ version: 1, families }, null, 2)}\n`);
|
||||||
|
console.log(
|
||||||
|
`manifest.json: ${families.length} families, ${families.reduce((n, f) => n + f.fonts.length, 0)} fonts`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Shared helpers for the font manifest builder and verifier (Node only).
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import * as fontkit from "fontkit";
|
||||||
|
|
||||||
|
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
export const FONTS_DIR = path.join(ROOT, "public/fonts");
|
||||||
|
export const MANIFEST_PATH = path.join(FONTS_DIR, "manifest.json");
|
||||||
|
|
||||||
|
export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||||
|
|
||||||
|
/** Folders under public/fonts that hold bundled families (everything that is a directory). */
|
||||||
|
export function familyFolders() {
|
||||||
|
return readdirSync(FONTS_DIR)
|
||||||
|
.filter((name) => statSync(path.join(FONTS_DIR, name)).isDirectory())
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ttfFiles(folder) {
|
||||||
|
return readdirSync(path.join(FONTS_DIR, folder))
|
||||||
|
.filter((name) => /\.ttf$/i.test(name))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First four bytes as a tag: "\0\1\0\0" is reported as "0x00010000". */
|
||||||
|
export function sfntTag(bytes) {
|
||||||
|
const b = bytes.subarray(0, 4);
|
||||||
|
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "0x00010000";
|
||||||
|
return String.fromCharCode(...b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw OS/2 fsType (fontkit only exposes decoded flags). */
|
||||||
|
function rawFsType(bytes) {
|
||||||
|
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const numTables = dv.getUint16(4);
|
||||||
|
for (let i = 0; i < numTables; i++) {
|
||||||
|
const rec = 12 + i * 16;
|
||||||
|
const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]);
|
||||||
|
if (tag === "OS/2") return dv.getUint16(dv.getUint32(rec + 8) + 8);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a TTF with fontkit and returns the facts the manifest records. */
|
||||||
|
export function inspectFont(folder, file) {
|
||||||
|
const bytes = new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file)));
|
||||||
|
const font = fontkit.create(Buffer.from(bytes));
|
||||||
|
if (font.fonts) throw new Error(`${folder}/${file}: font collections are not supported`);
|
||||||
|
const os2 = font["OS/2"];
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
tag: sfntTag(bytes),
|
||||||
|
variable: Boolean(font.directory?.tables?.fvar),
|
||||||
|
// Name ID 16 (typographic family) groups SemiBold etc. under the base family.
|
||||||
|
familyName: font.name?.records?.preferredFamily?.en ?? font.familyName,
|
||||||
|
info: {
|
||||||
|
file,
|
||||||
|
weight: os2.usWeightClass,
|
||||||
|
style: os2.fsSelection.italic || os2.fsSelection.oblique ? "italic" : "normal",
|
||||||
|
sha256: sha256(bytes),
|
||||||
|
fsType: rawFsType(bytes),
|
||||||
|
upm: font.unitsPerEm,
|
||||||
|
ascent: font.ascent,
|
||||||
|
descent: font.descent,
|
||||||
|
lineGap: font.lineGap,
|
||||||
|
capHeight: font.capHeight,
|
||||||
|
xHeight: font.xHeight,
|
||||||
|
hasRupee: font.hasGlyphForCodePoint(0x20b9),
|
||||||
|
hasTabularNums: font.availableFeatures.includes("tnum"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEIGHT_WORDS = {
|
||||||
|
Thin: 100, ExtraLight: 200, Light: 300, Regular: 400, Italic: 400, Text: 450,
|
||||||
|
Medium: 500, SemiBold: 600, Bold: 700, ExtraBold: 800, Black: 900,
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Checks public/fonts against manifest.json. Exits non-zero with readable messages on any problem.
|
||||||
|
// Run: npm run fonts:verify
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { FONTS_DIR, MANIFEST_PATH, WEIGHT_WORDS, familyFolders, inspectFont, ttfFiles } from "./lib.mjs";
|
||||||
|
|
||||||
|
const problems = [];
|
||||||
|
const fail = (where, msg) => problems.push(`${where}: ${msg}`);
|
||||||
|
|
||||||
|
if (!existsSync(MANIFEST_PATH)) {
|
||||||
|
console.error("public/fonts/manifest.json is missing; run node scripts/fonts/build-manifest.mjs");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
||||||
|
if (manifest.version !== 1) fail("manifest", `unsupported version ${manifest.version}`);
|
||||||
|
|
||||||
|
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||||
|
const listed = new Set();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const fam of manifest.families) {
|
||||||
|
const dir = `public/fonts/${fam.folder}`;
|
||||||
|
if (slug(fam.family) !== fam.folder) fail(dir, `folder does not match family "${fam.family}"`);
|
||||||
|
if (!existsSync(path.join(FONTS_DIR, fam.folder, fam.licenseFile))) {
|
||||||
|
fail(dir, `licence file ${fam.licenseFile} is missing`);
|
||||||
|
}
|
||||||
|
for (const entry of fam.fonts) {
|
||||||
|
const where = `${fam.folder}/${entry.file}`;
|
||||||
|
listed.add(where);
|
||||||
|
count++;
|
||||||
|
if (!existsSync(path.join(FONTS_DIR, fam.folder, entry.file))) {
|
||||||
|
fail(where, "listed in the manifest but not on disk");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let f;
|
||||||
|
try {
|
||||||
|
f = inspectFont(fam.folder, entry.file);
|
||||||
|
} catch (err) {
|
||||||
|
fail(where, `cannot be parsed: ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (f.tag !== "0x00010000" && f.tag !== "true") {
|
||||||
|
fail(where, `not a TrueType font (magic "${f.tag}"); WOFF, WOFF2 and OpenType/CFF must not be bundled for PDFs`);
|
||||||
|
}
|
||||||
|
if (f.variable) fail(where, "variable font (has fvar); react-pdf would embed only the default instance");
|
||||||
|
if (f.info.sha256 !== entry.sha256) fail(where, "sha256 differs from the manifest; rebuild it if the change is intended");
|
||||||
|
if (f.info.fsType !== 0 && f.info.fsType !== 8) {
|
||||||
|
fail(where, `fsType ${f.info.fsType} does not allow embedding (need 0 or 8)`);
|
||||||
|
}
|
||||||
|
if (f.familyName !== fam.family) fail(where, `family is "${f.familyName}", manifest says "${fam.family}"`);
|
||||||
|
if (f.info.weight !== entry.weight || f.info.style !== entry.style) {
|
||||||
|
fail(where, `weight/style ${f.info.weight}/${f.info.style} differs from the manifest ${entry.weight}/${entry.style}`);
|
||||||
|
}
|
||||||
|
const word = entry.file.replace(/\.ttf$/i, "").split("-").pop();
|
||||||
|
const expected = WEIGHT_WORDS[word];
|
||||||
|
if (expected !== undefined && word !== "Text" && expected !== f.info.weight) {
|
||||||
|
fail(where, `file name says ${word} (${expected}) but OS/2 weight is ${f.info.weight}`);
|
||||||
|
}
|
||||||
|
if (/Italic$/.test(word) !== (f.info.style === "italic")) {
|
||||||
|
fail(where, `file name and OS/2 disagree about italic (${f.info.style})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const folder of familyFolders()) {
|
||||||
|
for (const file of ttfFiles(folder)) {
|
||||||
|
if (!listed.has(`${folder}/${file}`)) fail(`${folder}/${file}`, "TTF on disk but not in the manifest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length) {
|
||||||
|
console.error(`fonts:verify found ${problems.length} problem(s):`);
|
||||||
|
for (const p of problems) console.error(` - ${p}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`fonts:verify OK: ${manifest.families.length} families, ${count} fonts`);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Child-process scenarios for src/pdf/hang.test.ts. Bundled with esbuild and run with node, because
|
||||||
|
// react-pdf's paginate() is a synchronous loop: an in-process timer never fires while it spins.
|
||||||
|
import { createElement as h } from "react";
|
||||||
|
import { Document, Page, Path, Svg, Text, View } from "@react-pdf/renderer";
|
||||||
|
import path from "node:path";
|
||||||
|
import { FirstPageHeader } from "../../src/pdf/blocks/FirstPageHeader";
|
||||||
|
import { find } from "../../src/pdf/testing/docs";
|
||||||
|
import { registerBundledFonts, stack } from "../../src/pdf/fonts/register";
|
||||||
|
import { renderCore } from "../../src/pdf/render/core";
|
||||||
|
|
||||||
|
registerBundledFonts(path.resolve(process.cwd(), "public/fonts"));
|
||||||
|
|
||||||
|
const [scenario, rowsArg] = process.argv.slice(2);
|
||||||
|
const rows = Number(rowsArg);
|
||||||
|
const W = 595.28;
|
||||||
|
const H = 841.89;
|
||||||
|
const PAD = 40;
|
||||||
|
const text = { fontFamily: stack("IBM Plex Sans"), fontSize: 10, lineHeight: "12pt" };
|
||||||
|
|
||||||
|
const flow = Array.from({ length: rows }, (_, i) =>
|
||||||
|
h(View, { key: i, style: { height: 18 } }, h(Text, { style: text }, `Row ${i + 1}`)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const svg = (extra: Record<string, unknown>) =>
|
||||||
|
h(
|
||||||
|
Svg,
|
||||||
|
{ viewBox: "0 0 100 40", style: { width: W, height: 40, ...extra } },
|
||||||
|
h(Path, { d: "M0 0 L100 0 L100 40 L0 40 Z", fill: "#cccccc" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
function pageWith(children: unknown[]) {
|
||||||
|
return h(Document, null, h(Page, { size: { width: W, height: H }, style: { padding: PAD } }, ...(children as never[])));
|
||||||
|
}
|
||||||
|
|
||||||
|
const element =
|
||||||
|
scenario === "page-level-svg"
|
||||||
|
? // Non-fixed, absolute, full width, straddling the bottom margin line (H - PAD = 801.89).
|
||||||
|
pageWith([...flow, svg({ position: "absolute", left: 0, top: H - PAD - 20 })])
|
||||||
|
: pageWith([
|
||||||
|
h(
|
||||||
|
FirstPageHeader,
|
||||||
|
{
|
||||||
|
key: "head",
|
||||||
|
pad: { top: PAD, right: PAD, bottom: PAD, left: PAD },
|
||||||
|
decor: [{ x: 0, y: 10, width: W, height: 40, children: svg({}) }],
|
||||||
|
style: { height: 60 },
|
||||||
|
},
|
||||||
|
h(Text, { style: text }, "Header"),
|
||||||
|
),
|
||||||
|
...flow,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const r = await renderCore(element);
|
||||||
|
const svgPages = r.layout.map((p) => find(p, (n) => n.type === "svg").length);
|
||||||
|
console.log(JSON.stringify({ pages: r.pages, svgPages }));
|
||||||
Generated
+48
-1
@@ -1,6 +1,6 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
@@ -2887,6 +2887,16 @@ dependencies = [
|
|||||||
"sqlite-wasm-rs",
|
"sqlite-wasm-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite_migration"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "03451d4dc523c0a96d9e412c574670b3f5f184c3988b77a435876be25477bbd3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"rusqlite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -3658,6 +3668,37 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-single-instance"
|
||||||
|
version = "2.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee2c8fe2d6b75caed0153f91eda30ea1dea3bfb6710d5adb80456d2c035a569d"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-window-state"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -4195,13 +4236,19 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"percent-encoding",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"rusqlite_migration",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
|
"tauri-plugin-window-state",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
+12
-1
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||||
authors = ["Bongbetic"]
|
authors = ["Bongbetic"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77"
|
rust-version = "1.95"
|
||||||
|
|
||||||
# The `_lib` suffix keeps the library name distinct from the binary name on Windows.
|
# The `_lib` suffix keeps the library name distinct from the binary name on Windows.
|
||||||
[lib]
|
[lib]
|
||||||
@@ -21,10 +21,21 @@ tauri-plugin-opener = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
|
rusqlite_migration = "2.6"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
percent-encoding = "2"
|
||||||
|
|
||||||
|
# Desktop-only plugins (not available on mobile targets).
|
||||||
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Production builds embed the frontend and serve it over the custom asset
|
# Production builds embed the frontend and serve it over the custom asset
|
||||||
|
|||||||
@@ -6,6 +6,25 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"opener:default"
|
"opener:default",
|
||||||
|
"window-state:default",
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-open-path",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DOWNLOAD/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$HOME/**" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-reveal-item-in-dir",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$DOCUMENT/**" },
|
||||||
|
{ "path": "$DOWNLOAD/**" },
|
||||||
|
{ "path": "$DESKTOP/**" },
|
||||||
|
{ "path": "$HOME/**" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
//! Content-addressed archive of the searchable PDF produced when an invoice is issued.
|
||||||
|
//! Files live at `<local data dir>/archive/<sha256>.pdf`; the invoice row keeps the hash.
|
||||||
|
use super::raw::{header_map, raw_body, required_header, write_atomic, Headers};
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::ipc::{Request, Response};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
pub const MAX_ARCHIVE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
const MAX_FINGERPRINT_LEN: usize = 256;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ArchiveStatus {
|
||||||
|
pub archived: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub archived_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hash comes from the database, but it ends up in a file path, so check its shape.
|
||||||
|
fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> {
|
||||||
|
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
||||||
|
if !valid {
|
||||||
|
return Err("The stored archive hash is malformed".to_string());
|
||||||
|
}
|
||||||
|
Ok(local_dir.join("archive").join(format!("{sha256}.pdf")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_invoice_id(headers: &Headers) -> Result<i64, String> {
|
||||||
|
required_header(headers, "x-invoice-id")?
|
||||||
|
.trim()
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|_| "x-invoice-id must be a number".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn archive_pdf_impl(
|
||||||
|
conn: &mut Connection,
|
||||||
|
local_dir: &Path,
|
||||||
|
bytes: &[u8],
|
||||||
|
headers: &Headers,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let id = parse_invoice_id(headers)?;
|
||||||
|
let fingerprint = required_header(headers, "x-fingerprint")?.trim();
|
||||||
|
if fingerprint.is_empty() || fingerprint.len() > MAX_FINGERPRINT_LEN {
|
||||||
|
return Err("x-fingerprint must be 1-256 characters".to_string());
|
||||||
|
}
|
||||||
|
if !bytes.starts_with(b"%PDF-") {
|
||||||
|
return Err("The data is not a PDF".to_string());
|
||||||
|
}
|
||||||
|
if bytes.len() > MAX_ARCHIVE_BYTES {
|
||||||
|
return Err("The PDF is larger than the 64 MB archive limit".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let row: Option<(String, Option<String>)> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT status, archived_pdf_sha256 FROM invoices WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
|
if status != "issued" && status != "cancelled" {
|
||||||
|
return Err(format!("Only an issued invoice can be archived (this one is {status})"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sha = sha256_hex(bytes);
|
||||||
|
if let Some(existing) = &existing {
|
||||||
|
if *existing != sha {
|
||||||
|
return Err(
|
||||||
|
"This invoice already has an archived original; the issued PDF cannot be replaced"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = archive_path(local_dir, &sha)?;
|
||||||
|
std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
|
||||||
|
.map_err(|e| format!("Could not create the archive folder: {e}"))?;
|
||||||
|
// Same content, same name; a matching length means the file is already in place.
|
||||||
|
let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);
|
||||||
|
if !present {
|
||||||
|
write_atomic(&path, bytes)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.is_none() {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE invoices SET archived_pdf_sha256 = ?1, archived_fingerprint = ?2, archived_at = ?3
|
||||||
|
WHERE id = ?4",
|
||||||
|
params![sha, fingerprint, now, id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(sha)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> {
|
||||||
|
let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
|
||||||
|
let path = archive_path(local_dir, &sha)?;
|
||||||
|
let bytes = std::fs::read(&path).map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
"The archived PDF file is missing".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Could not read the archived PDF: {e}")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if sha256_hex(&bytes) != sha {
|
||||||
|
return Err("The archived PDF is corrupted (its checksum no longer matches)".to_string());
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
|
||||||
|
let row: Option<Option<String>> = conn
|
||||||
|
.query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
row.ok_or_else(|| "Invoice not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn archive_status_impl(conn: &Connection, id: i64) -> Result<ArchiveStatus, String> {
|
||||||
|
let row: Option<(Option<String>, Option<String>, Option<String>)> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
|
Ok(ArchiveStatus { archived: sha256.is_some(), sha256, fingerprint, archived_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn archive_pdf(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let bytes = raw_body(&request)?;
|
||||||
|
let headers = header_map(&request);
|
||||||
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
archive_pdf_impl(&mut conn, &state.local_data_dir, bytes, &headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result<Response, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
read_archive_impl(&conn, &state.local_data_dir, invoice_id).map(Response::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result<ArchiveStatus, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
archive_status_impl(&conn, invoice_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn conn_with_invoice(status: &str) -> Connection {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, client_name, status, created_at, updated_at)
|
||||||
|
VALUES ('INV/2026-001', '2026-04-01', 'Client', ?1, 'now', 'now')",
|
||||||
|
params![status],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headers(id: &str) -> Headers {
|
||||||
|
Headers::from([
|
||||||
|
("x-invoice-id".to_string(), id.to_string()),
|
||||||
|
("x-fingerprint".to_string(), "fp-1".to_string()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDF: &[u8] = b"%PDF-1.7\nbody";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archives_once_and_is_idempotent() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
assert_eq!(sha.len(), 64);
|
||||||
|
assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF);
|
||||||
|
|
||||||
|
let status = archive_status_impl(&conn, 1).unwrap();
|
||||||
|
assert!(status.archived);
|
||||||
|
assert_eq!(status.sha256.as_deref(), Some(sha.as_str()));
|
||||||
|
assert_eq!(status.fingerprint.as_deref(), Some("fp-1"));
|
||||||
|
let first_at = status.archived_at.clone().unwrap();
|
||||||
|
|
||||||
|
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
assert_eq!(again, sha);
|
||||||
|
assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at);
|
||||||
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_invoices_can_be_archived() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("cancelled");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_bytes_for_an_archived_invoice_are_refused() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
|
||||||
|
assert!(err.contains("already has an archived original"), "{err}");
|
||||||
|
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
|
||||||
|
assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_pdf_and_oversize_bodies_are_refused() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("not a PDF"));
|
||||||
|
let mut big = b"%PDF-".to_vec();
|
||||||
|
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("64 MB"));
|
||||||
|
assert!(!archive_status_impl(&conn, 1).unwrap().archived);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drafts_unknown_invoices_and_bad_headers_are_refused() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("draft");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1"))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Only an issued invoice"));
|
||||||
|
assert_eq!(
|
||||||
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("99")).unwrap_err(),
|
||||||
|
"Invoice not found"
|
||||||
|
);
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("abc")).is_err());
|
||||||
|
let mut no_fp = headers("1");
|
||||||
|
no_fp.remove("x-fingerprint");
|
||||||
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &no_fp).is_err());
|
||||||
|
assert!(!dir.path().join("archive").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_archive_detects_corruption_and_missing_files() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let mut conn = conn_with_invoice("issued");
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF"));
|
||||||
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
|
||||||
|
|
||||||
|
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted"));
|
||||||
|
|
||||||
|
std::fs::remove_file(&file).unwrap();
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing"));
|
||||||
|
// Archiving the same bytes again restores the file.
|
||||||
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
||||||
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_stored_hash_never_becomes_a_path() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = conn_with_invoice("issued");
|
||||||
|
conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", [])
|
||||||
|
.unwrap();
|
||||||
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
|
const FORMAT_ERROR: &str = "Use a PNG or JPEG image";
|
||||||
|
|
||||||
fn safe_kind(kind: &str) -> String {
|
fn safe_kind(kind: &str) -> String {
|
||||||
kind.chars()
|
kind.chars()
|
||||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
||||||
@@ -10,47 +12,88 @@ fn safe_kind(kind: &str) -> String {
|
|||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_name(name: &str) -> String {
|
/// Identify an image by its magic bytes. react-pdf only renders PNG and JPEG and
|
||||||
name.chars()
|
/// silently drops everything else, so nothing else is accepted.
|
||||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
|
/// Returns (extension, MIME type).
|
||||||
.collect()
|
pub fn sniff_image(bytes: &[u8]) -> Result<(&'static str, &'static str), String> {
|
||||||
}
|
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||||
|
Ok(("png", "image/png"))
|
||||||
fn extension_of(path: &str) -> String {
|
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||||
Path::new(path)
|
Ok(("jpg", "image/jpeg"))
|
||||||
.extension()
|
} else {
|
||||||
.and_then(|e| e.to_str())
|
Err(FORMAT_ERROR.to_string())
|
||||||
.map(|e| e.to_lowercase())
|
|
||||||
.unwrap_or_else(|| "png".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mime_for(ext: &str) -> &'static str {
|
|
||||||
match ext {
|
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
|
||||||
"svg" => "image/svg+xml",
|
|
||||||
"webp" => "image/webp",
|
|
||||||
"gif" => "image/gif",
|
|
||||||
_ => "image/png",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assets_dir(state: &State<AppState>) -> Result<PathBuf, String> {
|
fn assets_dir(data_dir: &Path) -> Result<PathBuf, String> {
|
||||||
let dir = state.data_dir.join("assets");
|
let dir = data_dir.join("assets");
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_asset(state: &State<AppState>, kind: &str, ext: &str, bytes: &[u8]) -> Result<String, String> {
|
/// Resolve a stored asset path (relative to the data dir, or a legacy absolute one)
|
||||||
let dir = assets_dir(state)?;
|
/// to a real file inside `<data_dir>/assets`. `..`, symlink escapes and anything
|
||||||
let file = format!(
|
/// outside the assets directory are rejected.
|
||||||
"{}-{}.{}",
|
pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
|
||||||
safe_kind(kind),
|
let raw = Path::new(path);
|
||||||
uuid::Uuid::new_v4().simple(),
|
if path.trim().is_empty() {
|
||||||
ext
|
return Err("Asset path is empty".into());
|
||||||
);
|
}
|
||||||
let path = dir.join(file);
|
if raw.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
|
return Err("Asset path must not contain '..'".into());
|
||||||
Ok(path.to_string_lossy().to_string())
|
}
|
||||||
|
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
||||||
|
let root = data_dir
|
||||||
|
.join("assets")
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("Asset store is unavailable: {e}"))?;
|
||||||
|
let canonical = full
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("Could not open asset {path}: {e}"))?;
|
||||||
|
if canonical.starts_with(&root) && canonical != root {
|
||||||
|
Ok(canonical)
|
||||||
|
} else {
|
||||||
|
Err("Asset path is outside the asset store".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The form stored in the database: `assets/<file>` relative to the data dir, with
|
||||||
|
/// forward slashes on every platform.
|
||||||
|
pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String> {
|
||||||
|
let canonical = resolve_asset(data_dir, path)?;
|
||||||
|
let base = data_dir.canonicalize().map_err(|e| e.to_string())?;
|
||||||
|
let rel = canonical.strip_prefix(&base).map_err(|e| e.to_string())?;
|
||||||
|
Ok(rel
|
||||||
|
.components()
|
||||||
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
|
||||||
|
let (ext, _) = sniff_image(bytes)?;
|
||||||
|
let dir = assets_dir(data_dir)?;
|
||||||
|
let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext);
|
||||||
|
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
|
||||||
|
Ok(format!("assets/{file}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
|
||||||
|
let file = resolve_asset(data_dir, path)?;
|
||||||
|
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
|
||||||
|
let (_, mime) = sniff_image(&bytes)?;
|
||||||
|
Ok(format!("data:{};base64,{}", mime, STANDARD.encode(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
|
||||||
|
let raw = Path::new(path);
|
||||||
|
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
||||||
|
// Removing something that is already gone is fine, but an escape attempt is not.
|
||||||
|
if std::fs::symlink_metadata(&full).is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let target = resolve_asset(data_dir, path)?;
|
||||||
|
std::fs::remove_file(target).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy a user-picked file into the app's asset store and return the stored path.
|
/// Copy a user-picked file into the app's asset store and return the stored path.
|
||||||
@@ -61,56 +104,132 @@ pub fn import_asset(
|
|||||||
kind: String,
|
kind: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
|
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
|
||||||
let ext = extension_of(&source_path);
|
write_asset(&state.data_dir, &kind, &bytes)
|
||||||
write_asset(&state, &kind, &ext, &bytes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Used when the frontend already holds the bytes (base64) instead of a path.
|
/// Used when the frontend already holds the bytes (base64) instead of a path.
|
||||||
|
/// The file name is ignored: the type comes from the bytes.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_asset_bytes(
|
pub fn save_asset_bytes(
|
||||||
state: State<AppState>,
|
state: State<AppState>,
|
||||||
kind: String,
|
kind: String,
|
||||||
file_name: String,
|
#[allow(unused_variables)] file_name: String,
|
||||||
data_base64: String,
|
data_base64: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let bytes = STANDARD
|
let bytes = STANDARD
|
||||||
.decode(data_base64.as_bytes())
|
.decode(data_base64.as_bytes())
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let ext = extension_of(&sanitize_name(&file_name));
|
write_asset(&state.data_dir, &kind, &bytes)
|
||||||
write_asset(&state, &kind, &ext, &bytes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a stored asset back as a data URI so it can be embedded in the PDF.
|
/// Read a stored asset back as a data URI so it can be embedded in the PDF.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn read_asset_data_uri(path: String) -> Result<String, String> {
|
pub fn read_asset_data_uri(state: State<AppState>, path: String) -> Result<String, String> {
|
||||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
asset_data_uri(&state.data_dir, &path)
|
||||||
let ext = extension_of(&path);
|
|
||||||
Ok(format!(
|
|
||||||
"data:{};base64,{}",
|
|
||||||
mime_for(&ext),
|
|
||||||
STANDARD.encode(bytes)
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
|
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
|
||||||
let dir = assets_dir(&state)?;
|
remove_asset_file(&state.data_dir, &path)
|
||||||
let target = PathBuf::from(&path);
|
|
||||||
// Only allow deleting files inside the managed assets directory.
|
|
||||||
if target.starts_with(&dir) && target.exists() {
|
|
||||||
std::fs::remove_file(&target).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path.
|
#[cfg(test)]
|
||||||
#[tauri::command]
|
mod tests {
|
||||||
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> {
|
use super::*;
|
||||||
let bytes = STANDARD
|
use tempfile::tempdir;
|
||||||
.decode(data_base64.as_bytes())
|
|
||||||
.map_err(|e| e.to_string())?;
|
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
|
||||||
if let Some(parent) = Path::new(&path).parent() {
|
const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F'];
|
||||||
std::fs::create_dir_all(parent).ok();
|
|
||||||
|
#[test]
|
||||||
|
fn sniffing_accepts_only_png_and_jpeg() {
|
||||||
|
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
|
||||||
|
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
|
||||||
|
assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image");
|
||||||
|
assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err());
|
||||||
|
assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
|
||||||
|
assert!(sniff_image(b"").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_ignores_the_name_and_stores_a_relative_path() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let stored = write_asset(dir.path(), "Logo", JPEG).unwrap();
|
||||||
|
assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}");
|
||||||
|
assert!(dir.path().join(&stored).is_file());
|
||||||
|
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
|
||||||
|
let uri = asset_data_uri(dir.path(), &stored).unwrap();
|
||||||
|
assert!(uri.starts_with("data:image/jpeg;base64,"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_uri_mime_comes_from_the_bytes_not_the_extension() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let assets = dir.path().join("assets");
|
||||||
|
std::fs::create_dir_all(&assets).unwrap();
|
||||||
|
std::fs::write(assets.join("liar.jpg"), PNG).unwrap();
|
||||||
|
let uri = asset_data_uri(dir.path(), "assets/liar.jpg").unwrap();
|
||||||
|
assert!(uri.starts_with("data:image/png;base64,"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_accepts_relative_and_inside_absolute_paths() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
|
||||||
|
let by_relative = resolve_asset(dir.path(), &stored).unwrap();
|
||||||
|
let absolute = dir.path().join(&stored);
|
||||||
|
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(by_relative, by_absolute);
|
||||||
|
assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolver_rejects_escapes() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
|
||||||
|
std::fs::write(dir.path().join("voiced.db"), b"secret").unwrap();
|
||||||
|
let outside = tempdir().unwrap();
|
||||||
|
std::fs::write(outside.path().join("x.png"), PNG).unwrap();
|
||||||
|
|
||||||
|
assert!(resolve_asset(dir.path(), "../voiced.db").is_err());
|
||||||
|
assert!(resolve_asset(dir.path(), "assets/../voiced.db").is_err());
|
||||||
|
assert!(resolve_asset(dir.path(), "voiced.db").is_err());
|
||||||
|
assert!(resolve_asset(dir.path(), "assets").is_err());
|
||||||
|
assert!(resolve_asset(dir.path(), "").is_err());
|
||||||
|
let abs_outside = outside.path().join("x.png");
|
||||||
|
assert!(resolve_asset(dir.path(), abs_outside.to_str().unwrap()).is_err());
|
||||||
|
let abs_db = dir.path().join("voiced.db");
|
||||||
|
assert!(resolve_asset(dir.path(), abs_db.to_str().unwrap()).is_err());
|
||||||
|
assert!(resolve_asset(dir.path(), "assets/missing.png").is_err());
|
||||||
|
assert!(resolve_asset(dir.path(), &stored).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn resolver_rejects_symlink_escapes() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let _ = write_asset(dir.path(), "logo", PNG).unwrap();
|
||||||
|
let outside = tempdir().unwrap();
|
||||||
|
let target = outside.path().join("x.png");
|
||||||
|
std::fs::write(&target, PNG).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&target, dir.path().join("assets/link.png")).unwrap();
|
||||||
|
std::os::unix::fs::symlink(outside.path(), dir.path().join("assets/dir")).unwrap();
|
||||||
|
|
||||||
|
assert!(resolve_asset(dir.path(), "assets/link.png").is_err());
|
||||||
|
assert!(resolve_asset(dir.path(), "assets/dir/x.png").is_err());
|
||||||
|
assert!(remove_asset_file(dir.path(), "assets/link.png").is_err());
|
||||||
|
assert!(target.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_deletes_inside_and_tolerates_missing() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
|
||||||
|
remove_asset_file(dir.path(), &stored).unwrap();
|
||||||
|
assert!(!dir.path().join(&stored).exists());
|
||||||
|
remove_asset_file(dir.path(), &stored).unwrap();
|
||||||
|
std::fs::write(dir.path().join("voiced.db"), b"x").unwrap();
|
||||||
|
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
|
||||||
|
assert!(dir.path().join("voiced.db").exists());
|
||||||
}
|
}
|
||||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! User-visible files: exports written to a path from the save dialog, and the
|
||||||
|
//! reveal/open helpers used afterwards.
|
||||||
|
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers};
|
||||||
|
use crate::AppState;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use tauri::ipc::Request;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
pub fn write_export_file_impl(
|
||||||
|
conn: &Connection,
|
||||||
|
bytes: &[u8],
|
||||||
|
headers: &Headers,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let raw = decode_header_path(required_header(headers, "x-path")?)?;
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return Err("No file path given".to_string());
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(&raw);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("The export path must be absolute".to_string());
|
||||||
|
}
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err("Nothing to write: the file is empty".to_string());
|
||||||
|
}
|
||||||
|
let parent = path.parent().ok_or_else(|| "The export path has no folder".to_string())?;
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||||
|
write_atomic(&path, bytes)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET last_export_dir = ?1 WHERE id = 1",
|
||||||
|
params![parent.to_string_lossy()],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> {
|
||||||
|
conn.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_existing(path: &str) -> Result<PathBuf, String> {
|
||||||
|
if path.trim().is_empty() {
|
||||||
|
return Err("No file path given".to_string());
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(format!("{} does not exist", path.display()));
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last resort when the opener plugin cannot reach a file manager (a minimal Linux
|
||||||
|
/// install has no D-Bus file-manager service or portal). Arguments are passed
|
||||||
|
/// individually; nothing goes through a shell.
|
||||||
|
fn reveal_fallback_command(path: &Path) -> Command {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let mut cmd = Command::new("explorer");
|
||||||
|
cmd.arg(format!("/select,{}", path.display()));
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let mut cmd = Command::new("open");
|
||||||
|
cmd.arg("-R").arg(path);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
|
||||||
|
let mut cmd = Command::new("xdg-open");
|
||||||
|
cmd.arg(folder);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_fallback_command(path: &Path) -> Command {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let mut cmd = Command::new("explorer");
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let mut cmd = Command::new("open");
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
let mut cmd = Command::new("xdg-open");
|
||||||
|
cmd.arg(path);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
||||||
|
let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?;
|
||||||
|
// Reap the child so it does not linger as a zombie; the result is irrelevant
|
||||||
|
// (explorer.exe, for one, exits non-zero even on success).
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let bytes = raw_body(&request)?;
|
||||||
|
let headers = header_map(&request);
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
write_export_file_impl(&conn, bytes, &headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_last_export_dir(state: State<AppState>) -> Result<String, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
get_last_export_dir_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||||
|
let path = require_existing(&path)?;
|
||||||
|
if app.opener().reveal_item_in_dir(&path).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
spawn_detached(reveal_fallback_command(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||||
|
let path = require_existing(&path)?;
|
||||||
|
if app.opener().open_path(path.to_string_lossy(), None::<&str>).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
spawn_detached(open_fallback_command(&path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn headers(path: &str) -> Headers {
|
||||||
|
Headers::from([("x-path".to_string(), path.to_string())])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(path: &Path) -> String {
|
||||||
|
percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_the_file_and_remembers_the_folder() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
|
||||||
|
// Non-ASCII, spaces and a folder that does not exist yet.
|
||||||
|
let target = dir.path().join("New folder").join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf");
|
||||||
|
let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
|
||||||
|
assert_eq!(PathBuf::from(&written), target);
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
|
||||||
|
assert_eq!(
|
||||||
|
get_last_export_dir_impl(&conn).unwrap(),
|
||||||
|
target.parent().unwrap().to_string_lossy()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Overwrites in place.
|
||||||
|
write_export_file_impl(&conn, b"%PDF-2", &headers(&encode(&target))).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_relative_and_missing_paths() {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] {
|
||||||
|
assert!(write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err());
|
||||||
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_an_empty_body_without_touching_the_folder_setting() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let target = dir.path().join("a.pdf");
|
||||||
|
assert!(write_export_file_impl(&conn, b"", &headers(&encode(&target))).is_err());
|
||||||
|
assert!(!target.exists());
|
||||||
|
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reveal_and_open_refuse_missing_paths() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
assert!(require_existing("").is_err());
|
||||||
|
let missing = dir.path().join("nope.pdf");
|
||||||
|
assert!(require_existing(&missing.to_string_lossy()).unwrap_err().contains("does not exist"));
|
||||||
|
assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn linux_fallback_opens_the_parent_folder_with_separate_args() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let file = dir.path().join("a b; rm -rf.pdf");
|
||||||
|
std::fs::write(&file, b"x").unwrap();
|
||||||
|
let cmd = reveal_fallback_command(&file);
|
||||||
|
assert_eq!(cmd.get_program(), "xdg-open");
|
||||||
|
let args: Vec<_> = cmd.get_args().collect();
|
||||||
|
assert_eq!(args, [dir.path().as_os_str()]);
|
||||||
|
let open = open_fallback_command(&file);
|
||||||
|
assert_eq!(open.get_args().collect::<Vec<_>>(), [file.as_os_str()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
|
use super::assets::relative_asset_path;
|
||||||
|
use super::series::{validate_series_format, MAX_NUMBER_LEN};
|
||||||
|
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
||||||
use crate::db::format_number;
|
use crate::db::format_number;
|
||||||
use crate::models::{Invoice, InvoiceInput, InvoiceItem, InvoiceSummary};
|
use crate::gst::{self, TaxType};
|
||||||
|
use crate::models::{
|
||||||
|
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
|
||||||
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
use chrono::NaiveDate;
|
||||||
|
use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
|
||||||
|
use std::path::Path;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, client_id, client_name,
|
const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, client_id, client_name,
|
||||||
client_address, client_gstin, po_number, place_of_supply_state_code, subtotal, discount,
|
client_address, client_gstin, po_number, place_of_supply_state_code, subtotal, discount,
|
||||||
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
||||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at";
|
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at,
|
||||||
|
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
|
||||||
|
cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, ''), archived_fingerprint, archived_at";
|
||||||
|
|
||||||
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||||
Ok(Invoice {
|
Ok(Invoice {
|
||||||
@@ -38,13 +48,23 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
|||||||
status: row.get(24)?,
|
status: row.get(24)?,
|
||||||
created_at: row.get(25)?,
|
created_at: row.get(25)?,
|
||||||
updated_at: row.get(26)?,
|
updated_at: row.get(26)?,
|
||||||
|
doc_type: row.get(27)?,
|
||||||
|
reverse_charge: row.get::<_, i64>(28)? != 0,
|
||||||
|
vendor_snapshot: row.get(29)?,
|
||||||
|
snapshot_origin: row.get(30)?,
|
||||||
|
cancelled_at: row.get(31)?,
|
||||||
|
cancel_reason: row.get(32)?,
|
||||||
|
archived_pdf_sha256: row.get(33)?,
|
||||||
|
render_prefs: row.get(34)?,
|
||||||
|
archived_fingerprint: row.get(35)?,
|
||||||
|
archived_at: row.get(36)?,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<InvoiceItem>> {
|
fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<InvoiceItem>> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, description, mode, rate, unit, quantity, amount, sort_order
|
"SELECT id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac
|
||||||
FROM invoice_items WHERE invoice_id = ?1 ORDER BY sort_order ASC, id ASC",
|
FROM invoice_items WHERE invoice_id = ?1 ORDER BY sort_order ASC, id ASC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![invoice_id], |row| {
|
let rows = stmt.query_map(params![invoice_id], |row| {
|
||||||
@@ -57,6 +77,7 @@ fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<Invoi
|
|||||||
quantity: row.get(5)?,
|
quantity: row.get(5)?,
|
||||||
amount: row.get(6)?,
|
amount: row.get(6)?,
|
||||||
sort_order: row.get(7)?,
|
sort_order: row.get(7)?,
|
||||||
|
hsn_sac: row.get(8)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
rows.collect()
|
rows.collect()
|
||||||
@@ -90,31 +111,224 @@ pub fn peek_next_invoice_number(state: State<AppState>) -> Result<String, String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
fn paise_to_rupees(paise: i64) -> f64 {
|
||||||
pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Invoice, String> {
|
paise as f64 / 100.0
|
||||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
}
|
||||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let series = tx
|
fn parse_date(label: &str, value: &str) -> Result<(), String> {
|
||||||
|
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|_| format!("{label} must be a date like 2026-04-01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Largest rupee amount accepted for a single line, far below anything that could
|
||||||
|
/// overflow integer paise arithmetic.
|
||||||
|
const MAX_LINE_RUPEES: f64 = 1e11;
|
||||||
|
|
||||||
|
fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err("Add at least one line item".into());
|
||||||
|
}
|
||||||
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
let n = i + 1;
|
||||||
|
let finite_ok = |v: f64| v.is_finite() && (0.0..=MAX_LINE_RUPEES).contains(&v);
|
||||||
|
match item.mode.as_str() {
|
||||||
|
"rate" => {
|
||||||
|
if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 {
|
||||||
|
return Err(format!("Line {n}: rate and quantity must be positive numbers"));
|
||||||
|
}
|
||||||
|
if item.quantity > MAX_LINE_RUPEES {
|
||||||
|
return Err(format!("Line {n}: quantity is too large"));
|
||||||
|
}
|
||||||
|
let amount = gst::line_amount_paise(item);
|
||||||
|
if amount <= 0 {
|
||||||
|
return Err(format!("Line {n}: amount must be greater than zero"));
|
||||||
|
}
|
||||||
|
if amount as f64 / 100.0 > MAX_LINE_RUPEES {
|
||||||
|
return Err(format!("Line {n}: amount is too large"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fixed" => {
|
||||||
|
if !finite_ok(item.amount) {
|
||||||
|
return Err(format!("Line {n}: amount must be zero or more"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => return Err(format!("Line {n}: unknown line type \"{other}\"")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks that the supplier details in settings can legally produce this document.
|
||||||
|
fn validate_supplier(settings: &Settings) -> Result<(), String> {
|
||||||
|
if !gst::state_exists(&settings.vendor_state_code) {
|
||||||
|
return Err(format!(
|
||||||
|
"Supplier state code \"{}\" is not a valid GST state code. Fix it in Settings.",
|
||||||
|
settings.vendor_state_code
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match settings.gst_registration.as_str() {
|
||||||
|
"unregistered" => Ok(()),
|
||||||
|
"regular" => {
|
||||||
|
let gstin = settings.vendor_gstin.trim().to_ascii_uppercase();
|
||||||
|
if gstin.is_empty() {
|
||||||
|
return Err("A registered supplier needs a GSTIN. Add it in Settings.".into());
|
||||||
|
}
|
||||||
|
gst::validate_gstin(&gstin).map_err(|e| format!("Supplier GSTIN: {e}"))?;
|
||||||
|
if gstin[0..2] != settings.vendor_state_code {
|
||||||
|
return Err(format!(
|
||||||
|
"Supplier GSTIN starts with state code {} but the supplier state is {}",
|
||||||
|
&gstin[0..2],
|
||||||
|
settings.vendor_state_code
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let pan = settings.vendor_pan.trim();
|
||||||
|
if !pan.is_empty() && !gst::gstin_matches_pan(&gstin, pan) {
|
||||||
|
return Err("Supplier GSTIN does not contain the supplier PAN".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => Err(format!("Unsupported GST registration type \"{other}\"")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supplier details frozen onto the invoice. Built from the stored settings, never
|
||||||
|
/// from anything the webview sent. An unregistered supplier has no GSTIN to print.
|
||||||
|
fn vendor_snapshot(settings: &Settings) -> String {
|
||||||
|
let gstin = if settings.gst_registration == "unregistered" {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
settings.vendor_gstin.trim()
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"vendorName": settings.vendor_name,
|
||||||
|
"vendorAddress": settings.vendor_address,
|
||||||
|
"vendorEmail": settings.vendor_email,
|
||||||
|
"vendorPhone": settings.vendor_phone,
|
||||||
|
"vendorPan": settings.vendor_pan,
|
||||||
|
"vendorGstin": gstin,
|
||||||
|
"vendorStateCode": settings.vendor_state_code,
|
||||||
|
"logoPath": settings.logo_path,
|
||||||
|
"signaturePath": settings.signature_path,
|
||||||
|
"gstRegistration": settings.gst_registration,
|
||||||
|
"signatoryName": settings.signatory_name,
|
||||||
|
"signatoryDesignation": settings.signatory_designation,
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bank_snapshot(conn: &Connection, bank_id: Option<i64>) -> Result<String, String> {
|
||||||
|
let Some(id) = bank_id else {
|
||||||
|
return Ok(String::new());
|
||||||
|
};
|
||||||
|
let bank: BankAccount = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id, label, bank_name, account_name, account_no, branch, ifsc, is_default
|
||||||
|
FROM bank_accounts WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
map_bank,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| "The selected bank account no longer exists".to_string())?;
|
||||||
|
serde_json::to_string(&bank).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate, price and store an invoice in one transaction. The series counter only
|
||||||
|
/// moves if everything succeeded.
|
||||||
|
pub fn issue_invoice_impl(
|
||||||
|
conn: &mut Connection,
|
||||||
|
data_dir: &Path,
|
||||||
|
input: InvoiceInput,
|
||||||
|
render_prefs: &serde_json::Value,
|
||||||
|
) -> Result<Invoice, String> {
|
||||||
|
let db = |e: rusqlite::Error| e.to_string();
|
||||||
|
let tx = conn.transaction().map_err(db)?;
|
||||||
|
|
||||||
|
let settings: Settings = tx
|
||||||
|
.query_row(
|
||||||
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
||||||
|
[],
|
||||||
|
map_settings,
|
||||||
|
)
|
||||||
|
.map_err(db)?;
|
||||||
|
let (series_id, prefix, padding, next_number): (i64, String, i64, i64) = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id, prefix, padding, next_number FROM invoice_series
|
"SELECT id, prefix, padding, next_number FROM invoice_series
|
||||||
WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
|
WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
|
||||||
[],
|
[],
|
||||||
|r| {
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||||
Ok((
|
|
||||||
r.get::<_, i64>(0)?,
|
|
||||||
r.get::<_, String>(1)?,
|
|
||||||
r.get::<_, i64>(2)?,
|
|
||||||
r.get::<_, i64>(3)?,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(db)?
|
||||||
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
||||||
|
|
||||||
let (series_id, prefix, padding, next_number) = series;
|
validate_series_format(&prefix, padding)
|
||||||
|
.map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new series."))?;
|
||||||
let number = format_number(&prefix, padding, next_number);
|
let number = format_number(&prefix, padding, next_number);
|
||||||
|
if number.len() > MAX_NUMBER_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"Invoice number {number} is longer than {MAX_NUMBER_LEN} characters. Start a new series."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_supplier(&settings)?;
|
||||||
|
parse_date("Invoice date", &input.invoice_date)?;
|
||||||
|
if !input.due_date.trim().is_empty() {
|
||||||
|
parse_date("Due date", &input.due_date)?;
|
||||||
|
}
|
||||||
|
validate_items(&input.items)?;
|
||||||
|
|
||||||
|
let client_gstin = match input.client_gstin.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"" | "NA" => String::new(),
|
||||||
|
g => {
|
||||||
|
gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?;
|
||||||
|
g.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pos = input.place_of_supply_state_code.trim().to_string();
|
||||||
|
if !pos.is_empty() && !gst::state_exists(&pos) {
|
||||||
|
return Err(format!("Place of supply \"{pos}\" is not a valid GST state code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let derived = gst::derive_tax_type(&settings.gst_registration, &settings.vendor_state_code, &pos);
|
||||||
|
if input.tax_type != derived.as_str() {
|
||||||
|
return Err(format!(
|
||||||
|
"Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}",
|
||||||
|
input.tax_type,
|
||||||
|
derived.as_str(),
|
||||||
|
settings.vendor_state_code,
|
||||||
|
if pos.is_empty() { &settings.vendor_state_code } else { &pos },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let tax_rate = if derived == TaxType::None { 0.0 } else { input.tax_rate };
|
||||||
|
if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) {
|
||||||
|
return Err("Tax rate must be between 0 and 100".into());
|
||||||
|
}
|
||||||
|
let rate_bp = (tax_rate * 100.0).round() as i64;
|
||||||
|
let stored_pos = if pos.is_empty() && derived != TaxType::None {
|
||||||
|
settings.vendor_state_code.clone()
|
||||||
|
} else {
|
||||||
|
pos
|
||||||
|
};
|
||||||
|
|
||||||
|
let totals = gst::compute_totals(
|
||||||
|
&input.items,
|
||||||
|
gst::rupees_to_paise(input.discount),
|
||||||
|
derived,
|
||||||
|
rate_bp,
|
||||||
|
);
|
||||||
|
let words = gst::amount_in_words(totals.total);
|
||||||
|
let snapshot = vendor_snapshot(&settings);
|
||||||
|
let bank = bank_snapshot(&tx, input.bank_account_id)?;
|
||||||
|
let signature_path = match input.signature_path.as_deref().map(str::trim) {
|
||||||
|
Some(p) if !p.is_empty() => Some(
|
||||||
|
relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?,
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?;
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
// Persist the client first when the user asked to save a new one.
|
// Persist the client first when the user asked to save a new one.
|
||||||
let client_id = match input.client_id {
|
let client_id = match input.client_id {
|
||||||
@@ -126,65 +340,78 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
|
|||||||
params![
|
params![
|
||||||
input.client_name,
|
input.client_name,
|
||||||
input.client_address,
|
input.client_address,
|
||||||
input.client_gstin,
|
client_gstin,
|
||||||
input.place_of_supply_state_code,
|
stored_pos,
|
||||||
input.po_number,
|
input.po_number,
|
||||||
chrono::Utc::now().to_rfc3339()
|
now
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(db)?;
|
||||||
Some(tx.last_insert_rowid())
|
Some(tx.last_insert_rowid())
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
r#"INSERT INTO invoices
|
r#"INSERT INTO invoices
|
||||||
(number, series_id, invoice_date, due_date, client_id, client_name, client_address,
|
(number, series_id, invoice_date, due_date, client_id, client_name, client_address,
|
||||||
client_gstin, po_number, place_of_supply_state_code, subtotal, discount, tax_type,
|
client_gstin, po_number, place_of_supply_state_code, subtotal, discount, tax_type,
|
||||||
tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
||||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at)
|
bank_account_id, bank_snapshot, signature_path, notes, status, doc_type,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
reverse_charge, vendor_snapshot, render_prefs, snapshot_origin, created_at, updated_at)
|
||||||
?18, ?19, ?20, ?21, ?22, ?23, 'issued', ?24, ?24)"#,
|
VALUES (:number, :series_id, :invoice_date, :due_date, :client_id, :client_name,
|
||||||
params![
|
:client_address, :client_gstin, :po_number, :pos, :subtotal, :discount,
|
||||||
number,
|
:tax_type, :tax_rate, :cgst, :sgst, :igst, :total, :words, :bank_id,
|
||||||
series_id,
|
:bank_snapshot, :signature_path, :notes, 'issued', :doc_type,
|
||||||
input.invoice_date,
|
:reverse_charge, :vendor_snapshot, :render_prefs, 'issued', :now, :now)"#,
|
||||||
input.due_date,
|
named_params! {
|
||||||
client_id,
|
":number": number,
|
||||||
input.client_name,
|
":series_id": series_id,
|
||||||
input.client_address,
|
":invoice_date": input.invoice_date.trim(),
|
||||||
input.client_gstin,
|
":due_date": input.due_date.trim(),
|
||||||
input.po_number,
|
":client_id": client_id,
|
||||||
input.place_of_supply_state_code,
|
":client_name": input.client_name,
|
||||||
input.subtotal,
|
":client_address": input.client_address,
|
||||||
input.discount,
|
":client_gstin": client_gstin,
|
||||||
input.tax_type,
|
":po_number": input.po_number,
|
||||||
input.tax_rate,
|
":pos": stored_pos,
|
||||||
input.cgst_amount,
|
":subtotal": paise_to_rupees(totals.subtotal),
|
||||||
input.sgst_amount,
|
":discount": paise_to_rupees(totals.discount),
|
||||||
input.igst_amount,
|
":tax_type": derived.as_str(),
|
||||||
input.total,
|
":tax_rate": tax_rate,
|
||||||
input.amount_in_words,
|
":cgst": paise_to_rupees(totals.cgst),
|
||||||
input.bank_account_id,
|
":sgst": paise_to_rupees(totals.sgst),
|
||||||
input.bank_snapshot,
|
":igst": paise_to_rupees(totals.igst),
|
||||||
input.signature_path,
|
":total": paise_to_rupees(totals.total),
|
||||||
input.notes,
|
":words": words,
|
||||||
now,
|
":bank_id": input.bank_account_id,
|
||||||
],
|
":bank_snapshot": bank,
|
||||||
|
":signature_path": signature_path,
|
||||||
|
":notes": input.notes,
|
||||||
|
":doc_type": gst::doc_type(&settings.gst_registration),
|
||||||
|
":reverse_charge": input.reverse_charge,
|
||||||
|
":vendor_snapshot": snapshot,
|
||||||
|
":render_prefs": prefs,
|
||||||
|
":now": now,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("UNIQUE") {
|
||||||
|
format!("Invoice number {number} already exists. Start a new series or fix the counter.")
|
||||||
|
} else {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
|
})?;
|
||||||
let invoice_id = tx.last_insert_rowid();
|
let invoice_id = tx.last_insert_rowid();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut stmt = tx
|
let mut stmt = tx
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT INTO invoice_items
|
"INSERT INTO invoice_items
|
||||||
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order)
|
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(db)?;
|
||||||
for (i, item) in input.items.iter().enumerate() {
|
for (i, item) in input.items.iter().enumerate() {
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
invoice_id,
|
invoice_id,
|
||||||
@@ -193,10 +420,11 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
|
|||||||
item.rate,
|
item.rate,
|
||||||
item.unit,
|
item.unit,
|
||||||
item.quantity,
|
item.quantity,
|
||||||
item.amount,
|
paise_to_rupees(gst::line_amount_paise(item)),
|
||||||
i as i64,
|
i as i64,
|
||||||
|
item.hsn_sac.trim(),
|
||||||
])
|
])
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(db)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,11 +433,55 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
|
|||||||
"UPDATE invoice_series SET next_number = next_number + 1 WHERE id = ?1",
|
"UPDATE invoice_series SET next_number = next_number + 1 WHERE id = ?1",
|
||||||
params![series_id],
|
params![series_id],
|
||||||
)
|
)
|
||||||
|
.map_err(db)?;
|
||||||
|
|
||||||
|
if let Some(draft_id) = input.draft_id {
|
||||||
|
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
|
||||||
|
.map_err(db)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().map_err(db)?;
|
||||||
|
fetch_invoice(conn, invoice_id).map_err(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn issue_invoice(
|
||||||
|
state: State<AppState>,
|
||||||
|
input: InvoiceInput,
|
||||||
|
render_prefs: serde_json::Value,
|
||||||
|
) -> Result<Invoice, String> {
|
||||||
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
issue_invoice_impl(&mut conn, &state.data_dir, input, &render_prefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
||||||
|
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let changed = conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE invoices
|
||||||
|
SET status = 'cancelled', cancelled_at = ?1, cancel_reason = ?2, updated_at = ?1
|
||||||
|
WHERE id = ?3 AND status = 'issued'",
|
||||||
|
params![now, reason.trim(), id],
|
||||||
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if changed == 0 {
|
||||||
|
let status: Option<String> = conn
|
||||||
|
.query_row("SELECT status FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
return Err(match status {
|
||||||
|
None => "Invoice not found".to_string(),
|
||||||
|
Some(s) => format!("Only an issued invoice can be cancelled (this one is {s})"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
tx.commit().map_err(|e| e.to_string())?;
|
#[tauri::command]
|
||||||
|
pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result<Invoice, String> {
|
||||||
fetch_invoice(&conn, invoice_id).map_err(|e| e.to_string())
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
cancel_invoice_impl(&mut conn, id, &reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -246,10 +518,459 @@ pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
|||||||
fetch_invoice(&conn, id).map_err(|e| e.to_string())
|
fetch_invoice(&conn, id).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn save_draft_impl(
|
||||||
|
conn: &Connection,
|
||||||
|
id: Option<i64>,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) -> Result<i64, String> {
|
||||||
|
let text = serde_json::to_string(payload).map_err(|e| e.to_string())?;
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
match id {
|
||||||
|
Some(id) => {
|
||||||
|
let changed = conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE invoice_drafts SET payload = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
params![text, now, id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if changed == 0 {
|
||||||
|
return Err("Draft not found".into());
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoice_drafts (payload, updated_at) VALUES (?1, ?2)",
|
||||||
|
params![text, now],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?))
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, payload, updated_at)| {
|
||||||
|
let json: serde_json::Value = serde_json::from_str(&payload).unwrap_or_default();
|
||||||
|
DraftSummary {
|
||||||
|
id,
|
||||||
|
updated_at,
|
||||||
|
client_name: json.get("clientName").and_then(|v| v.as_str()).map(String::from),
|
||||||
|
total: json.get("total").and_then(|v| v.as_f64()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> {
|
||||||
|
let text: String = conn
|
||||||
|
.query_row("SELECT payload FROM invoice_drafts WHERE id = ?1", params![id], |r| r.get(0))
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| "Draft not found".to_string())?;
|
||||||
|
serde_json::from_str(&text).map_err(|e| format!("Draft is corrupted: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
|
pub fn save_draft(
|
||||||
|
state: State<AppState>,
|
||||||
|
id: Option<i64>,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<i64, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
conn.execute("DELETE FROM invoices WHERE id = ?1", params![id])
|
save_draft_impl(&conn, id, &payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_drafts(state: State<AppState>) -> Result<Vec<DraftSummary>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_drafts_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_draft(state: State<AppState>, id: i64) -> Result<serde_json::Value, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
get_draft_impl(&conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_draft(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![id])
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn registered() -> Connection {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET gst_registration = 'regular', vendor_gstin = '27AAPFU0939F1ZV',
|
||||||
|
vendor_state_code = '27', vendor_pan = 'AAPFU0939F', default_tax_type = 'cgst_sgst'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input(extra: serde_json::Value) -> InvoiceInput {
|
||||||
|
let mut base = json!({
|
||||||
|
"invoiceDate": "2026-04-01",
|
||||||
|
"dueDate": "2026-05-01",
|
||||||
|
"clientName": "Client Ltd",
|
||||||
|
"taxType": "cgst_sgst",
|
||||||
|
"taxRate": 18.0,
|
||||||
|
"items": [{"description": "Design", "mode": "fixed", "amount": 7310.0}],
|
||||||
|
});
|
||||||
|
for (k, v) in extra.as_object().unwrap() {
|
||||||
|
base[k] = v.clone();
|
||||||
|
}
|
||||||
|
serde_json::from_value(base).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issue(conn: &mut Connection, input: InvoiceInput) -> Result<Invoice, String> {
|
||||||
|
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_number(conn: &Connection) -> i64 {
|
||||||
|
conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invoice_count(conn: &Connection) -> i64 {
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issues_numbered_tax_invoices_with_derived_totals() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let year = chrono::Local::now().format("%Y");
|
||||||
|
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
||||||
|
assert_eq!(first.number, format!("INV/{year}-001"));
|
||||||
|
assert_eq!(first.status, "issued");
|
||||||
|
assert_eq!(first.doc_type, "tax_invoice");
|
||||||
|
assert_eq!(first.snapshot_origin, "issued");
|
||||||
|
assert_eq!(first.subtotal, 7310.0);
|
||||||
|
assert_eq!(first.cgst_amount, 657.9);
|
||||||
|
assert_eq!(first.sgst_amount, 657.9);
|
||||||
|
assert_eq!(first.igst_amount, 0.0);
|
||||||
|
assert_eq!(first.total, 8625.8);
|
||||||
|
assert_eq!(
|
||||||
|
first.amount_in_words,
|
||||||
|
"Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only"
|
||||||
|
);
|
||||||
|
assert_eq!(first.place_of_supply_state_code, "27");
|
||||||
|
assert_eq!(first.items.len(), 1);
|
||||||
|
assert_eq!(first.items[0].amount, 7310.0);
|
||||||
|
|
||||||
|
let second = issue(&mut conn, input(json!({}))).unwrap();
|
||||||
|
assert_eq!(second.number, format!("INV/{year}-002"));
|
||||||
|
// An empty place of supply is stored as the supplier's own state.
|
||||||
|
assert_eq!(second.place_of_supply_state_code, "27");
|
||||||
|
assert_eq!(next_number(&conn), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inter_state_supply_is_igst_with_hsn_and_reverse_charge() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let inv = issue(
|
||||||
|
&mut conn,
|
||||||
|
input(json!({
|
||||||
|
"placeOfSupplyStateCode": "29",
|
||||||
|
"taxType": "igst",
|
||||||
|
"taxRate": 5.0,
|
||||||
|
"reverseCharge": true,
|
||||||
|
"discount": 10.0,
|
||||||
|
"items": [
|
||||||
|
{"description": "Hours", "mode": "rate", "rate": 1200.0, "quantity": 1.5, "unit": "hour", "hsnSac": " 998314 "},
|
||||||
|
{"description": "Fee", "mode": "fixed", "amount": 33.33},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(inv.subtotal, 1833.33);
|
||||||
|
assert_eq!(inv.discount, 10.0);
|
||||||
|
// (1833.33 - 10.00) = 1823.33 -> 5% = 91.1665 -> 91.17
|
||||||
|
assert_eq!(inv.igst_amount, 91.17);
|
||||||
|
assert_eq!(inv.cgst_amount, 0.0);
|
||||||
|
assert_eq!(inv.total, 1914.5);
|
||||||
|
assert!(inv.reverse_charge);
|
||||||
|
assert_eq!(inv.items[0].hsn_sac, "998314");
|
||||||
|
assert_eq!(inv.items[0].amount, 1800.0);
|
||||||
|
assert_eq!(inv.items[1].hsn_sac, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tax_type_mismatch_is_rejected_and_the_counter_stays() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let err = issue(
|
||||||
|
&mut conn,
|
||||||
|
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), "{err}");
|
||||||
|
let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err();
|
||||||
|
assert!(err.contains("does not match"), "{err}");
|
||||||
|
assert_eq!(next_number(&conn), 1);
|
||||||
|
assert_eq!(invoice_count(&conn), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregistered_supplier_issues_a_plain_invoice_without_tax() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'", []).unwrap();
|
||||||
|
let inv = issue(
|
||||||
|
&mut conn,
|
||||||
|
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(inv.doc_type, "invoice");
|
||||||
|
assert_eq!(inv.tax_type, "none");
|
||||||
|
assert_eq!(inv.tax_rate, 0.0);
|
||||||
|
assert_eq!(inv.total, 7310.0);
|
||||||
|
assert_eq!(inv.cgst_amount + inv.sgst_amount + inv.igst_amount, 0.0);
|
||||||
|
// An unregistered supplier's snapshot must not carry a GSTIN.
|
||||||
|
let snap: serde_json::Value = serde_json::from_str(&inv.vendor_snapshot).unwrap();
|
||||||
|
assert_eq!(snap["vendorGstin"], "");
|
||||||
|
assert_eq!(snap["gstRegistration"], "unregistered");
|
||||||
|
|
||||||
|
let err = issue(&mut conn, input(json!({"taxType": "igst"}))).unwrap_err();
|
||||||
|
assert!(err.contains("does not match \"none\""), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vendor_snapshot_comes_from_settings_not_input() {
|
||||||
|
let mut conn = registered();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET vendor_name = 'Real Name', logo_path = 'assets/logo-1.png',
|
||||||
|
signatory_name = 'A Signer', signatory_designation = 'Partner'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Fields the webview might still send are ignored.
|
||||||
|
let inv = issue(
|
||||||
|
&mut conn,
|
||||||
|
input(json!({
|
||||||
|
"vendorName": "Forged", "vendorSnapshot": "{\"vendorName\":\"Forged\"}",
|
||||||
|
"total": 1.0, "subtotal": 1.0, "cgstAmount": 99.0, "amountInWords": "Free",
|
||||||
|
"bankSnapshot": "{\"bankName\":\"Forged\"}",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snap: serde_json::Value = serde_json::from_str(&inv.vendor_snapshot).unwrap();
|
||||||
|
assert_eq!(snap["vendorName"], "Real Name");
|
||||||
|
assert_eq!(snap["vendorGstin"], "27AAPFU0939F1ZV");
|
||||||
|
assert_eq!(snap["vendorStateCode"], "27");
|
||||||
|
assert_eq!(snap["logoPath"], "assets/logo-1.png");
|
||||||
|
assert!(snap["signaturePath"].is_null());
|
||||||
|
assert_eq!(snap["gstRegistration"], "regular");
|
||||||
|
assert_eq!(snap["signatoryName"], "A Signer");
|
||||||
|
assert_eq!(snap["signatoryDesignation"], "Partner");
|
||||||
|
assert_eq!(inv.total, 8625.8);
|
||||||
|
assert_eq!(inv.bank_snapshot, "");
|
||||||
|
assert!(inv.amount_in_words.contains("Eight Thousand"));
|
||||||
|
|
||||||
|
// Later settings edits do not touch the stored snapshot.
|
||||||
|
conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", []).unwrap();
|
||||||
|
let again = fetch_invoice(&conn, inv.id).unwrap();
|
||||||
|
assert_eq!(again.vendor_snapshot, inv.vendor_snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bank_snapshot_is_built_from_the_bank_row() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap();
|
||||||
|
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
|
||||||
|
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
|
||||||
|
assert_eq!(bank["bankName"], "State Bank of India");
|
||||||
|
let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
|
||||||
|
assert!(err.contains("bank account"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_after_validation_leaves_the_series_untouched() {
|
||||||
|
let mut conn = registered();
|
||||||
|
// Occupy the next number so the INSERT hits the UNIQUE constraint mid-transaction.
|
||||||
|
let year = chrono::Local::now().format("%Y");
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, created_at, updated_at)
|
||||||
|
VALUES (?1, '2026-01-01', 'now', 'now')",
|
||||||
|
params![format!("INV/{year}-001")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", [])
|
||||||
|
.unwrap();
|
||||||
|
let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err();
|
||||||
|
assert!(err.contains("already exists"), "{err}");
|
||||||
|
assert_eq!(next_number(&conn), 1);
|
||||||
|
assert_eq!(invoice_count(&conn), 1);
|
||||||
|
// The client insert and the draft delete were rolled back too.
|
||||||
|
let clients: i64 = conn.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0)).unwrap();
|
||||||
|
let drafts: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!((clients, drafts), (0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_failures_do_not_consume_a_number() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let bad = [
|
||||||
|
(json!({"items": []}), "at least one line"),
|
||||||
|
(json!({"invoiceDate": "01/04/2026"}), "Invoice date"),
|
||||||
|
(json!({"dueDate": "soon"}), "Due date"),
|
||||||
|
(
|
||||||
|
json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}),
|
||||||
|
"greater than zero",
|
||||||
|
),
|
||||||
|
(json!({"items": [{"mode": "fixed", "amount": -1.0}]}), "zero or more"),
|
||||||
|
(json!({"items": [{"mode": "weird", "amount": 1.0}]}), "unknown line type"),
|
||||||
|
(json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"),
|
||||||
|
(json!({"placeOfSupplyStateCode": "99"}), "not a valid GST state code"),
|
||||||
|
(json!({"taxRate": 150.0}), "between 0 and 100"),
|
||||||
|
(json!({"signaturePath": "../voiced.db"}), "Signature image"),
|
||||||
|
];
|
||||||
|
for (extra, needle) in bad {
|
||||||
|
let err = issue(&mut conn, input(extra)).unwrap_err();
|
||||||
|
assert!(err.contains(needle), "expected {needle:?} in {err:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(next_number(&conn), 1);
|
||||||
|
assert_eq!(invoice_count(&conn), 0);
|
||||||
|
|
||||||
|
// A valid client GSTIN, lowercase, is accepted and normalised.
|
||||||
|
let ok = issue(&mut conn, input(json!({"clientGstin": "29aagcb7383j1z4"}))).unwrap();
|
||||||
|
assert_eq!(ok.client_gstin, "29AAGCB7383J1Z4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supplier_settings_are_validated() {
|
||||||
|
let mut conn = registered();
|
||||||
|
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", []).unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("check digit"));
|
||||||
|
conn.execute("UPDATE app_settings SET vendor_gstin = ''", []).unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("needs a GSTIN"));
|
||||||
|
conn.execute("UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'", []).unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code 29"));
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("PAN"));
|
||||||
|
conn.execute("UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", [])
|
||||||
|
.unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code"));
|
||||||
|
assert_eq!(next_number(&conn), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_series_that_break_the_rules_are_refused() {
|
||||||
|
let mut conn = registered();
|
||||||
|
conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", []).unwrap();
|
||||||
|
let err = issue(&mut conn, input(json!({}))).unwrap_err();
|
||||||
|
assert!(err.contains("active invoice series"), "{err}");
|
||||||
|
conn.execute("UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3", []).unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({}))).is_err());
|
||||||
|
// A counter that outgrows its padding can push the number past 16 characters.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLM', padding = 3, next_number = 10000",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16"));
|
||||||
|
assert_eq!(invoice_count(&conn), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issuing_deletes_the_draft() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let draft = save_draft_impl(&conn, None, &json!({"clientName": "X"})).unwrap();
|
||||||
|
let other = save_draft_impl(&conn, None, &json!({"clientName": "Y"})).unwrap();
|
||||||
|
issue(&mut conn, input(json!({"draftId": draft}))).unwrap();
|
||||||
|
assert!(get_draft_impl(&conn, draft).is_err());
|
||||||
|
assert!(get_draft_impl(&conn, other).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signature_path_is_stored_relative() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("assets")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("assets/signature-1.png"), b"x").unwrap();
|
||||||
|
let mut conn = registered();
|
||||||
|
let abs = dir.path().join("assets/signature-1.png");
|
||||||
|
let inv = issue_invoice_impl(
|
||||||
|
&mut conn,
|
||||||
|
dir.path(),
|
||||||
|
input(json!({"signaturePath": abs.to_str().unwrap()})),
|
||||||
|
&json!({"theme": "plain"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(inv.signature_path.as_deref(), Some("assets/signature-1.png"));
|
||||||
|
let prefs: String = conn
|
||||||
|
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(prefs, "{\"theme\":\"plain\"}");
|
||||||
|
assert_eq!(inv.render_prefs, prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelling_keeps_the_number_and_only_works_once() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let inv = issue(&mut conn, input(json!({}))).unwrap();
|
||||||
|
let cancelled = cancel_invoice_impl(&mut conn, inv.id, " duplicate ").unwrap();
|
||||||
|
assert_eq!(cancelled.status, "cancelled");
|
||||||
|
assert_eq!(cancelled.number, inv.number);
|
||||||
|
assert_eq!(cancelled.cancel_reason, "duplicate");
|
||||||
|
assert!(cancelled.cancelled_at.is_some());
|
||||||
|
assert_eq!(cancelled.total, inv.total);
|
||||||
|
|
||||||
|
let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err();
|
||||||
|
assert!(err.contains("cancelled"), "{err}");
|
||||||
|
assert!(cancel_invoice_impl(&mut conn, 999, "").unwrap_err().contains("not found"));
|
||||||
|
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![inv.id]).unwrap();
|
||||||
|
assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err());
|
||||||
|
|
||||||
|
// The number is never reused.
|
||||||
|
let next = issue(&mut conn, input(json!({}))).unwrap();
|
||||||
|
assert_ne!(next.number, inv.number);
|
||||||
|
assert_eq!(next_number(&conn), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drafts_round_trip() {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let a = save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap();
|
||||||
|
let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap();
|
||||||
|
assert_ne!(a, b);
|
||||||
|
let c = save_draft_impl(&conn, Some(a), &json!({"clientName": "Acme 2", "total": 10})).unwrap();
|
||||||
|
assert_eq!(c, a);
|
||||||
|
assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err());
|
||||||
|
|
||||||
|
assert_eq!(get_draft_impl(&conn, a).unwrap()["clientName"], "Acme 2");
|
||||||
|
let list = list_drafts_impl(&conn).unwrap();
|
||||||
|
assert_eq!(list.len(), 2);
|
||||||
|
let acme = list.iter().find(|d| d.id == a).unwrap();
|
||||||
|
assert_eq!(acme.client_name.as_deref(), Some("Acme 2"));
|
||||||
|
assert_eq!(acme.total, Some(10.0));
|
||||||
|
let blank = list.iter().find(|d| d.id == b).unwrap();
|
||||||
|
assert_eq!((blank.client_name.clone(), blank.total), (None, None));
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a]).unwrap();
|
||||||
|
assert!(get_draft_impl(&conn, a).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
pub mod archive;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod clients;
|
pub mod clients;
|
||||||
|
pub mod files;
|
||||||
pub mod invoice;
|
pub mod invoice;
|
||||||
|
pub mod raw;
|
||||||
pub mod series;
|
pub mod series;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! Helpers for commands that take their payload as a raw IPC body (no base64).
|
||||||
|
//!
|
||||||
|
//! The webview sends `invoke(cmd, Uint8Array, { headers })`; Tauri hands the bytes over as
|
||||||
|
//! `InvokeBody::Raw` and the headers as an `http::HeaderMap`. Header values must be visible
|
||||||
|
//! ASCII (`HeaderValue::from_str` rejects anything else), so non-ASCII data such as file
|
||||||
|
//! paths is percent-encoded by the caller.
|
||||||
|
use percent_encoding::percent_decode_str;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri::ipc::{InvokeBody, Request};
|
||||||
|
|
||||||
|
/// Lower-cased header name to value; values that are not valid UTF-8 are dropped.
|
||||||
|
pub type Headers = HashMap<String, String>;
|
||||||
|
|
||||||
|
pub fn raw_body<'a>(request: &'a Request<'_>) -> Result<&'a [u8], String> {
|
||||||
|
match request.body() {
|
||||||
|
InvokeBody::Raw(bytes) => Ok(bytes),
|
||||||
|
InvokeBody::Json(_) => Err("Expected a raw binary request body".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn header_map(request: &Request<'_>) -> Headers {
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(name, value)| {
|
||||||
|
let value = value.to_str().ok()?;
|
||||||
|
Some((name.as_str().to_ascii_lowercase(), value.to_string()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn required_header<'a>(headers: &'a Headers, name: &str) -> Result<&'a str, String> {
|
||||||
|
headers
|
||||||
|
.get(name)
|
||||||
|
.map(String::as_str)
|
||||||
|
.ok_or_else(|| format!("Missing {name} header"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_header_path(value: &str) -> Result<String, String> {
|
||||||
|
percent_decode_str(value)
|
||||||
|
.decode_utf8()
|
||||||
|
.map(|s| s.into_owned())
|
||||||
|
.map_err(|_| "The path header is not valid UTF-8".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `bytes` to `path` via a sibling temp file and a rename, so a crash never leaves a
|
||||||
|
/// half-written file under the final name.
|
||||||
|
pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut tmp_name = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| "The path has no file name".to_string())?
|
||||||
|
.to_os_string();
|
||||||
|
tmp_name.push(".tmp");
|
||||||
|
let tmp = path.with_file_name(tmp_name);
|
||||||
|
let result = (|| {
|
||||||
|
let mut file = std::fs::File::create(&tmp)?;
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
})();
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(format!("Could not write {}: {e}", path.display()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_percent_encoded_paths() {
|
||||||
|
assert_eq!(
|
||||||
|
decode_header_path("%2Ftmp%2Fa%20b%2F%E0%A6%AC.pdf").unwrap(),
|
||||||
|
"/tmp/a b/\u{9ac}.pdf"
|
||||||
|
);
|
||||||
|
assert!(decode_header_path("%FF").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atomic_write_replaces_and_leaves_no_temp_file() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let target = dir.path().join("out.pdf");
|
||||||
|
write_atomic(&target, b"one").unwrap();
|
||||||
|
write_atomic(&target, b"two").unwrap();
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"two");
|
||||||
|
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::db::format_number;
|
use crate::db::format_number;
|
||||||
use crate::models::InvoiceSeries;
|
use crate::models::InvoiceSeries;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use rusqlite::{params, Row};
|
use rusqlite::{params, Connection, Row};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
fn map_series(row: &Row) -> rusqlite::Result<InvoiceSeries> {
|
fn map_series(row: &Row) -> rusqlite::Result<InvoiceSeries> {
|
||||||
@@ -52,32 +52,140 @@ pub fn list_series(state: State<AppState>) -> Result<Vec<InvoiceSeries>, String>
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Longest invoice number GST allows (India Compliance enforces the same limit).
|
||||||
|
pub const MAX_NUMBER_LEN: usize = 16;
|
||||||
|
|
||||||
|
/// A series prefix starts with a letter or digit and then uses only letters, digits,
|
||||||
|
/// '-' and '/'; the prefix plus the number padding must fit in 16 characters.
|
||||||
|
pub fn validate_series_format(prefix: &str, padding: i64) -> Result<(), String> {
|
||||||
|
let mut chars = prefix.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => return Err("Series prefix cannot be empty".into()),
|
||||||
|
Some(c) if !c.is_ascii_alphanumeric() => {
|
||||||
|
return Err("Series prefix must start with a letter or digit".into())
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/') {
|
||||||
|
return Err(
|
||||||
|
"Series prefix may only contain letters, digits, '-' and '/' (no spaces)".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let length = prefix.len() as i64 + padding.max(1);
|
||||||
|
if length > MAX_NUMBER_LEN as i64 {
|
||||||
|
return Err(format!(
|
||||||
|
"Series prefix plus number padding is {length} characters; invoice numbers can be at most {MAX_NUMBER_LEN}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Deactivates the current series and starts a fresh one, resetting the counter to 1.
|
/// Deactivates the current series and starts a fresh one, resetting the counter to 1.
|
||||||
|
/// The deactivate and insert happen in one transaction so a failure never leaves the
|
||||||
|
/// app without an active series.
|
||||||
|
pub fn start_new_series_impl(
|
||||||
|
conn: &mut Connection,
|
||||||
|
prefix: &str,
|
||||||
|
padding: i64,
|
||||||
|
) -> Result<InvoiceSeries, String> {
|
||||||
|
let prefix = prefix.trim();
|
||||||
|
let padding = padding.clamp(1, 8);
|
||||||
|
validate_series_format(prefix, padding)?;
|
||||||
|
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||||
|
tx.execute("UPDATE invoice_series SET is_active = 0", [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
|
||||||
|
VALUES (?1, ?2, 1, 1, ?3)",
|
||||||
|
params![prefix, padding, chrono::Utc::now().to_rfc3339()],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let id = tx.last_insert_rowid();
|
||||||
|
let series = tx
|
||||||
|
.query_row(
|
||||||
|
&format!("{} WHERE id = ?1", SERIES_SELECT),
|
||||||
|
params![id],
|
||||||
|
map_series,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tx.commit().map_err(|e| e.to_string())?;
|
||||||
|
Ok(series)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn start_new_series(
|
pub fn start_new_series(
|
||||||
state: State<AppState>,
|
state: State<AppState>,
|
||||||
prefix: String,
|
prefix: String,
|
||||||
padding: i64,
|
padding: i64,
|
||||||
) -> Result<InvoiceSeries, String> {
|
) -> Result<InvoiceSeries, String> {
|
||||||
let prefix = prefix.trim().to_string();
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
if prefix.is_empty() {
|
start_new_series_impl(&mut conn, &prefix, padding)
|
||||||
return Err("Series prefix cannot be empty".into());
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn conn() -> Connection {
|
||||||
|
crate::db::open_in_memory().unwrap()
|
||||||
}
|
}
|
||||||
let padding = padding.clamp(1, 8);
|
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
#[test]
|
||||||
conn.execute("UPDATE invoice_series SET is_active = 0", [])
|
fn prefix_charset_and_length_rules() {
|
||||||
.map_err(|e| e.to_string())?;
|
assert!(validate_series_format("INV/2026-", 3).is_ok());
|
||||||
conn.execute(
|
assert!(validate_series_format("INV", 1).is_ok());
|
||||||
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
|
assert!(validate_series_format("", 3).is_err());
|
||||||
VALUES (?1, ?2, 1, 1, ?3)",
|
assert!(validate_series_format("-AP", 3).is_err());
|
||||||
params![prefix, padding, chrono::Utc::now().to_rfc3339()],
|
assert!(validate_series_format("/AP", 3).is_err());
|
||||||
|
assert!(validate_series_format("AP 2026", 3).is_err());
|
||||||
|
assert!(validate_series_format("AP_26", 3).is_err());
|
||||||
|
assert!(validate_series_format("AP.26", 3).is_err());
|
||||||
|
assert!(validate_series_format("ÄP", 3).is_err());
|
||||||
|
// 8 + 8 = 16 fits, 9 + 8 = 17 does not.
|
||||||
|
assert!(validate_series_format("ABCDEFGH", 8).is_ok());
|
||||||
|
let err = validate_series_format("ABCDEFGHI", 8).unwrap_err();
|
||||||
|
assert!(err.contains("17") && err.contains("16"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_series_deactivates_the_old_one() {
|
||||||
|
let mut c = conn();
|
||||||
|
let s = start_new_series_impl(&mut c, " FY27/ ", 4).unwrap();
|
||||||
|
assert_eq!(s.prefix, "FY27/");
|
||||||
|
assert_eq!(s.next_invoice_number, "FY27/0001");
|
||||||
|
let active: i64 = c
|
||||||
|
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(active, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_series_leaves_the_active_one_alone() {
|
||||||
|
let mut c = conn();
|
||||||
|
let before: i64 = c
|
||||||
|
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err());
|
||||||
|
assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err());
|
||||||
|
let after: i64 = c
|
||||||
|
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_insert_rolls_back_the_deactivation() {
|
||||||
|
let mut c = conn();
|
||||||
|
// A trigger forces the INSERT to fail after the UPDATE has run.
|
||||||
|
c.execute_batch(
|
||||||
|
"CREATE TRIGGER no_new_series BEFORE INSERT ON invoice_series
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'blocked'); END;",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.unwrap();
|
||||||
let id = conn.last_insert_rowid();
|
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
|
||||||
conn.query_row(
|
let active: i64 = c
|
||||||
&format!("{} WHERE id = ?1", SERIES_SELECT),
|
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||||
params![id],
|
.unwrap();
|
||||||
map_series,
|
assert_eq!(active, 1);
|
||||||
)
|
}
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
use crate::models::{BankAccount, Settings};
|
use crate::models::{BankAccount, Settings};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use rusqlite::{params, Row};
|
use rusqlite::{params, Connection, Row};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
||||||
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
||||||
default_tax_type, payment_terms_days, currency, onboarded, theme";
|
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
||||||
|
signatory_designation, render_prefs, last_export_dir";
|
||||||
|
|
||||||
fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||||
Ok(Settings {
|
Ok(Settings {
|
||||||
vendor_name: row.get(0)?,
|
vendor_name: row.get(0)?,
|
||||||
vendor_address: row.get(1)?,
|
vendor_address: row.get(1)?,
|
||||||
@@ -25,6 +26,12 @@ fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
|||||||
currency: row.get(13)?,
|
currency: row.get(13)?,
|
||||||
onboarded: row.get::<_, i64>(14)? != 0,
|
onboarded: row.get::<_, i64>(14)? != 0,
|
||||||
theme: row.get(15)?,
|
theme: row.get(15)?,
|
||||||
|
gst_registration: row.get(16)?,
|
||||||
|
default_hsn_sac: row.get(17)?,
|
||||||
|
signatory_name: row.get(18)?,
|
||||||
|
signatory_designation: row.get(19)?,
|
||||||
|
render_prefs: row.get(20)?,
|
||||||
|
last_export_dir: row.get(21)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +55,8 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
vendor_pan = ?5, vendor_gstin = ?6, vendor_state_code = ?7, logo_path = ?8,
|
vendor_pan = ?5, vendor_gstin = ?6, vendor_state_code = ?7, logo_path = ?8,
|
||||||
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
||||||
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
||||||
onboarded = ?15, theme = ?16
|
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
||||||
|
signatory_name = ?19, signatory_designation = ?20, render_prefs = ?21
|
||||||
WHERE id = 1"#,
|
WHERE id = 1"#,
|
||||||
params![
|
params![
|
||||||
settings.vendor_name,
|
settings.vendor_name,
|
||||||
@@ -67,6 +75,11 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
settings.currency,
|
settings.currency,
|
||||||
if settings.onboarded { 1 } else { 0 },
|
if settings.onboarded { 1 } else { 0 },
|
||||||
settings.theme,
|
settings.theme,
|
||||||
|
settings.gst_registration,
|
||||||
|
settings.default_hsn_sac,
|
||||||
|
settings.signatory_name,
|
||||||
|
settings.signatory_designation,
|
||||||
|
settings.render_prefs,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -74,7 +87,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
get_settings(state)
|
get_settings(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
|
pub(crate) fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
|
||||||
Ok(BankAccount {
|
Ok(BankAccount {
|
||||||
id: Some(row.get(0)?),
|
id: Some(row.get(0)?),
|
||||||
label: row.get(1)?,
|
label: row.get(1)?,
|
||||||
@@ -159,10 +172,114 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes a bank account. If it was the default, the lowest remaining account
|
||||||
|
/// becomes the default (or the default is cleared when none is left). Issued invoices
|
||||||
|
/// keep their own `bank_snapshot`, so they are unaffected.
|
||||||
|
pub fn delete_bank_impl(conn: &mut Connection, id: i64) -> Result<(), String> {
|
||||||
|
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||||
|
let was_default: bool = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE((SELECT is_default FROM bank_accounts WHERE id = ?1), 0) <> 0
|
||||||
|
OR COALESCE((SELECT default_bank_id FROM app_settings WHERE id = 1), 0) = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tx.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if was_default {
|
||||||
|
let next: Option<i64> = tx
|
||||||
|
.query_row("SELECT MIN(id) FROM bank_accounts", [], |r| r.get(0))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tx.execute("UPDATE bank_accounts SET is_default = 0", [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(next) = next {
|
||||||
|
tx.execute("UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", params![next])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
tx.commit().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> {
|
pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
conn.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
|
delete_bank_impl(&mut conn, id)
|
||||||
.map_err(|e| e.to_string())?;
|
}
|
||||||
Ok(())
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn add_bank(conn: &Connection, name: &str, is_default: bool) -> i64 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bank_accounts (bank_name, is_default) VALUES (?1, ?2)",
|
||||||
|
params![name, is_default],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.last_insert_rowid()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) {
|
||||||
|
let setting = conn
|
||||||
|
.query_row("SELECT default_bank_id FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
|
||||||
|
.unwrap();
|
||||||
|
let flagged = stmt.query_map([], |r| r.get(0)).unwrap().map(Result::unwrap).collect();
|
||||||
|
(setting, flagged)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_carry_render_prefs() {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let read = |conn: &Connection| {
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
||||||
|
[],
|
||||||
|
map_settings,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(read(&conn).render_prefs, "");
|
||||||
|
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"])
|
||||||
|
.unwrap();
|
||||||
|
let settings = read(&conn);
|
||||||
|
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
||||||
|
let json = serde_json::to_value(&settings).unwrap();
|
||||||
|
assert_eq!(json["renderPrefs"], "{\"version\":1}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
// The seeded "Primary" bank is the default; add two more.
|
||||||
|
let seeded: i64 = conn
|
||||||
|
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let b = add_bank(&conn, "B", false);
|
||||||
|
let c = add_bank(&conn, "C", false);
|
||||||
|
assert_eq!(default_state(&conn), (Some(seeded), vec![seeded]));
|
||||||
|
|
||||||
|
delete_bank_impl(&mut conn, seeded).unwrap();
|
||||||
|
assert_eq!(default_state(&conn), (Some(b), vec![b]));
|
||||||
|
delete_bank_impl(&mut conn, b).unwrap();
|
||||||
|
assert_eq!(default_state(&conn), (Some(c), vec![c]));
|
||||||
|
delete_bank_impl(&mut conn, c).unwrap();
|
||||||
|
assert_eq!(default_state(&conn), (None, vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_non_default_bank_keeps_the_default() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let seeded: i64 = conn
|
||||||
|
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let b = add_bank(&conn, "B", false);
|
||||||
|
delete_bank_impl(&mut conn, b).unwrap();
|
||||||
|
assert_eq!(default_state(&conn), (Some(seeded), vec![seeded]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+622
-7
@@ -1,7 +1,29 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use std::path::Path;
|
use rusqlite_migration::{Migrations, M};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// The full schema. Every statement is idempotent so it doubles as the migration step.
|
/// Number of pre-migration backups kept in the backup directory.
|
||||||
|
const MAX_BACKUPS: usize = 10;
|
||||||
|
|
||||||
|
/// Prefix shared by every pre-migration backup file name.
|
||||||
|
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||||
|
|
||||||
|
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||||
|
const LATEST_VERSION: i64 = 4;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DbError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Sqlite(#[from] rusqlite::Error),
|
||||||
|
#[error("database migration failed: {0}")]
|
||||||
|
Migration(#[from] rusqlite_migration::Error),
|
||||||
|
#[error("file system error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version 1: the original schema. Every statement is idempotent, so databases
|
||||||
|
/// created before migrations existed (user_version 0, all tables present) adopt it safely.
|
||||||
const SCHEMA: &str = r#"
|
const SCHEMA: &str = r#"
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
@@ -99,17 +121,141 @@ CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id
|
|||||||
CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
|
/// Version 2: GST registration, invoice snapshots/cancellation fields, HSN/SAC and drafts.
|
||||||
let conn = Connection::open(path)?;
|
const M2: &str = r#"
|
||||||
|
ALTER TABLE app_settings ADD COLUMN gst_registration TEXT NOT NULL DEFAULT 'unregistered';
|
||||||
|
ALTER TABLE app_settings ADD COLUMN default_hsn_sac TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE app_settings ADD COLUMN signatory_name TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE app_settings ADD COLUMN signatory_designation TEXT NOT NULL DEFAULT '';
|
||||||
|
UPDATE app_settings SET gst_registration = 'regular' WHERE vendor_gstin <> '';
|
||||||
|
-- An unregistered supplier may not charge GST, so new invoices default to no tax.
|
||||||
|
UPDATE app_settings SET default_tax_type = 'none' WHERE gst_registration = 'unregistered';
|
||||||
|
|
||||||
|
ALTER TABLE invoices ADD COLUMN doc_type TEXT NOT NULL DEFAULT 'invoice';
|
||||||
|
ALTER TABLE invoices ADD COLUMN reverse_charge INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE invoices ADD COLUMN vendor_snapshot TEXT;
|
||||||
|
ALTER TABLE invoices ADD COLUMN render_prefs TEXT;
|
||||||
|
ALTER TABLE invoices ADD COLUMN snapshot_origin TEXT NOT NULL DEFAULT 'issued';
|
||||||
|
ALTER TABLE invoices ADD COLUMN cancelled_at TEXT;
|
||||||
|
ALTER TABLE invoices ADD COLUMN cancel_reason TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE invoices ADD COLUMN archived_pdf_sha256 TEXT;
|
||||||
|
|
||||||
|
UPDATE invoices SET
|
||||||
|
vendor_snapshot = (
|
||||||
|
SELECT json_object(
|
||||||
|
'vendorName', vendor_name,
|
||||||
|
'vendorAddress', vendor_address,
|
||||||
|
'vendorEmail', vendor_email,
|
||||||
|
'vendorPhone', vendor_phone,
|
||||||
|
'vendorPan', vendor_pan,
|
||||||
|
'vendorGstin', vendor_gstin,
|
||||||
|
'vendorStateCode', vendor_state_code,
|
||||||
|
'logoPath', logo_path,
|
||||||
|
'signaturePath', signature_path,
|
||||||
|
'gstRegistration', gst_registration,
|
||||||
|
'signatoryName', signatory_name,
|
||||||
|
'signatoryDesignation', signatory_designation
|
||||||
|
) FROM app_settings WHERE id = 1
|
||||||
|
),
|
||||||
|
snapshot_origin = 'backfilled',
|
||||||
|
doc_type = CASE WHEN tax_type <> 'none' THEN 'tax_invoice' ELSE 'invoice' END;
|
||||||
|
|
||||||
|
ALTER TABLE invoice_items ADD COLUMN hsn_sac TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_drafts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Version 3: the user's default page setup (RenderPrefsV1 JSON; empty means built-in defaults).
|
||||||
|
const M3: &str = r#"
|
||||||
|
ALTER TABLE app_settings ADD COLUMN render_prefs TEXT NOT NULL DEFAULT '';
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Version 4: the archived PDF's layout fingerprint and time, and the last export folder.
|
||||||
|
const M4: &str = r#"
|
||||||
|
ALTER TABLE invoices ADD COLUMN archived_fingerprint TEXT;
|
||||||
|
ALTER TABLE invoices ADD COLUMN archived_at TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN last_export_dir TEXT NOT NULL DEFAULT '';
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn migrations() -> Migrations<'static> {
|
||||||
|
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||||
|
/// Existing databases are backed up into `backup_dir` before any migration runs.
|
||||||
|
pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
|
||||||
|
let mut conn = Connection::open(path)?;
|
||||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
conn.execute_batch(SCHEMA)?;
|
|
||||||
|
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
|
||||||
|
if version < LATEST_VERSION && has_user_tables(&conn)? {
|
||||||
|
backup_before_migration(&conn, backup_dir, version)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations().to_latest(&mut conn)?;
|
||||||
seed(&conn)?;
|
seed(&conn)?;
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy the database to `<backup_dir>/voiced-pre-v{version}-{YYYYmmdd-HHMMSS}.db`
|
||||||
|
/// and drop all but the newest `MAX_BACKUPS` backups.
|
||||||
|
fn backup_before_migration(
|
||||||
|
conn: &Connection,
|
||||||
|
backup_dir: &Path,
|
||||||
|
version: i64,
|
||||||
|
) -> Result<PathBuf, DbError> {
|
||||||
|
fs::create_dir_all(backup_dir)?;
|
||||||
|
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||||
|
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
||||||
|
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||||
|
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
|
||||||
|
// A failed prune must not block startup; the backup itself already succeeded.
|
||||||
|
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
||||||
|
Ok(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep only the `keep` newest pre-migration backups, ordered by the timestamp
|
||||||
|
/// embedded in the file name (the schema version prefix is ignored).
|
||||||
|
fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> {
|
||||||
|
let mut backups: Vec<(String, PathBuf)> = Vec::new();
|
||||||
|
for entry in fs::read_dir(backup_dir)? {
|
||||||
|
let path = entry?.path();
|
||||||
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(stem) = name
|
||||||
|
.strip_prefix(BACKUP_PREFIX)
|
||||||
|
.and_then(|n| n.strip_suffix(".db"))
|
||||||
|
{
|
||||||
|
// "{version}-{YYYYmmdd}-{HHMMSS}" -> "{YYYYmmdd}-{HHMMSS}"
|
||||||
|
let stamp = stem.split_once('-').map_or(stem, |(_, t)| t);
|
||||||
|
backups.push((stamp.to_string(), path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backups.sort();
|
||||||
|
let excess = backups.len().saturating_sub(keep);
|
||||||
|
for (_, path) in backups.into_iter().take(excess) {
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Insert the first-run defaults the very first time the app is opened.
|
/// Insert the first-run defaults the very first time the app is opened.
|
||||||
/// Vendor details are pre-filled from the owner's existing invoice.
|
/// Vendor details are pre-filled from the owner's existing invoice. The vendor
|
||||||
|
/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax).
|
||||||
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
let settings_count: i64 =
|
let settings_count: i64 =
|
||||||
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
||||||
@@ -119,7 +265,7 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
||||||
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
||||||
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
||||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'cgst_sgst', 30, 'INR', 0, 'g10')"#,
|
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
||||||
params![
|
params![
|
||||||
"Test Vendor",
|
"Test Vendor",
|
||||||
"1 Test Road, Bangalore - 560001",
|
"1 Test Road, Bangalore - 560001",
|
||||||
@@ -169,7 +315,476 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A migrated, seeded in-memory database for unit tests of the command logic.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn open_in_memory() -> Result<Connection, DbError> {
|
||||||
|
let mut conn = Connection::open_in_memory()?;
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
|
migrations().to_latest(&mut conn)?;
|
||||||
|
seed(&conn)?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<data_dir>/assets/x.png` -> `assets/x.png`; anything else is returned unchanged.
|
||||||
|
/// Legacy databases stored absolute paths, which break as soon as the data dir moves.
|
||||||
|
fn relativize(path: &str, data_dir: &Path) -> String {
|
||||||
|
match Path::new(path).strip_prefix(data_dir.join("assets")) {
|
||||||
|
Ok(rest) if !rest.as_os_str().is_empty() => {
|
||||||
|
let mut out = String::from("assets");
|
||||||
|
for part in rest.components() {
|
||||||
|
out.push('/');
|
||||||
|
out.push_str(&part.as_os_str().to_string_lossy());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
_ => path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite absolute asset paths under `<data_dir>/assets/` to relative ones in
|
||||||
|
/// settings, invoices and the invoice vendor snapshots. Safe to run on every start.
|
||||||
|
pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::Result<()> {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
|
for (table, column, pk) in [
|
||||||
|
("app_settings", "logo_path", "id"),
|
||||||
|
("app_settings", "signature_path", "id"),
|
||||||
|
("invoices", "signature_path", "id"),
|
||||||
|
] {
|
||||||
|
let rows: Vec<(i64, String)> = {
|
||||||
|
let mut stmt = tx.prepare(&format!(
|
||||||
|
"SELECT {pk}, {column} FROM {table} WHERE {column} IS NOT NULL"
|
||||||
|
))?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
rows.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
for (id, path) in rows {
|
||||||
|
let rel = relativize(&path, data_dir);
|
||||||
|
if rel != path {
|
||||||
|
tx.execute(
|
||||||
|
&format!("UPDATE {table} SET {column} = ?1 WHERE {pk} = ?2"),
|
||||||
|
params![rel, id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshots: Vec<(i64, String)> = {
|
||||||
|
let mut stmt =
|
||||||
|
tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
rows.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
for (id, text) in snapshots {
|
||||||
|
let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut changed = false;
|
||||||
|
for key in ["logoPath", "signaturePath"] {
|
||||||
|
if let Some(path) = json.get(key).and_then(|v| v.as_str()) {
|
||||||
|
let rel = relativize(path, data_dir);
|
||||||
|
if rel != path {
|
||||||
|
json[key] = serde_json::Value::String(rel);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE invoices SET vendor_snapshot = ?1 WHERE id = ?2",
|
||||||
|
params![json.to_string(), id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
}
|
||||||
|
|
||||||
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
|
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
|
||||||
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
||||||
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
|
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
/// The schema as it shipped before migrations existed (user_version 0).
|
||||||
|
const V1_SCHEMA: &str = r#"
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
vendor_name TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_address TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_email TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_phone TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_pan TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_gstin TEXT NOT NULL DEFAULT '',
|
||||||
|
vendor_state_code TEXT NOT NULL DEFAULT '29',
|
||||||
|
logo_path TEXT,
|
||||||
|
signature_path TEXT,
|
||||||
|
default_bank_id INTEGER,
|
||||||
|
default_tax_rate REAL NOT NULL DEFAULT 18,
|
||||||
|
default_tax_type TEXT NOT NULL DEFAULT 'cgst_sgst',
|
||||||
|
payment_terms_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'INR',
|
||||||
|
onboarded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
theme TEXT NOT NULL DEFAULT 'g10'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bank_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
bank_name TEXT NOT NULL DEFAULT '',
|
||||||
|
account_name TEXT NOT NULL DEFAULT '',
|
||||||
|
account_no TEXT NOT NULL DEFAULT '',
|
||||||
|
branch TEXT NOT NULL DEFAULT '',
|
||||||
|
ifsc TEXT NOT NULL DEFAULT '',
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_series (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
padding INTEGER NOT NULL DEFAULT 3,
|
||||||
|
next_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
gstin TEXT NOT NULL DEFAULT '',
|
||||||
|
state_code TEXT NOT NULL DEFAULT '',
|
||||||
|
po_number TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
number TEXT NOT NULL UNIQUE,
|
||||||
|
series_id INTEGER,
|
||||||
|
invoice_date TEXT NOT NULL,
|
||||||
|
due_date TEXT NOT NULL DEFAULT '',
|
||||||
|
client_id INTEGER,
|
||||||
|
client_name TEXT NOT NULL DEFAULT '',
|
||||||
|
client_address TEXT NOT NULL DEFAULT '',
|
||||||
|
client_gstin TEXT NOT NULL DEFAULT '',
|
||||||
|
po_number TEXT NOT NULL DEFAULT '',
|
||||||
|
place_of_supply_state_code TEXT NOT NULL DEFAULT '',
|
||||||
|
subtotal REAL NOT NULL DEFAULT 0,
|
||||||
|
discount REAL NOT NULL DEFAULT 0,
|
||||||
|
tax_type TEXT NOT NULL DEFAULT 'none',
|
||||||
|
tax_rate REAL NOT NULL DEFAULT 0,
|
||||||
|
cgst_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
sgst_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
igst_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
total REAL NOT NULL DEFAULT 0,
|
||||||
|
amount_in_words TEXT NOT NULL DEFAULT '',
|
||||||
|
bank_account_id INTEGER,
|
||||||
|
bank_snapshot TEXT NOT NULL DEFAULT '',
|
||||||
|
signature_path TEXT,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'issued',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
mode TEXT NOT NULL DEFAULT 'fixed',
|
||||||
|
rate REAL NOT NULL DEFAULT 0,
|
||||||
|
unit TEXT NOT NULL DEFAULT 'unit',
|
||||||
|
quantity REAL NOT NULL DEFAULT 1,
|
||||||
|
amount REAL NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn user_version(conn: &Connection) -> i64 {
|
||||||
|
conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backups_in(dir: &Path) -> Vec<PathBuf> {
|
||||||
|
match fs::read_dir(dir) {
|
||||||
|
Ok(rd) => rd.map(|e| e.unwrap().path()).collect(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrations_are_valid_and_latest_matches() {
|
||||||
|
migrations().validate().unwrap();
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
migrations().to_latest(&mut conn).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_database_is_seeded_without_backup() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let backups = dir.path().join("backups");
|
||||||
|
let conn = open(&dir.path().join("voiced.db"), &backups).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
|
let (tax_type, gst_reg, name): (String, String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT default_tax_type, gst_registration, vendor_name FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tax_type, "none");
|
||||||
|
assert_eq!(gst_reg, "unregistered");
|
||||||
|
assert_eq!(name, "Test Vendor");
|
||||||
|
assert!(backups_in(&backups).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m4_adds_archive_and_export_dir_columns() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let dir: String = conn
|
||||||
|
.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dir, "");
|
||||||
|
// The columns exist and are nullable on invoices (no rows needed to prove it).
|
||||||
|
conn.prepare("SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices")
|
||||||
|
.unwrap();
|
||||||
|
let notnull: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT SUM(\"notnull\") FROM pragma_table_info('invoices')
|
||||||
|
WHERE name IN ('archived_fingerprint', 'archived_at')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(notnull, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let initial: String = conn
|
||||||
|
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(initial, "");
|
||||||
|
|
||||||
|
let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#;
|
||||||
|
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap();
|
||||||
|
let stored: String = conn
|
||||||
|
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_v1_database(path: &Path) {
|
||||||
|
let conn = Connection::open(path).unwrap();
|
||||||
|
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO app_settings (id, vendor_name, vendor_gstin, default_tax_type, onboarded)
|
||||||
|
VALUES (1, 'Acme Studio', '29ABCDE1234F1Z5', 'cgst_sgst', 1)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bank_accounts (label, bank_name, account_no, is_default)
|
||||||
|
VALUES ('Primary', 'Test Bank', '12345', 1)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
|
||||||
|
VALUES ('AC/2026-', 3, 2, 1, '2026-01-01T00:00:00Z')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, tax_type, tax_rate, total, created_at, updated_at)
|
||||||
|
VALUES ('AC/2026-001', '2026-01-02', 'cgst_sgst', 18, 118, 'now', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoice_items (invoice_id, description, rate, quantity, amount)
|
||||||
|
VALUES (1, 'Design work', 100, 1, 100)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(user_version(&conn), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v1_database_upgrades_with_backup_and_backfill() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("voiced.db");
|
||||||
|
let backups = dir.path().join("backups");
|
||||||
|
build_v1_database(&db_path);
|
||||||
|
|
||||||
|
let conn = open(&db_path, &backups).unwrap();
|
||||||
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
||||||
|
|
||||||
|
// Backup exists and is a valid, un-migrated copy.
|
||||||
|
let files = backups_in(&backups);
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
|
let name = files[0].file_name().unwrap().to_str().unwrap();
|
||||||
|
assert!(name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), "{name}");
|
||||||
|
let backup = Connection::open(&files[0]).unwrap();
|
||||||
|
assert_eq!(user_version(&backup), 0);
|
||||||
|
let n: i64 = backup
|
||||||
|
.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
|
||||||
|
// Settings backfill.
|
||||||
|
let (gst_reg, tax_type, vendor): (String, String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT gst_registration, default_tax_type, vendor_name FROM app_settings WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(gst_reg, "regular");
|
||||||
|
assert_eq!(tax_type, "cgst_sgst");
|
||||||
|
assert_eq!(vendor, "Acme Studio");
|
||||||
|
|
||||||
|
// Invoice backfill.
|
||||||
|
let (origin, snapshot, doc_type, number, total): (String, Option<String>, String, String, f64) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(origin, "backfilled");
|
||||||
|
assert_eq!(doc_type, "tax_invoice");
|
||||||
|
assert_eq!(number, "AC/2026-001");
|
||||||
|
assert_eq!(total, 118.0);
|
||||||
|
let json: serde_json::Value = serde_json::from_str(&snapshot.expect("snapshot")).unwrap();
|
||||||
|
assert_eq!(json["vendorName"], "Acme Studio");
|
||||||
|
assert_eq!(json["vendorGstin"], "29ABCDE1234F1Z5");
|
||||||
|
assert_eq!(json["gstRegistration"], "regular");
|
||||||
|
|
||||||
|
// Old data intact, new columns defaulted.
|
||||||
|
let (desc, hsn): (String, String) = conn
|
||||||
|
.query_row("SELECT description, hsn_sac FROM invoice_items", [], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(desc, "Design work");
|
||||||
|
assert_eq!(hsn, "");
|
||||||
|
let (bank, next): (String, i64) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT (SELECT bank_name FROM bank_accounts), (SELECT next_number FROM invoice_series)",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bank, "Test Bank");
|
||||||
|
assert_eq!(next, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reopening_up_to_date_database_makes_no_backup() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("voiced.db");
|
||||||
|
let backups = dir.path().join("backups");
|
||||||
|
build_v1_database(&db_path);
|
||||||
|
|
||||||
|
drop(open(&db_path, &backups).unwrap());
|
||||||
|
assert_eq!(backups_in(&backups).len(), 1);
|
||||||
|
drop(open(&db_path, &backups).unwrap());
|
||||||
|
assert_eq!(backups_in(&backups).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pruning_keeps_the_newest_ten() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
for i in 0..14 {
|
||||||
|
// Mixed version prefixes: ordering must follow the timestamp only.
|
||||||
|
let version = if i % 2 == 0 { 2 } else { 10 };
|
||||||
|
let name = format!("{BACKUP_PREFIX}{version}-202601{:02}-120000.db", i + 1);
|
||||||
|
fs::write(dir.path().join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
fs::write(dir.path().join("unrelated.db"), b"x").unwrap();
|
||||||
|
|
||||||
|
prune_backups(dir.path(), MAX_BACKUPS).unwrap();
|
||||||
|
|
||||||
|
let mut names: Vec<String> = backups_in(dir.path())
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| n.starts_with(BACKUP_PREFIX))
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
assert_eq!(names.len(), MAX_BACKUPS);
|
||||||
|
assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104")));
|
||||||
|
assert!(dir.path().join("unrelated.db").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_paths_become_relative_and_the_rewrite_is_idempotent() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let data = dir.path();
|
||||||
|
let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap();
|
||||||
|
let abs_logo = data.join("assets").join("logo-1.png").to_string_lossy().into_owned();
|
||||||
|
let abs_sig = data.join("assets").join("signature-2.jpg").to_string_lossy().into_owned();
|
||||||
|
let foreign = "/somewhere/else/logo.png";
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE app_settings SET logo_path = ?1, signature_path = ?2",
|
||||||
|
params![abs_logo, foreign],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snapshot = serde_json::json!({
|
||||||
|
"vendorName": "Acme",
|
||||||
|
"logoPath": abs_logo,
|
||||||
|
"signaturePath": abs_sig,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, signature_path, vendor_snapshot, created_at, updated_at)
|
||||||
|
VALUES ('A-1', '2026-01-01', ?1, ?2, 'now', 'now')",
|
||||||
|
params![abs_sig, snapshot],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoices (number, invoice_date, vendor_snapshot, created_at, updated_at)
|
||||||
|
VALUES ('A-2', '2026-01-01', 'not json', 'now', 'now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for _ in 0..2 {
|
||||||
|
relativize_asset_paths(&conn, data).unwrap();
|
||||||
|
let (logo, sig): (String, String) = conn
|
||||||
|
.query_row("SELECT logo_path, signature_path FROM app_settings", [], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(logo, "assets/logo-1.png");
|
||||||
|
assert_eq!(sig, foreign);
|
||||||
|
let (inv_sig, snap): (String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT signature_path, vendor_snapshot FROM invoices WHERE number = 'A-1'",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(inv_sig, "assets/signature-2.jpg");
|
||||||
|
let json: serde_json::Value = serde_json::from_str(&snap).unwrap();
|
||||||
|
assert_eq!(json["logoPath"], "assets/logo-1.png");
|
||||||
|
assert_eq!(json["signaturePath"], "assets/signature-2.jpg");
|
||||||
|
assert_eq!(json["vendorName"], "Acme");
|
||||||
|
let bad: String = conn
|
||||||
|
.query_row("SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bad, "not json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
//! GST rules: state codes, tax-type derivation, GSTIN validation, integer-paise
|
||||||
|
//! totals and amount in words. Everything here is a pure function.
|
||||||
|
|
||||||
|
use crate::models::InvoiceItem;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// GST state codes, copied from India Compliance (`STATE_NUMBERS` in
|
||||||
|
/// `india_compliance/gst_india/constants/__init__.py`), including 96 and 97.
|
||||||
|
pub const STATES: &[(&str, &str)] = &[
|
||||||
|
("01", "Jammu and Kashmir"),
|
||||||
|
("02", "Himachal Pradesh"),
|
||||||
|
("03", "Punjab"),
|
||||||
|
("04", "Chandigarh"),
|
||||||
|
("05", "Uttarakhand"),
|
||||||
|
("06", "Haryana"),
|
||||||
|
("07", "Delhi"),
|
||||||
|
("08", "Rajasthan"),
|
||||||
|
("09", "Uttar Pradesh"),
|
||||||
|
("10", "Bihar"),
|
||||||
|
("11", "Sikkim"),
|
||||||
|
("12", "Sampleachal Pradesh"),
|
||||||
|
("13", "Nagaland"),
|
||||||
|
("14", "Manipur"),
|
||||||
|
("15", "Mizoram"),
|
||||||
|
("16", "Tripura"),
|
||||||
|
("17", "Meghalaya"),
|
||||||
|
("18", "Assam"),
|
||||||
|
("19", "West Bengal"),
|
||||||
|
("20", "Jharkhand"),
|
||||||
|
("21", "Odisha"),
|
||||||
|
("22", "Chhattisgarh"),
|
||||||
|
("23", "Madhya Pradesh"),
|
||||||
|
("24", "Gujarat"),
|
||||||
|
("26", "Dadra and Nagar Haveli and Daman and Diu"),
|
||||||
|
("27", "Maharashtra"),
|
||||||
|
("29", "Karnataka"),
|
||||||
|
("30", "Goa"),
|
||||||
|
("31", "Lakshadweep Islands"),
|
||||||
|
("32", "Kerala"),
|
||||||
|
("33", "Tamil Nadu"),
|
||||||
|
("34", "Puducherry"),
|
||||||
|
("35", "Andaman and Nicobar Islands"),
|
||||||
|
("36", "Telangana"),
|
||||||
|
("37", "Andhra Pradesh"),
|
||||||
|
("38", "Ladakh"),
|
||||||
|
("96", "Other Countries"),
|
||||||
|
("97", "Other Territory"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn state_exists(code: &str) -> bool {
|
||||||
|
STATES.iter().any(|(c, _)| *c == code)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TaxType {
|
||||||
|
None,
|
||||||
|
CgstSgst,
|
||||||
|
Igst,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaxType {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TaxType::None => "none",
|
||||||
|
TaxType::CgstSgst => "cgst_sgst",
|
||||||
|
TaxType::Igst => "igst",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unregistered supplier cannot charge GST. Otherwise the tax head follows the
|
||||||
|
/// place of supply, which defaults to the supplier's own state when left empty.
|
||||||
|
pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str) -> TaxType {
|
||||||
|
if registration == "unregistered" {
|
||||||
|
return TaxType::None;
|
||||||
|
}
|
||||||
|
let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state };
|
||||||
|
if pos == supplier_state {
|
||||||
|
TaxType::CgstSgst
|
||||||
|
} else {
|
||||||
|
TaxType::Igst
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Union territories without a legislature: Chandigarh, Dadra & Nagar Haveli and
|
||||||
|
/// Daman & Diu, Lakshadweep, Andaman & Nicobar Islands, Ladakh.
|
||||||
|
#[cfg_attr(not(test), allow(dead_code))] // used through `second_head_label`, which the PDF layer will call
|
||||||
|
pub fn is_ut_without_legislature(code: &str) -> bool {
|
||||||
|
matches!(code, "04" | "26" | "31" | "35" | "38")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTGST replaces SGST only when the supplier is in one of those territories.
|
||||||
|
#[cfg_attr(not(test), allow(dead_code))] // consumed by the PDF layer in a later step
|
||||||
|
pub fn second_head_label(supplier_state: &str) -> &'static str {
|
||||||
|
if is_ut_without_legislature(supplier_state) {
|
||||||
|
"UTGST"
|
||||||
|
} else {
|
||||||
|
"SGST"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn doc_type(registration: &str) -> &'static str {
|
||||||
|
if registration == "unregistered" {
|
||||||
|
"invoice"
|
||||||
|
} else {
|
||||||
|
"tax_invoice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GSTIN_CHARS: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
|
||||||
|
fn gstin_shape_ok(b: &[u8]) -> bool {
|
||||||
|
b.len() == 15
|
||||||
|
&& b[0..2].iter().all(u8::is_ascii_digit)
|
||||||
|
&& b[2..7].iter().all(u8::is_ascii_uppercase)
|
||||||
|
&& b[7..11].iter().all(u8::is_ascii_digit)
|
||||||
|
&& b[11].is_ascii_uppercase()
|
||||||
|
&& (b[12].is_ascii_uppercase() || (b'1'..=b'9').contains(&b[12]))
|
||||||
|
&& b[13] == b'Z'
|
||||||
|
&& (b[14].is_ascii_digit() || b[14].is_ascii_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard GSTIN mod-36 check character over the first 14 characters.
|
||||||
|
fn gstin_check_char(first14: &[u8]) -> u8 {
|
||||||
|
let sum: usize = first14
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, ch)| {
|
||||||
|
let value = GSTIN_CHARS.iter().position(|c| c == ch).unwrap_or(0);
|
||||||
|
let product = value * if i % 2 == 0 { 1 } else { 2 };
|
||||||
|
product / 36 + product % 36
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
GSTIN_CHARS[(36 - sum % 36) % 36]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_gstin(g: &str) -> Result<(), String> {
|
||||||
|
let g = g.trim().to_ascii_uppercase();
|
||||||
|
if g.len() != 15 || !g.is_ascii() {
|
||||||
|
return Err("GSTIN must be 15 characters".into());
|
||||||
|
}
|
||||||
|
let b = g.as_bytes();
|
||||||
|
if !gstin_shape_ok(b) {
|
||||||
|
return Err("GSTIN format is invalid".into());
|
||||||
|
}
|
||||||
|
if !state_exists(&g[0..2]) {
|
||||||
|
return Err(format!("GSTIN has an unknown state code {}", &g[0..2]));
|
||||||
|
}
|
||||||
|
let expected = gstin_check_char(&b[0..14]);
|
||||||
|
if b[14] != expected {
|
||||||
|
return Err(format!(
|
||||||
|
"GSTIN check digit is wrong (expected {})",
|
||||||
|
expected as char
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Characters 3 to 12 of a GSTIN are the holder's PAN.
|
||||||
|
pub fn gstin_matches_pan(gstin: &str, pan: &str) -> bool {
|
||||||
|
let gstin = gstin.trim().to_ascii_uppercase();
|
||||||
|
let pan = pan.trim().to_ascii_uppercase();
|
||||||
|
gstin.len() == 15 && gstin.is_ascii() && pan.len() == 10 && gstin[2..12] == pan
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Totals {
|
||||||
|
pub subtotal: i64,
|
||||||
|
pub discount: i64,
|
||||||
|
pub taxable: i64,
|
||||||
|
pub cgst: i64,
|
||||||
|
pub sgst: i64,
|
||||||
|
pub igst: i64,
|
||||||
|
pub total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rupees_to_paise(rupees: f64) -> i64 {
|
||||||
|
(rupees * 100.0).round() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round-half-up integer division for non-negative operands.
|
||||||
|
fn div_round(num: i128, den: i128) -> i64 {
|
||||||
|
((num + den / 2) / den) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Amount of a single line in paise. Rate lines multiply the rounded rate by the
|
||||||
|
/// (possibly fractional) quantity and round half up once.
|
||||||
|
pub fn line_amount_paise(item: &InvoiceItem) -> i64 {
|
||||||
|
if item.mode == "rate" {
|
||||||
|
let rate = rupees_to_paise(item.rate);
|
||||||
|
(rate as f64 * item.quantity).round() as i64
|
||||||
|
} else {
|
||||||
|
rupees_to_paise(item.amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_totals(
|
||||||
|
items: &[InvoiceItem],
|
||||||
|
discount_paise: i64,
|
||||||
|
tax_type: TaxType,
|
||||||
|
rate_bp: i64,
|
||||||
|
) -> Totals {
|
||||||
|
let subtotal: i64 = items.iter().map(line_amount_paise).sum();
|
||||||
|
let discount = discount_paise.clamp(0, subtotal.max(0));
|
||||||
|
let taxable = subtotal - discount;
|
||||||
|
let (mut cgst, mut sgst, mut igst) = (0, 0, 0);
|
||||||
|
match tax_type {
|
||||||
|
TaxType::CgstSgst => {
|
||||||
|
cgst = div_round(taxable as i128 * rate_bp as i128, 20_000);
|
||||||
|
sgst = cgst;
|
||||||
|
}
|
||||||
|
TaxType::Igst => igst = div_round(taxable as i128 * rate_bp as i128, 10_000),
|
||||||
|
TaxType::None => {}
|
||||||
|
}
|
||||||
|
Totals {
|
||||||
|
subtotal,
|
||||||
|
discount,
|
||||||
|
taxable,
|
||||||
|
cgst,
|
||||||
|
sgst,
|
||||||
|
igst,
|
||||||
|
total: taxable + cgst + sgst + igst,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONES: [&str; 20] = [
|
||||||
|
"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven",
|
||||||
|
"Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TENS: [&str; 10] = [
|
||||||
|
"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn two_digits(n: u64) -> String {
|
||||||
|
if n < 20 {
|
||||||
|
return ONES[n as usize].to_string();
|
||||||
|
}
|
||||||
|
let ones = n % 10;
|
||||||
|
let mut out = TENS[(n / 10) as usize].to_string();
|
||||||
|
if ones > 0 {
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(ONES[ones as usize]);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn three_digits(n: u64) -> String {
|
||||||
|
let hundreds = n / 100;
|
||||||
|
let rest = n % 100;
|
||||||
|
let mut out = String::new();
|
||||||
|
if hundreds > 0 {
|
||||||
|
out.push_str(ONES[hundreds as usize]);
|
||||||
|
out.push_str(" Hundred");
|
||||||
|
}
|
||||||
|
if rest > 0 {
|
||||||
|
if !out.is_empty() {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out.push_str(&two_digits(rest));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whole number to words in the Indian system (thousand, lakh, crore).
|
||||||
|
fn whole_to_words(n: u64) -> String {
|
||||||
|
if n == 0 {
|
||||||
|
return "Zero".to_string();
|
||||||
|
}
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
let crore = n / 10_000_000;
|
||||||
|
let mut rest = n % 10_000_000;
|
||||||
|
if crore > 0 {
|
||||||
|
parts.push(format!("{} Crore", whole_to_words(crore)));
|
||||||
|
}
|
||||||
|
let lakh = rest / 100_000;
|
||||||
|
rest %= 100_000;
|
||||||
|
if lakh > 0 {
|
||||||
|
parts.push(format!("{} Lakh", two_digits(lakh)));
|
||||||
|
}
|
||||||
|
let thousand = rest / 1000;
|
||||||
|
rest %= 1000;
|
||||||
|
if thousand > 0 {
|
||||||
|
parts.push(format!("{} Thousand", two_digits(thousand)));
|
||||||
|
}
|
||||||
|
if rest > 0 {
|
||||||
|
parts.push(three_digits(rest));
|
||||||
|
}
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same output as `amountInWords` in `src/lib/numberToWords.ts`,
|
||||||
|
/// e.g. 2_500_000 paise -> "Indian Rupees Twenty Five Thousand Only".
|
||||||
|
pub fn amount_in_words(paise: i64) -> String {
|
||||||
|
let value = paise.unsigned_abs();
|
||||||
|
let rupees = value / 100;
|
||||||
|
let paise = value % 100;
|
||||||
|
let mut out = format!("Indian Rupees {}", whole_to_words(rupees));
|
||||||
|
if paise > 0 {
|
||||||
|
out.push_str(&format!(" and {} Paise", two_digits(paise)));
|
||||||
|
}
|
||||||
|
out.push_str(" Only");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rate_item(rate: f64, quantity: f64) -> InvoiceItem {
|
||||||
|
InvoiceItem {
|
||||||
|
id: None,
|
||||||
|
description: String::new(),
|
||||||
|
mode: "rate".into(),
|
||||||
|
rate,
|
||||||
|
unit: "hour".into(),
|
||||||
|
quantity,
|
||||||
|
amount: 0.0,
|
||||||
|
sort_order: 0,
|
||||||
|
hsn_sac: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed_item(amount: f64) -> InvoiceItem {
|
||||||
|
InvoiceItem {
|
||||||
|
mode: "fixed".into(),
|
||||||
|
amount,
|
||||||
|
..rate_item(0.0, 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_list_has_special_codes_and_unique_codes() {
|
||||||
|
assert_eq!(STATES.len(), 38);
|
||||||
|
assert!(STATES.contains(&("96", "Other Countries")));
|
||||||
|
assert!(STATES.contains(&("97", "Other Territory")));
|
||||||
|
assert!(STATES.contains(&("29", "Karnataka")));
|
||||||
|
let mut codes: Vec<_> = STATES.iter().map(|(c, _)| *c).collect();
|
||||||
|
codes.dedup();
|
||||||
|
assert_eq!(codes.len(), STATES.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tax_type_derivation() {
|
||||||
|
assert_eq!(derive_tax_type("unregistered", "29", "27"), TaxType::None);
|
||||||
|
assert_eq!(derive_tax_type("unregistered", "29", ""), TaxType::None);
|
||||||
|
assert_eq!(derive_tax_type("regular", "29", "29"), TaxType::CgstSgst);
|
||||||
|
assert_eq!(derive_tax_type("regular", "29", ""), TaxType::CgstSgst);
|
||||||
|
assert_eq!(derive_tax_type("regular", "29", "27"), TaxType::Igst);
|
||||||
|
assert_eq!(derive_tax_type("regular", "29", "96"), TaxType::Igst);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tax_type_serde_strings() {
|
||||||
|
assert_eq!(serde_json::to_string(&TaxType::CgstSgst).unwrap(), "\"cgst_sgst\"");
|
||||||
|
assert_eq!(serde_json::from_str::<TaxType>("\"igst\"").unwrap(), TaxType::Igst);
|
||||||
|
assert_eq!(serde_json::from_str::<TaxType>("\"none\"").unwrap(), TaxType::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn union_territory_labels() {
|
||||||
|
for code in ["04", "26", "31", "35", "38"] {
|
||||||
|
assert!(is_ut_without_legislature(code));
|
||||||
|
assert_eq!(second_head_label(code), "UTGST");
|
||||||
|
}
|
||||||
|
for code in ["07", "29", "34", "01", "96"] {
|
||||||
|
assert!(!is_ut_without_legislature(code));
|
||||||
|
assert_eq!(second_head_label(code), "SGST");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_type() {
|
||||||
|
assert_eq!(doc_type("unregistered"), "invoice");
|
||||||
|
assert_eq!(doc_type("regular"), "tax_invoice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gstin_checksum() {
|
||||||
|
assert_eq!(validate_gstin("27AAPFU0939F1ZV"), Ok(()));
|
||||||
|
assert_eq!(validate_gstin("07AAGFF2194N1Z1"), Ok(()));
|
||||||
|
assert_eq!(validate_gstin("29AAGCB7383J1Z4"), Ok(()));
|
||||||
|
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
|
||||||
|
// The algorithm gives W for this body, so a trailing 5 is a typo.
|
||||||
|
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
|
||||||
|
assert!(validate_gstin("29ABCDE1234F1Z5").unwrap_err().contains("check digit"));
|
||||||
|
assert!(validate_gstin("27AAPFU0939F1ZX").unwrap_err().contains("check digit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gstin_rejects_bad_shapes() {
|
||||||
|
assert!(validate_gstin("").unwrap_err().contains("15 characters"));
|
||||||
|
assert!(validate_gstin("27AAPFU0939F1Z").unwrap_err().contains("15 characters"));
|
||||||
|
assert!(validate_gstin("27AAPFU0939F1ZVV").unwrap_err().contains("15 characters"));
|
||||||
|
assert!(validate_gstin("2XAAPFU0939F1ZV").unwrap_err().contains("format"));
|
||||||
|
assert!(validate_gstin("27AAPFU0939F1AV").unwrap_err().contains("format"));
|
||||||
|
// Valid shape and checksum but state 99 does not exist.
|
||||||
|
let body = b"99AAPFU0939F1Z";
|
||||||
|
let mut g = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
g.push(gstin_check_char(body) as char);
|
||||||
|
assert!(validate_gstin(&g).unwrap_err().contains("state code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pan_cross_check() {
|
||||||
|
assert!(gstin_matches_pan("27AAPFU0939F1ZV", "AAPFU0939F"));
|
||||||
|
assert!(gstin_matches_pan("27AAPFU0939F1ZV", " aapfu0939f "));
|
||||||
|
assert!(!gstin_matches_pan("27AAPFU0939F1ZV", "AAPFU0939G"));
|
||||||
|
assert!(!gstin_matches_pan("27AAPFU0939F1ZV", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totals_cgst_sgst_18_percent() {
|
||||||
|
let t = compute_totals(&[fixed_item(7310.0)], 0, TaxType::CgstSgst, 1800);
|
||||||
|
assert_eq!(t.subtotal, 731_000);
|
||||||
|
assert_eq!(t.cgst, 65_790);
|
||||||
|
assert_eq!(t.sgst, 65_790);
|
||||||
|
assert_eq!(t.igst, 0);
|
||||||
|
assert_eq!(t.total, 731_000 + 131_580);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totals_igst_5_percent_rounds_half_up() {
|
||||||
|
// 33.33 x 5% = 1.6665 -> 1.67; split in two heads it would be 0.83 + 0.83.
|
||||||
|
let igst = compute_totals(&[fixed_item(33.33)], 0, TaxType::Igst, 500);
|
||||||
|
assert_eq!(igst.igst, 167);
|
||||||
|
assert_eq!(igst.total, 3333 + 167);
|
||||||
|
let split = compute_totals(&[fixed_item(33.33)], 0, TaxType::CgstSgst, 500);
|
||||||
|
assert_eq!((split.cgst, split.sgst), (83, 83));
|
||||||
|
assert_eq!(split.total, 3333 + 166);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totals_fractional_hours() {
|
||||||
|
let t = compute_totals(&[rate_item(1200.0, 1.5)], 0, TaxType::None, 0);
|
||||||
|
assert_eq!(t.subtotal, 180_000);
|
||||||
|
assert_eq!(t.total, 180_000);
|
||||||
|
// 99.99 x 2.5 = 249.975 -> 249.98
|
||||||
|
assert_eq!(line_amount_paise(&rate_item(99.99, 2.5)), 24_998);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totals_discount_is_clamped() {
|
||||||
|
let items = [fixed_item(100.0), fixed_item(50.5)];
|
||||||
|
let over = compute_totals(&items, 99_999, TaxType::Igst, 1800);
|
||||||
|
assert_eq!(over.discount, 15_050);
|
||||||
|
assert_eq!(over.taxable, 0);
|
||||||
|
assert_eq!(over.total, 0);
|
||||||
|
let negative = compute_totals(&items, -500, TaxType::None, 0);
|
||||||
|
assert_eq!(negative.discount, 0);
|
||||||
|
assert_eq!(negative.total, 15_050);
|
||||||
|
let partial = compute_totals(&items, 5050, TaxType::None, 0);
|
||||||
|
assert_eq!(partial.taxable, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totals_with_no_tax_ignore_the_rate() {
|
||||||
|
let t = compute_totals(&[fixed_item(1000.0)], 0, TaxType::None, 1800);
|
||||||
|
assert_eq!((t.cgst, t.sgst, t.igst), (0, 0, 0));
|
||||||
|
assert_eq!(t.total, 100_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn words_match_the_typescript_implementation() {
|
||||||
|
// Expected strings were produced by running src/lib/numberToWords.ts.
|
||||||
|
let cases: &[(i64, &str)] = &[
|
||||||
|
(0, "Indian Rupees Zero Only"),
|
||||||
|
(1, "Indian Rupees Zero and One Paise Only"),
|
||||||
|
(100, "Indian Rupees One Only"),
|
||||||
|
(1950, "Indian Rupees Nineteen and Fifty Paise Only"),
|
||||||
|
(10_000, "Indian Rupees One Hundred Only"),
|
||||||
|
(10_101, "Indian Rupees One Hundred One and One Paise Only"),
|
||||||
|
(
|
||||||
|
99_999,
|
||||||
|
"Indian Rupees Nine Hundred Ninety Nine and Ninety Nine Paise Only",
|
||||||
|
),
|
||||||
|
(100_000, "Indian Rupees One Thousand Only"),
|
||||||
|
(
|
||||||
|
1_234_567,
|
||||||
|
"Indian Rupees Twelve Thousand Three Hundred Forty Five and Sixty Seven Paise Only",
|
||||||
|
),
|
||||||
|
(10_000_000, "Indian Rupees One Lakh Only"),
|
||||||
|
(10_000_005, "Indian Rupees One Lakh and Five Paise Only"),
|
||||||
|
(
|
||||||
|
123_456_789,
|
||||||
|
"Indian Rupees Twelve Lakh Thirty Four Thousand Five Hundred Sixty Seven and Eighty Nine Paise Only",
|
||||||
|
),
|
||||||
|
(1_000_000_000, "Indian Rupees One Crore Only"),
|
||||||
|
(1210, "Indian Rupees Twelve and Ten Paise Only"),
|
||||||
|
(65_790, "Indian Rupees Six Hundred Fifty Seven and Ninety Paise Only"),
|
||||||
|
(
|
||||||
|
8_625_820,
|
||||||
|
"Indian Rupees Eighty Six Thousand Two Hundred Fifty Eight and Twenty Paise Only",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
99_999_999_999,
|
||||||
|
"Indian Rupees Ninety Nine Crore Ninety Nine Lakh Ninety Nine Thousand Nine Hundred Ninety Nine and Ninety Nine Paise Only",
|
||||||
|
),
|
||||||
|
(250_000_000_000, "Indian Rupees Two Hundred Fifty Crore Only"),
|
||||||
|
];
|
||||||
|
for (paise, expected) in cases {
|
||||||
|
assert_eq!(amount_in_words(*paise), *expected, "paise {paise}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
-18
@@ -1,34 +1,107 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod gst;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod selftest;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Mutex<Connection>,
|
pub db: Mutex<Connection>,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
|
/// Machine-local, non-roaming data (the PDF archive). On Windows this is
|
||||||
|
/// %LOCALAPPDATA%, unlike `data_dir`, which is the roaming location.
|
||||||
|
pub local_data_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a native error dialog. Release builds have no console on Windows, so
|
||||||
|
/// without this a startup failure would look like the app silently not opening.
|
||||||
|
fn show_startup_error(app: &tauri::App, error: &dyn std::fmt::Display, data_dir: Option<&Path>) {
|
||||||
|
let location = data_dir
|
||||||
|
.map(|d| format!("\n\nData directory:\n{}", d.display()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
eprintln!("Voiced could not start: {error}");
|
||||||
|
app.dialog()
|
||||||
|
.message(format!("{error}{location}"))
|
||||||
|
.title("Voiced could not start")
|
||||||
|
.kind(MessageDialogKind::Error)
|
||||||
|
.blocking_show();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>> {
|
||||||
|
let data_dir = match app.path().app_data_dir() {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(e) => {
|
||||||
|
show_startup_error(app, &format!("Could not resolve the application data directory: {e}"), None);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||||
|
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir));
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
let local_data_dir = match app.path().app_local_data_dir() {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(e) => {
|
||||||
|
show_startup_error(app, &format!("Could not resolve the local data directory: {e}"), Some(&data_dir));
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&local_data_dir) {
|
||||||
|
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(e) => {
|
||||||
|
show_startup_error(app, &format!("Could not open the database: {e}"), Some(&data_dir));
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) {
|
||||||
|
show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir));
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
Ok(AppState {
|
||||||
|
db: Mutex::new(conn),
|
||||||
|
data_dir,
|
||||||
|
local_data_dir,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
||||||
|
// combinations. Respect an explicit user setting, otherwise turn it off.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
let builder = tauri::Builder::default();
|
||||||
|
|
||||||
|
// The single-instance plugin must be registered before every other plugin.
|
||||||
|
#[cfg(desktop)]
|
||||||
|
let builder = builder
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||||
|
|
||||||
|
let result = builder
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let data_dir = app
|
let state = init_state(app)?;
|
||||||
.path()
|
app.manage(state);
|
||||||
.app_data_dir()
|
|
||||||
.expect("could not resolve the application data directory");
|
|
||||||
std::fs::create_dir_all(&data_dir).expect("could not create the application data directory");
|
|
||||||
|
|
||||||
let conn = db::open(&data_dir.join("voiced.db")).expect("could not open the database");
|
|
||||||
app.manage(AppState {
|
|
||||||
db: Mutex::new(conn),
|
|
||||||
data_dir,
|
|
||||||
});
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -44,16 +117,32 @@ pub fn run() {
|
|||||||
commands::clients::save_client,
|
commands::clients::save_client,
|
||||||
commands::clients::delete_client,
|
commands::clients::delete_client,
|
||||||
commands::invoice::peek_next_invoice_number,
|
commands::invoice::peek_next_invoice_number,
|
||||||
commands::invoice::create_invoice,
|
commands::invoice::issue_invoice,
|
||||||
|
commands::invoice::cancel_invoice,
|
||||||
commands::invoice::list_invoices,
|
commands::invoice::list_invoices,
|
||||||
commands::invoice::get_invoice,
|
commands::invoice::get_invoice,
|
||||||
commands::invoice::delete_invoice,
|
commands::invoice::save_draft,
|
||||||
|
commands::invoice::list_drafts,
|
||||||
|
commands::invoice::get_draft,
|
||||||
|
commands::invoice::delete_draft,
|
||||||
commands::assets::import_asset,
|
commands::assets::import_asset,
|
||||||
commands::assets::save_asset_bytes,
|
commands::assets::save_asset_bytes,
|
||||||
commands::assets::read_asset_data_uri,
|
commands::assets::read_asset_data_uri,
|
||||||
commands::assets::remove_asset,
|
commands::assets::remove_asset,
|
||||||
commands::assets::save_binary_file,
|
commands::archive::archive_pdf,
|
||||||
|
commands::archive::read_archive,
|
||||||
|
commands::archive::archive_status,
|
||||||
|
commands::files::write_export_file,
|
||||||
|
commands::files::get_last_export_dir,
|
||||||
|
commands::files::reveal_in_folder,
|
||||||
|
commands::files::open_file,
|
||||||
|
selftest::selftest_config,
|
||||||
|
selftest::selftest_report,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!());
|
||||||
.expect("error while running the Voiced application");
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("error while running the Voiced application: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-13
@@ -19,6 +19,16 @@ pub struct Settings {
|
|||||||
pub currency: String,
|
pub currency: String,
|
||||||
pub onboarded: bool,
|
pub onboarded: bool,
|
||||||
pub theme: String,
|
pub theme: String,
|
||||||
|
pub gst_registration: String,
|
||||||
|
pub default_hsn_sac: String,
|
||||||
|
pub signatory_name: String,
|
||||||
|
pub signatory_designation: String,
|
||||||
|
/// The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults.
|
||||||
|
#[serde(default)]
|
||||||
|
pub render_prefs: String,
|
||||||
|
/// Folder of the last export; owned by the backend, so saving settings never changes it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_export_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -92,6 +102,8 @@ pub struct InvoiceItem {
|
|||||||
pub amount: f64,
|
pub amount: f64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sort_order: i64,
|
pub sort_order: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hsn_sac: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_mode() -> String {
|
fn default_mode() -> String {
|
||||||
@@ -102,6 +114,8 @@ fn default_unit() -> String {
|
|||||||
"unit".to_string()
|
"unit".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the webview may send when issuing an invoice. Money totals, tax amounts,
|
||||||
|
/// the amount in words and the vendor/bank snapshots are derived on the Rust side.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct InvoiceInput {
|
pub struct InvoiceInput {
|
||||||
@@ -121,34 +135,25 @@ pub struct InvoiceInput {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub place_of_supply_state_code: String,
|
pub place_of_supply_state_code: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub subtotal: f64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub discount: f64,
|
pub discount: f64,
|
||||||
|
/// The tax treatment the user chose; it must match the derived one.
|
||||||
#[serde(default = "default_tax_type")]
|
#[serde(default = "default_tax_type")]
|
||||||
pub tax_type: String,
|
pub tax_type: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tax_rate: f64,
|
pub tax_rate: f64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cgst_amount: f64,
|
pub reverse_charge: bool,
|
||||||
#[serde(default)]
|
|
||||||
pub sgst_amount: f64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub igst_amount: f64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub total: f64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub amount_in_words: String,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub bank_account_id: Option<i64>,
|
pub bank_account_id: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub bank_snapshot: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub signature_path: Option<String>,
|
pub signature_path: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notes: String,
|
pub notes: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub save_client: bool,
|
pub save_client: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub draft_id: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
pub items: Vec<InvoiceItem>,
|
pub items: Vec<InvoiceItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,9 +188,22 @@ pub struct Invoice {
|
|||||||
pub bank_snapshot: String,
|
pub bank_snapshot: String,
|
||||||
pub signature_path: Option<String>,
|
pub signature_path: Option<String>,
|
||||||
pub notes: String,
|
pub notes: String,
|
||||||
|
/// draft | issued | cancelled
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
|
pub doc_type: String,
|
||||||
|
pub reverse_charge: bool,
|
||||||
|
/// JSON of the supplier details as they were when the invoice was issued; empty if unknown.
|
||||||
|
pub vendor_snapshot: String,
|
||||||
|
pub snapshot_origin: String,
|
||||||
|
pub cancelled_at: Option<String>,
|
||||||
|
pub cancel_reason: String,
|
||||||
|
pub archived_pdf_sha256: Option<String>,
|
||||||
|
/// RenderPrefsV1 JSON frozen at issue; empty when none was stored.
|
||||||
|
pub render_prefs: String,
|
||||||
|
pub archived_fingerprint: Option<String>,
|
||||||
|
pub archived_at: Option<String>,
|
||||||
pub items: Vec<InvoiceItem>,
|
pub items: Vec<InvoiceItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,3 +219,12 @@ pub struct InvoiceSummary {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DraftSummary {
|
||||||
|
pub id: i64,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub client_name: Option<String>,
|
||||||
|
pub total: Option<f64>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//! Unattended end-to-end self-test hook. Inert unless `VOICED_SELFTEST_OUT` is set: the webview asks
|
||||||
|
//! for the report path at startup, runs its checks and hands the JSON back, and the process exits with
|
||||||
|
//! the verdict. This is the release gate that proves the real webview works, which Node tests cannot.
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const ENV_VAR: &str = "VOICED_SELFTEST_OUT";
|
||||||
|
|
||||||
|
fn configured_path() -> Option<PathBuf> {
|
||||||
|
std::env::var_os(ENV_VAR).filter(|v| !v.is_empty()).map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_report(path: &Path, json: &str) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let mut file = std::fs::File::create(path).map_err(|e| format!("Could not write the report: {e}"))?;
|
||||||
|
file.write_all(json.as_bytes())
|
||||||
|
.and_then(|()| file.sync_all())
|
||||||
|
.map_err(|e| format!("Could not write the report: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn selftest_config() -> Option<String> {
|
||||||
|
configured_path().map(|p| p.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn selftest_report(json: String, ok: bool) -> Result<(), String> {
|
||||||
|
let path = configured_path().ok_or_else(|| "The self-test is not enabled".to_string())?;
|
||||||
|
write_report(&path, &json)?;
|
||||||
|
std::process::exit(if ok { 0 } else { 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_the_report_creating_folders() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("a/b/report.json");
|
||||||
|
write_report(&path, "{\"ok\":true}").unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
"title": "Voiced",
|
"title": "Voiced",
|
||||||
"width": 1360,
|
"width": 1360,
|
||||||
"height": 900,
|
"height": 900,
|
||||||
"minWidth": 1024,
|
"minWidth": 960,
|
||||||
"minHeight": 720,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"center": true
|
"center": true
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
],
|
],
|
||||||
"windows": {
|
"windows": {
|
||||||
"webviewInstallMode": {
|
"webviewInstallMode": {
|
||||||
"type": "downloadBootstrapper"
|
"type": "embedBootstrapper"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"installMode": "currentUser"
|
"installMode": "currentUser"
|
||||||
|
|||||||
+13
-3
@@ -88,9 +88,19 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Theme theme={theme}>
|
<Theme theme={theme}>
|
||||||
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
|
||||||
{view === "new" && <NewInvoice settings={settings} onSettingsChange={refreshSettings} />}
|
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
|
||||||
{view === "invoices" && <InvoiceHistory settings={settings} />}
|
<NewInvoice
|
||||||
{view === "series" && <SeriesSettings />}
|
settings={settings}
|
||||||
|
onSettingsChange={refreshSettings}
|
||||||
|
active={view === "new"}
|
||||||
|
onActivate={() => setView("new")}
|
||||||
|
/>
|
||||||
|
<div hidden={view !== "invoices"}>
|
||||||
|
<InvoiceHistory settings={settings} active={view === "invoices"} />
|
||||||
|
</div>
|
||||||
|
<div hidden={view !== "series"}>
|
||||||
|
<SeriesSettings active={view === "series"} />
|
||||||
|
</div>
|
||||||
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</Theme>
|
</Theme>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, ComposedModal, InlineLoading, InlineNotification, ModalBody, ModalFooter, ModalHeader, Tag } from "@carbon/react";
|
||||||
|
import { reportToText, runSelfTest, type Check, type SelfTestReport } from "../lib/selfTest";
|
||||||
|
|
||||||
|
const STATUS_TAG: Record<Check["status"], { type: "green" | "red" | "gray"; text: string }> = {
|
||||||
|
pass: { type: "green", text: "Pass" },
|
||||||
|
fail: { type: "red", text: "Fail" },
|
||||||
|
info: { type: "gray", text: "Info" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renders the fixtures in this webview's real render client and reports what it found. */
|
||||||
|
export default function DiagnosticsPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const [report, setReport] = useState<SelfTestReport | null>(null);
|
||||||
|
const [fatal, setFatal] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState<"ok" | "failed" | null>(null);
|
||||||
|
const runId = useRef(0);
|
||||||
|
|
||||||
|
const run = useCallback(async () => {
|
||||||
|
const id = ++runId.current;
|
||||||
|
setReport(null);
|
||||||
|
setFatal(null);
|
||||||
|
setCopied(null);
|
||||||
|
try {
|
||||||
|
// Loaded on demand: the fixtures, goldens and pdf.js are only needed here.
|
||||||
|
const { browserSelfTestDeps } = await import("../lib/selfTestBrowser");
|
||||||
|
await runSelfTest(browserSelfTestDeps(), (r) => {
|
||||||
|
if (runId.current === id) setReport(r);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (runId.current === id) setFatal(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) void run();
|
||||||
|
else runId.current++;
|
||||||
|
}, [open, run]);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
if (!report) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(reportToText(report));
|
||||||
|
setCopied("ok");
|
||||||
|
} catch {
|
||||||
|
setCopied("failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const running = open && !fatal && !report?.done;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposedModal open={open} onClose={onClose} size="md" preventCloseOnClickOutside aria-label="Diagnostics">
|
||||||
|
<ModalHeader title="Diagnostics" label="Settings" />
|
||||||
|
<ModalBody hasScrollingContent>
|
||||||
|
<p className="voiced-muted" style={{ marginBottom: "1rem" }}>
|
||||||
|
Renders two sample invoices in this app's own renderer, reads them back and compares them with the reference layouts. Nothing is saved or sent.
|
||||||
|
</p>
|
||||||
|
{fatal ? <InlineNotification kind="error" lowContrast hideCloseButton title="The self-test could not run" subtitle={fatal} /> : null}
|
||||||
|
{report && report.done ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind={report.passed ? "success" : "error"}
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title={report.passed ? "All checks passed" : "Some checks failed"}
|
||||||
|
subtitle={report.passed ? undefined : "Copy the report and include it when you report the problem."}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{report?.sections.map((section) => (
|
||||||
|
<section key={section.title} style={{ marginTop: "1.5rem" }} aria-label={section.title}>
|
||||||
|
<h4 style={{ marginBottom: "0.5rem" }}>{section.title}</h4>
|
||||||
|
{section.checks.map((check, i) => (
|
||||||
|
<div key={`${check.label}-${i}`} className="voiced-diagnostics__check">
|
||||||
|
<div className="voiced-diagnostics__head">
|
||||||
|
<Tag type={STATUS_TAG[check.status].type} size="sm">
|
||||||
|
{STATUS_TAG[check.status].text}
|
||||||
|
</Tag>
|
||||||
|
<span className="voiced-diagnostics__label">{check.label}</span>
|
||||||
|
{check.ms !== undefined ? <span className="voiced-diagnostics__ms">{check.ms} ms</span> : null}
|
||||||
|
</div>
|
||||||
|
{check.detail ? <p className="voiced-diagnostics__detail">{check.detail}</p> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{running ? (
|
||||||
|
<div style={{ marginTop: "1.5rem" }}>
|
||||||
|
<InlineLoading description="Running checks…" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{copied ? (
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<InlineNotification
|
||||||
|
kind={copied === "ok" ? "success" : "warning"}
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title={copied === "ok" ? "Report copied" : "Could not copy"}
|
||||||
|
subtitle={copied === "ok" ? undefined : "The clipboard is not available here."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button kind="secondary" onClick={() => void run()} disabled={running}>
|
||||||
|
Run again
|
||||||
|
</Button>
|
||||||
|
<Button kind="secondary" onClick={() => void copy()} disabled={!report}>
|
||||||
|
Copy report
|
||||||
|
</Button>
|
||||||
|
<Button kind="primary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ComposedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { MenuButton, MenuItem, OverflowMenu, OverflowMenuItem } from "@carbon/react";
|
||||||
|
import { ChevronDown } from "@carbon/icons-react";
|
||||||
|
import type { ExportMode } from "../lib/filename";
|
||||||
|
import { EXPORT_MODE_LABEL, saveExportMode } from "../lib/exportMode";
|
||||||
|
|
||||||
|
const MODES: ExportMode[] = ["searchable", "flattened"];
|
||||||
|
|
||||||
|
/** "Export PDF" menu for an issued invoice. Choosing a mode also makes it the remembered one. */
|
||||||
|
export default function ExportButton({ onExport, disabled }: { onExport: (mode: ExportMode) => void; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<MenuButton label="Export PDF" kind="tertiary" disabled={disabled} menuAlignment="bottom-start">
|
||||||
|
{MODES.map((mode) => (
|
||||||
|
<MenuItem
|
||||||
|
key={mode}
|
||||||
|
label={EXPORT_MODE_LABEL[mode]}
|
||||||
|
onClick={() => {
|
||||||
|
saveExportMode(mode);
|
||||||
|
onExport(mode);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MenuButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The format the primary "Issue & export" action uses; a small menu next to that button. */
|
||||||
|
export function ExportModePicker({ mode, onChange, disabled }: { mode: ExportMode; onChange: (mode: ExportMode) => void; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<OverflowMenu
|
||||||
|
aria-label="Choose the PDF format for Issue & export"
|
||||||
|
iconDescription={`PDF format: ${EXPORT_MODE_LABEL[mode]}`}
|
||||||
|
renderIcon={ChevronDown}
|
||||||
|
flipped
|
||||||
|
size="lg"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<OverflowMenuItem
|
||||||
|
key={m}
|
||||||
|
itemText={m === mode ? `${EXPORT_MODE_LABEL[m]} (selected)` : EXPORT_MODE_LABEL[m]}
|
||||||
|
onClick={() => {
|
||||||
|
saveExportMode(m);
|
||||||
|
onChange(m);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</OverflowMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button, InlineLoading, InlineNotification } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { InvoiceExport } from "../hooks/useInvoiceExport";
|
||||||
|
import FlattenProgressModal from "./FlattenProgressModal";
|
||||||
|
|
||||||
|
/** Progress modal, spinner, success and error notices for one `useInvoiceExport`. */
|
||||||
|
export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
|
||||||
|
const { busy, progress, request, error, result } = exp;
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
useEffect(() => setActionError(null), [result]);
|
||||||
|
|
||||||
|
const flattenFailed = request?.mode === "flattened" && error?.flatten === true;
|
||||||
|
const modalOpen = request?.mode === "flattened" && ((busy && progress?.phase !== "saving") || flattenFailed);
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<void>) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (e) {
|
||||||
|
setActionError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FlattenProgressModal
|
||||||
|
open={modalOpen}
|
||||||
|
progress={progress}
|
||||||
|
error={flattenFailed ? error!.message : null}
|
||||||
|
onCancel={exp.cancel}
|
||||||
|
onRetry={exp.retry}
|
||||||
|
onExportSearchable={exp.exportSearchableInstead}
|
||||||
|
onClose={exp.dismiss}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
|
||||||
|
|
||||||
|
{error && !flattenFailed ? (
|
||||||
|
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}>
|
||||||
|
<div className="voiced-notice-actions">
|
||||||
|
<Button kind="ghost" size="sm" onClick={exp.retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</InlineNotification>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<>
|
||||||
|
<InlineNotification
|
||||||
|
kind="success"
|
||||||
|
lowContrast
|
||||||
|
title={`Saved to ${result.path}`}
|
||||||
|
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
|
||||||
|
onCloseButtonClick={exp.dismiss}
|
||||||
|
>
|
||||||
|
<div className="voiced-notice-actions">
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
|
||||||
|
Open file
|
||||||
|
</Button>
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
|
||||||
|
Show in folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</InlineNotification>
|
||||||
|
{result.identicalToIssued === false ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="warning"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="Layout differs from the issued invoice"
|
||||||
|
subtitle="The current template lays this invoice out differently from the PDF issued. The archived original is unchanged; use Export PDF to get it."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{result.archiveError ? (
|
||||||
|
<InlineNotification kind="warning" lowContrast hideCloseButton title="This invoice is still not archived" subtitle={result.archiveError} />
|
||||||
|
) : null}
|
||||||
|
{actionError ? <InlineNotification kind="error" lowContrast title="Could not open the file" subtitle={actionError} onCloseButtonClick={() => setActionError(null)} /> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Button, ComposedModal, InlineNotification, ModalBody, ModalFooter, ModalHeader, ProgressBar } from "@carbon/react";
|
||||||
|
import type { ExportProgress } from "../lib/exportFlow";
|
||||||
|
|
||||||
|
export interface FlattenProgressModalProps {
|
||||||
|
open: boolean;
|
||||||
|
progress: ExportProgress | null;
|
||||||
|
/** The flatten failed: show it with Retry and "Export searchable instead". */
|
||||||
|
error: string | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onExportSearchable: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(p: ExportProgress | null): { label: string; value?: number; max: number } {
|
||||||
|
if (p?.phase === "flattening" && p.page && p.pages) {
|
||||||
|
// The page being rendered counts as in progress, so the bar shows the pages finished before it.
|
||||||
|
return { label: `Rendering page ${p.page} of ${p.pages}…`, value: p.page - 1, max: p.pages };
|
||||||
|
}
|
||||||
|
if (p?.phase === "assembling") return { label: "Building the PDF…", value: 1, max: 1 };
|
||||||
|
return { label: "Preparing the invoice…", max: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FlattenProgressModal({ open, progress, error, onCancel, onRetry, onExportSearchable, onClose }: FlattenProgressModalProps) {
|
||||||
|
const { label, value, max } = describe(progress);
|
||||||
|
return (
|
||||||
|
<ComposedModal open={open} size="sm" preventCloseOnClickOutside onClose={() => (error ? onClose() : onCancel())} aria-label="Flattened PDF export">
|
||||||
|
<ModalHeader title="Flattened PDF" label="Export" />
|
||||||
|
<ModalBody hasForm={false}>
|
||||||
|
{error ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="error"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="The flattened PDF could not be made"
|
||||||
|
subtitle={`${error} Nothing was saved, and the resolution was not lowered.`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProgressBar label={label} value={value} max={max} helperText="Each page is drawn at 300 DPI; this takes a few seconds per page." />
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
{error ? (
|
||||||
|
// Three direct children: Carbon lays a three-button footer out differently from a two-button one.
|
||||||
|
<ModalFooter>
|
||||||
|
<Button kind="secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button kind="tertiary" onClick={onExportSearchable}>
|
||||||
|
Export searchable instead
|
||||||
|
</Button>
|
||||||
|
<Button kind="primary" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
) : (
|
||||||
|
<ModalFooter>
|
||||||
|
<Button kind="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
)}
|
||||||
|
</ComposedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import { UNIT_LABELS } from "../lib/types";
|
|
||||||
import { computeLineAmount } from "../lib/invoice";
|
|
||||||
import { formatAmount, formatDate } from "../lib/format";
|
|
||||||
import type { InvoicePdfProps } from "../pdf/InvoiceDocument";
|
|
||||||
|
|
||||||
const money = (n: number) => `₹${formatAmount(n)}`;
|
|
||||||
|
|
||||||
/** On-screen preview that mirrors the exported PDF layout. */
|
|
||||||
export function InvoicePreview(props: InvoicePdfProps) {
|
|
||||||
const {
|
|
||||||
vendor,
|
|
||||||
bank,
|
|
||||||
logoDataUri,
|
|
||||||
signatureDataUri,
|
|
||||||
number,
|
|
||||||
invoiceDate,
|
|
||||||
dueDate,
|
|
||||||
clientName,
|
|
||||||
clientAddress,
|
|
||||||
clientGstin,
|
|
||||||
poNumber,
|
|
||||||
placeOfSupplyStateCode,
|
|
||||||
items,
|
|
||||||
subtotal,
|
|
||||||
discount,
|
|
||||||
taxType,
|
|
||||||
taxRate,
|
|
||||||
cgst,
|
|
||||||
sgst,
|
|
||||||
igst,
|
|
||||||
total,
|
|
||||||
amountInWords,
|
|
||||||
notes,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const isTaxInvoice = taxType !== "none";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inv-preview-wrap">
|
|
||||||
<div className="inv-paper">
|
|
||||||
<div className="inv-top">
|
|
||||||
<div className="inv-vendor">
|
|
||||||
{logoDataUri ? <img className="inv-vendor__logo" src={logoDataUri} alt="" /> : null}
|
|
||||||
<div>
|
|
||||||
<h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2>
|
|
||||||
<p className="inv-vendor__line">{vendor.vendorAddress}</p>
|
|
||||||
{vendor.vendorEmail ? <p className="inv-vendor__line">{vendor.vendorEmail}</p> : null}
|
|
||||||
{vendor.vendorPhone ? <p className="inv-vendor__line">{vendor.vendorPhone}</p> : null}
|
|
||||||
{vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null}
|
|
||||||
{vendor.vendorGstin ? <p className="inv-vendor__line">GSTIN: {vendor.vendorGstin}</p> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inv-meta">
|
|
||||||
<h3 className="inv-meta__title">{isTaxInvoice ? "Tax Invoice" : "Invoice"}</h3>
|
|
||||||
<div className="inv-meta__row">
|
|
||||||
<span className="inv-meta__label">Invoice No.</span>
|
|
||||||
<span className="inv-meta__value">{number}</span>
|
|
||||||
</div>
|
|
||||||
<div className="inv-meta__row">
|
|
||||||
<span className="inv-meta__label">Date</span>
|
|
||||||
<span className="inv-meta__value">{formatDate(invoiceDate)}</span>
|
|
||||||
</div>
|
|
||||||
{dueDate ? (
|
|
||||||
<div className="inv-meta__row">
|
|
||||||
<span className="inv-meta__label">Due Date</span>
|
|
||||||
<span className="inv-meta__value">{formatDate(dueDate)}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inv-split">
|
|
||||||
<div className="inv-block" style={{ maxWidth: "60%" }}>
|
|
||||||
<p className="inv-block__label">Billed To</p>
|
|
||||||
<div className="inv-block__strong">{clientName || "—"}</div>
|
|
||||||
{clientAddress ? <div style={{ whiteSpace: "pre-line" }}>{clientAddress}</div> : null}
|
|
||||||
{clientGstin ? <div>GSTIN: {clientGstin}</div> : null}
|
|
||||||
{placeOfSupplyStateCode ? <div>Place of Supply: {placeOfSupplyStateCode}</div> : null}
|
|
||||||
</div>
|
|
||||||
{poNumber ? (
|
|
||||||
<div className="inv-block">
|
|
||||||
<p className="inv-block__label">PO Number</p>
|
|
||||||
<div className="inv-block__strong">{poNumber}</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className="inv-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Description</th>
|
|
||||||
<th className="num">Unit Price</th>
|
|
||||||
<th className="num">Qty</th>
|
|
||||||
<th className="num">Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<tr key={item.id ?? index}>
|
|
||||||
<td>{item.description || "—"}</td>
|
|
||||||
<td className="num">
|
|
||||||
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="num">{item.mode === "rate" ? item.quantity : 1}</td>
|
|
||||||
<td className="num">{money(computeLineAmount(item))}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="inv-totals">
|
|
||||||
<div className="inv-totals__table">
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>Subtotal</span>
|
|
||||||
<span>{money(subtotal)}</span>
|
|
||||||
</div>
|
|
||||||
{discount > 0 ? (
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>Discount</span>
|
|
||||||
<span>- {money(discount)}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{taxType === "cgst_sgst" ? (
|
|
||||||
<>
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>CGST ({(taxRate / 2).toFixed(2)}%)</span>
|
|
||||||
<span>{money(cgst)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>SGST ({(taxRate / 2).toFixed(2)}%)</span>
|
|
||||||
<span>{money(sgst)}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{taxType === "igst" ? (
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>IGST ({taxRate.toFixed(2)}%)</span>
|
|
||||||
<span>{money(igst)}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="inv-totals__row inv-totals__row--grand">
|
|
||||||
<span>Total</span>
|
|
||||||
<span>{money(total)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="inv-words">Amount in words: {amountInWords}</p>
|
|
||||||
|
|
||||||
{notes ? (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<p className="inv-block__label">Notes</p>
|
|
||||||
<div style={{ whiteSpace: "pre-line" }}>{notes}</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="inv-bottom">
|
|
||||||
<div className="inv-block" style={{ maxWidth: "55%" }}>
|
|
||||||
{bank ? (
|
|
||||||
<>
|
|
||||||
<p className="inv-block__label">Pay To</p>
|
|
||||||
<div className="inv-block__strong">{bank.accountName || vendor.vendorName}</div>
|
|
||||||
<div>{bank.bankName}</div>
|
|
||||||
{bank.accountNo ? <div>Account No.: {bank.accountNo}</div> : null}
|
|
||||||
{bank.branch ? <div>Branch: {bank.branch}</div> : null}
|
|
||||||
{bank.ifsc ? <div>IFSC: {bank.ifsc}</div> : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="inv-signature">
|
|
||||||
{signatureDataUri ? (
|
|
||||||
<>
|
|
||||||
<img className="inv-signature__img" src={signatureDataUri} alt="Signature" />
|
|
||||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
|
||||||
<p className="inv-fineprint">Computer-generated invoice; no signature is required.</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inv-footer">
|
|
||||||
<span>{vendor.vendorName}</span>
|
|
||||||
<span>Generated with Voiced — a Bongbetic product</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Button, ContentSwitcher, InlineLoading, Select, SelectItem, Switch } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { Settings } from "../lib/types";
|
||||||
|
import type { MarginPreset, PageId } from "../pdf/engine/geometry";
|
||||||
|
import { parsePrefs, prefsToJson, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
|
|
||||||
|
const PAGES: { id: PageId; label: string }[] = [
|
||||||
|
{ id: "A4", label: "A4" },
|
||||||
|
{ id: "LETTER", label: "US Letter" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARGINS: { id: MarginPreset; label: string }[] = [
|
||||||
|
{ id: "template", label: "Template default" },
|
||||||
|
{ id: "narrow", label: "Narrow (0.5″)" },
|
||||||
|
{ id: "normal", label: "Normal (0.75″)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prefs: RenderPrefsV1;
|
||||||
|
onChange: (prefs: RenderPrefsV1) => void;
|
||||||
|
/** Live settings; `renderPrefs` is the saved default. */
|
||||||
|
settings: Settings;
|
||||||
|
/** Called after the default was saved so the app reloads its settings. */
|
||||||
|
onSaved: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Where a TemplatePicker goes once there is more than one template. */
|
||||||
|
templateSlot?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page size and margins for the invoice being edited, plus "Save as default" for new invoices. */
|
||||||
|
export default function PageSetupControls({ prefs, onChange, settings, onSaved, disabled, templateSlot }: Props) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const saved = prefsToJson(parsePrefs(settings.renderPrefs));
|
||||||
|
const isDefault = prefsToJson(prefs) === saved;
|
||||||
|
|
||||||
|
const saveDefault = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.saveSettings({ ...settings, renderPrefs: prefsToJson(prefs) });
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voiced-page-setup">
|
||||||
|
<div className="voiced-page-setup__row">
|
||||||
|
{templateSlot}
|
||||||
|
<div className="voiced-page-setup__field">
|
||||||
|
<span className="cds--label">Page size</span>
|
||||||
|
<ContentSwitcher
|
||||||
|
size="md"
|
||||||
|
selectedIndex={Math.max(0, PAGES.findIndex((p) => p.id === prefs.pageSize))}
|
||||||
|
onChange={(e) => !disabled && onChange({ ...prefs, pageSize: PAGES[e.index ?? 0].id })}
|
||||||
|
>
|
||||||
|
{PAGES.map((p) => (
|
||||||
|
<Switch key={p.id} name={p.id} text={p.label} disabled={disabled} />
|
||||||
|
))}
|
||||||
|
</ContentSwitcher>
|
||||||
|
</div>
|
||||||
|
<div className="voiced-page-setup__field">
|
||||||
|
<Select
|
||||||
|
id="page-margins"
|
||||||
|
labelText="Margins"
|
||||||
|
size="md"
|
||||||
|
disabled={disabled}
|
||||||
|
value={prefs.margins}
|
||||||
|
onChange={(e) => onChange({ ...prefs, margins: e.target.value as MarginPreset })}
|
||||||
|
>
|
||||||
|
{MARGINS.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="voiced-page-setup__footer">
|
||||||
|
{saving ? (
|
||||||
|
<InlineLoading status="active" description="Saving…" />
|
||||||
|
) : (
|
||||||
|
<Button kind="tertiary" size="sm" disabled={isDefault} onClick={() => void saveDefault()}>
|
||||||
|
{isDefault ? "Saved as default" : "Save as default"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className="voiced-muted voiced-page-setup__note">
|
||||||
|
{error ?? "This choice is frozen into each invoice when it is issued."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Button, ComposedModal, ContentSwitcher, InlineLoading, InlineNotification, ModalBody, ModalHeader, Popover, PopoverContent, Switch, Tag } from "@carbon/react";
|
||||||
|
import { Maximize, Warning } from "@carbon/icons-react";
|
||||||
|
import { cancelPageRender, closePdf, isRenderCancelled, openPdf, renderPageToCanvas, type PDFDocumentProxy } from "../lib/pdfjs";
|
||||||
|
import type { AuditIssue } from "../pdf/engine/audit";
|
||||||
|
|
||||||
|
export interface PdfPreviewProps {
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
pages: number;
|
||||||
|
issues: AuditIssue[];
|
||||||
|
rendering: boolean;
|
||||||
|
/** "main": rendering runs on the main thread, so the preview only updates when typing pauses. */
|
||||||
|
mode: "worker" | "main";
|
||||||
|
error: string | null;
|
||||||
|
/** Adds a "Focus preview" button (the modal itself is built in). */
|
||||||
|
onFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Zoom = "fit" | "100" | "150";
|
||||||
|
const ZOOMS: { id: Zoom; label: string }[] = [
|
||||||
|
{ id: "fit", label: "Fit width" },
|
||||||
|
{ id: "100", label: "100%" },
|
||||||
|
{ id: "150", label: "150%" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Gap between pages and padding around them, in CSS px (matches .voiced-pdf-preview__pages). */
|
||||||
|
const PAGE_GAP = 16;
|
||||||
|
const PAD = 16;
|
||||||
|
const MAX_LIVE_CANVASES = 3;
|
||||||
|
/** Backing store is never below 2x the CSS size: at fit-width the text would be blurry at 1x. */
|
||||||
|
const MIN_BACKING = 2;
|
||||||
|
const CSS_PX_PER_PT = 96 / 72;
|
||||||
|
|
||||||
|
function useDocument(bytes: Uint8Array | null) {
|
||||||
|
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const current = useRef<PDFDocumentProxy | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bytes) return;
|
||||||
|
let stale = false;
|
||||||
|
openPdf(bytes).then(
|
||||||
|
(opened) => {
|
||||||
|
if (stale) {
|
||||||
|
void closePdf(opened);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = current.current;
|
||||||
|
current.current = opened;
|
||||||
|
setDoc(opened);
|
||||||
|
setLoadError(null);
|
||||||
|
// The old canvases are plain bitmaps, so the old document can go once the slots re-render.
|
||||||
|
if (previous) setTimeout(() => void closePdf(previous), 500);
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
if (!stale) setLoadError(err instanceof Error ? err.message : String(err));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
}, [bytes]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (current.current) void closePdf(current.current);
|
||||||
|
current.current = null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { doc, loadError };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDebounced<T>(value: T, ms: number): T {
|
||||||
|
const [v, setV] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setV(value), ms);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, ms]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotProps {
|
||||||
|
doc: PDFDocumentProxy;
|
||||||
|
index: number;
|
||||||
|
live: boolean;
|
||||||
|
renderScale: number;
|
||||||
|
cssWidth: number;
|
||||||
|
cssHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page. A live slot renders into an offscreen canvas and swaps it in when done, so a re-render
|
||||||
|
* never blanks the page; a slot that is not live holds no canvas at all.
|
||||||
|
*/
|
||||||
|
function PageSlot({ doc, index, live, renderScale, cssWidth, cssHeight }: SlotProps) {
|
||||||
|
const host = useRef<HTMLDivElement>(null);
|
||||||
|
const key = useRef({}).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = host.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!live) {
|
||||||
|
const old = el.firstElementChild as HTMLCanvasElement | null;
|
||||||
|
el.replaceChildren();
|
||||||
|
if (old) old.width = old.height = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dead = false;
|
||||||
|
const off = document.createElement("canvas");
|
||||||
|
off.className = "voiced-pdf-preview__canvas";
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const page = await doc.getPage(index + 1);
|
||||||
|
await renderPageToCanvas(page, off, renderScale, key);
|
||||||
|
if (dead) return;
|
||||||
|
const old = el.firstElementChild as HTMLCanvasElement | null;
|
||||||
|
el.replaceChildren(off);
|
||||||
|
if (old) old.width = old.height = 0;
|
||||||
|
} catch (err) {
|
||||||
|
if (!dead && !isRenderCancelled(err)) console.error("PDF page render failed", err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
cancelPageRender(key);
|
||||||
|
};
|
||||||
|
}, [doc, index, live, renderScale, key]);
|
||||||
|
|
||||||
|
return <div ref={host} className="voiced-pdf-preview__page" data-page={index} style={{ width: cssWidth, height: cssHeight }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewerProps {
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
/** Page count from the render, shown until the document has opened. */
|
||||||
|
pages: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zoom control, page indicator and the scrolling stack of page canvases. */
|
||||||
|
function PdfViewer({ bytes, pages, className }: ViewerProps) {
|
||||||
|
const { doc, loadError } = useDocument(bytes);
|
||||||
|
const [zoom, setZoom] = useState<Zoom>("fit");
|
||||||
|
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||||
|
const [fitWidth, setFitWidth] = useState(0);
|
||||||
|
const [live, setLive] = useState<number[]>([0]);
|
||||||
|
const [current, setCurrent] = useState(1);
|
||||||
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doc) return;
|
||||||
|
let stale = false;
|
||||||
|
void doc.getPage(1).then((p) => {
|
||||||
|
const v = p.getViewport({ scale: 1 });
|
||||||
|
if (!stale) setNatural({ w: v.width, h: v.height });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
}, [doc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scroller.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setFitWidth(Math.max(0, el.clientWidth - 2 * PAD));
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pageCount = doc?.numPages ?? pages;
|
||||||
|
const cssWidth = natural ? (zoom === "fit" ? fitWidth : natural.w * CSS_PX_PER_PT * (Number(zoom) / 100)) : 0;
|
||||||
|
const cssHeight = natural ? (cssWidth * natural.h) / natural.w : 0;
|
||||||
|
const settledWidth = useDebounced(cssWidth, 150);
|
||||||
|
const renderScale = natural && settledWidth > 0 ? (settledWidth / natural.w) * Math.max(MIN_BACKING, window.devicePixelRatio || 1) : 0;
|
||||||
|
|
||||||
|
// Which pages are near the viewport; at most MAX_LIVE_CANVASES of them hold a canvas.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scroller.current;
|
||||||
|
if (!root || !doc || !natural) return;
|
||||||
|
const visible = new Set<number>();
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
const n = Number((e.target as HTMLElement).dataset.page);
|
||||||
|
if (e.isIntersecting) visible.add(n);
|
||||||
|
else visible.delete(n);
|
||||||
|
}
|
||||||
|
const next = [...visible].sort((a, b) => a - b).slice(0, MAX_LIVE_CANVASES);
|
||||||
|
setLive((prev) => (prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : next));
|
||||||
|
},
|
||||||
|
{ root, rootMargin: "200px 0px" },
|
||||||
|
);
|
||||||
|
root.querySelectorAll("[data-page]").forEach((el) => io.observe(el));
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [doc, natural, pageCount, cssHeight]);
|
||||||
|
|
||||||
|
// Pages are one size, so the page under the middle of the viewport follows from the scroll offset.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scroller.current;
|
||||||
|
if (!root || cssHeight <= 0) return;
|
||||||
|
let frame = 0;
|
||||||
|
const update = () => {
|
||||||
|
frame = 0;
|
||||||
|
const middle = root.scrollTop + root.clientHeight / 2 - PAD;
|
||||||
|
const n = Math.floor(middle / (cssHeight + PAGE_GAP)) + 1;
|
||||||
|
setCurrent(Math.min(Math.max(n, 1), Math.max(pageCount, 1)));
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
root.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener("scroll", onScroll);
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [cssHeight, pageCount]);
|
||||||
|
|
||||||
|
const liveSet = useMemo(() => new Set(live), [live]);
|
||||||
|
const zoomIndex = ZOOMS.findIndex((z) => z.id === zoom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`voiced-pdf-viewer ${className ?? ""}`}>
|
||||||
|
<div className="voiced-pdf-viewer__bar">
|
||||||
|
<ContentSwitcher
|
||||||
|
size="sm"
|
||||||
|
className="voiced-pdf-viewer__zoom"
|
||||||
|
selectedIndex={zoomIndex}
|
||||||
|
onChange={(e) => setZoom(ZOOMS[e.index ?? 0].id)}
|
||||||
|
>
|
||||||
|
{ZOOMS.map((z) => (
|
||||||
|
<Switch key={z.id} name={z.id} text={z.label} />
|
||||||
|
))}
|
||||||
|
</ContentSwitcher>
|
||||||
|
<span className="voiced-pdf-viewer__page" aria-live="polite">
|
||||||
|
{pageCount > 0 ? `Page ${current} of ${pageCount}` : "No pages"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref={scroller} className="voiced-pdf-preview__scroll">
|
||||||
|
{loadError ? (
|
||||||
|
<InlineNotification lowContrast hideCloseButton kind="error" title="Cannot show the PDF" subtitle={loadError} />
|
||||||
|
) : null}
|
||||||
|
<div className="voiced-pdf-preview__pages">
|
||||||
|
{doc && natural && cssWidth > 0
|
||||||
|
? Array.from({ length: pageCount }, (_, i) => (
|
||||||
|
<PageSlot
|
||||||
|
key={i}
|
||||||
|
doc={doc}
|
||||||
|
index={i}
|
||||||
|
live={liveSet.has(i) && renderScale > 0}
|
||||||
|
renderScale={renderScale}
|
||||||
|
cssWidth={cssWidth}
|
||||||
|
cssHeight={cssHeight}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PdfPreview({ bytes, pages, issues, rendering, mode, error, onFocus }: PdfPreviewProps) {
|
||||||
|
const [issuesOpen, setIssuesOpen] = useState(false);
|
||||||
|
const [focusOpen, setFocusOpen] = useState(false);
|
||||||
|
const errors = issues.filter((i) => i.severity === "error").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voiced-pdf-preview">
|
||||||
|
<div className="voiced-pdf-preview__status">
|
||||||
|
<div className="voiced-pdf-preview__chips">
|
||||||
|
{rendering ? <InlineLoading status="active" description="Rendering…" /> : null}
|
||||||
|
{mode === "main" ? (
|
||||||
|
<Tag type="gray" size="sm">
|
||||||
|
Preview paused while typing
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="voiced-pdf-preview__actions">
|
||||||
|
{issues.length > 0 ? (
|
||||||
|
<Popover open={issuesOpen} align="bottom-end" dropShadow onRequestClose={() => setIssuesOpen(false)}>
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
renderIcon={Warning}
|
||||||
|
aria-expanded={issuesOpen}
|
||||||
|
onClick={() => setIssuesOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{errors > 0 ? `Issues (${issues.length}, ${errors} error${errors === 1 ? "" : "s"})` : `Issues (${issues.length})`}
|
||||||
|
</Button>
|
||||||
|
<PopoverContent className="voiced-pdf-issues">
|
||||||
|
<ul className="voiced-pdf-issues__list">
|
||||||
|
{issues.map((issue, i) => (
|
||||||
|
<li key={i} className="voiced-pdf-issues__item">
|
||||||
|
<div className="voiced-pdf-issues__head">
|
||||||
|
<Tag type={issue.severity === "error" ? "red" : "gray"} size="sm" className={issue.severity === "error" ? undefined : "voiced-tag--warn"}>
|
||||||
|
{issue.code}
|
||||||
|
</Tag>
|
||||||
|
<span className="voiced-muted">Page {issue.page}</span>
|
||||||
|
</div>
|
||||||
|
<p className="voiced-pdf-issues__message">{issue.message}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
hasIconOnly
|
||||||
|
renderIcon={Maximize}
|
||||||
|
iconDescription="Focus preview"
|
||||||
|
tooltipPosition="left"
|
||||||
|
disabled={!bytes}
|
||||||
|
onClick={() => {
|
||||||
|
setFocusOpen(true);
|
||||||
|
onFocus?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <InlineNotification lowContrast hideCloseButton kind="error" title="Preview failed" subtitle={error} /> : null}
|
||||||
|
|
||||||
|
{bytes ? (
|
||||||
|
<PdfViewer bytes={bytes} pages={pages} />
|
||||||
|
) : (
|
||||||
|
<div className="voiced-pdf-preview__empty">{rendering ? "Rendering the first preview…" : "The preview appears here."}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ComposedModal open={focusOpen} size="lg" className="voiced-pdf-focus" onClose={() => setFocusOpen(false)}>
|
||||||
|
<ModalHeader title="Invoice preview" closeModal={() => setFocusOpen(false)} />
|
||||||
|
<ModalBody hasScrollingContent={false}>{focusOpen && bytes ? <PdfViewer bytes={bytes} pages={pages} className="voiced-pdf-viewer--focus" /> : null}</ModalBody>
|
||||||
|
</ComposedModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { createExportDeps } from "../lib/exportDeps";
|
||||||
|
import { exportInvoice, isExportCancelled, isFlattenFailure, type ExportProgress, type ExportResult, type ExportSource } from "../lib/exportFlow";
|
||||||
|
import type { ExportMode } from "../lib/filename";
|
||||||
|
import type { BankAccount, Invoice, Settings } from "../lib/types";
|
||||||
|
|
||||||
|
export interface ExportRequestState {
|
||||||
|
invoice: Invoice;
|
||||||
|
mode: ExportMode;
|
||||||
|
source: ExportSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportFailure {
|
||||||
|
message: string;
|
||||||
|
/** The flatten itself failed: the modal offers Retry and "Export searchable instead". */
|
||||||
|
flatten: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceExport {
|
||||||
|
busy: boolean;
|
||||||
|
progress: ExportProgress | null;
|
||||||
|
request: ExportRequestState | null;
|
||||||
|
error: ExportFailure | null;
|
||||||
|
result: ExportResult | null;
|
||||||
|
start(invoice: Invoice, mode: ExportMode, source: ExportSource): Promise<ExportResult | null>;
|
||||||
|
cancel(): void;
|
||||||
|
retry(): void;
|
||||||
|
exportSearchableInstead(): void;
|
||||||
|
dismiss(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs exports one at a time and keeps what the UI shows: progress, the last result, the last error and
|
||||||
|
* the request, so Retry and "Export searchable instead" can repeat it. `onInvoiceUpdated` hears about an
|
||||||
|
* invoice that was archived by the export.
|
||||||
|
*/
|
||||||
|
export function useInvoiceExport(opts: { settings: Settings; bank: BankAccount | null; onInvoiceUpdated?: (invoice: Invoice) => void }): InvoiceExport {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<ExportProgress | null>(null);
|
||||||
|
const [request, setRequest] = useState<ExportRequestState | null>(null);
|
||||||
|
const [error, setError] = useState<ExportFailure | null>(null);
|
||||||
|
const [result, setResult] = useState<ExportResult | null>(null);
|
||||||
|
const controller = useRef<AbortController | null>(null);
|
||||||
|
const running = useRef(false);
|
||||||
|
|
||||||
|
const latest = useRef(opts);
|
||||||
|
latest.current = opts;
|
||||||
|
|
||||||
|
const start = useCallback(async (invoice: Invoice, mode: ExportMode, source: ExportSource) => {
|
||||||
|
if (running.current) return null;
|
||||||
|
running.current = true;
|
||||||
|
const ctl = new AbortController();
|
||||||
|
controller.current = ctl;
|
||||||
|
setRequest({ invoice, mode, source });
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
setProgress({ phase: "rendering" });
|
||||||
|
try {
|
||||||
|
const deps = createExportDeps({ settings: latest.current.settings, bank: latest.current.bank });
|
||||||
|
const res = await exportInvoice({ invoice, mode, source, signal: ctl.signal, onProgress: setProgress }, deps);
|
||||||
|
if (res) {
|
||||||
|
setResult(res);
|
||||||
|
if (res.invoice !== invoice) latest.current.onInvoiceUpdated?.(res.invoice);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
if (!isExportCancelled(e)) setError({ message: e instanceof Error ? e.message : String(e), flatten: isFlattenFailure(e) });
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
running.current = false;
|
||||||
|
controller.current = null;
|
||||||
|
setBusy(false);
|
||||||
|
setProgress(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => controller.current?.abort(), []);
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
}, []);
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
if (request) void start(request.invoice, request.mode, request.source);
|
||||||
|
}, [request, start]);
|
||||||
|
const exportSearchableInstead = useCallback(() => {
|
||||||
|
if (request) void start(request.invoice, "searchable", request.source);
|
||||||
|
}, [request, start]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss }),
|
||||||
|
[busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { getRenderClient, nextRenderJobId } from "../lib/renderClient";
|
||||||
|
import type { AuditIssue } from "../pdf/engine/audit";
|
||||||
|
import type { RenderModel } from "../pdf/model/build";
|
||||||
|
import type { RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
|
import { Superseded } from "../pdf/render/client";
|
||||||
|
|
||||||
|
/** Typing pauses this long before the model is re-rendered; the render itself takes about 1 s. */
|
||||||
|
export const MODEL_DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
|
export interface InvoicePdfState {
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
pages: number;
|
||||||
|
issues: AuditIssue[];
|
||||||
|
rendering: boolean;
|
||||||
|
error: string | null;
|
||||||
|
mode: "worker" | "main";
|
||||||
|
lastMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL: InvoicePdfState = { bytes: null, pages: 0, issues: [], rendering: false, error: null, mode: "worker", lastMs: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live PDF for a render model. Model changes are debounced; changes to the prefs render at once. A new
|
||||||
|
* request cancels the one in flight, the previous bytes stay in place while the next render runs, and
|
||||||
|
* a null model never renders (the last PDF stays). Pass a memoised model: a new object means a change.
|
||||||
|
*/
|
||||||
|
export function useInvoicePdf({ model, prefs }: { model: RenderModel | null; prefs: RenderPrefsV1 }): InvoicePdfState {
|
||||||
|
const [state, setState] = useState<InvoicePdfState>(INITIAL);
|
||||||
|
const latestJob = useRef(0);
|
||||||
|
const lastPrefsKey = useRef<string | null>(null);
|
||||||
|
const mounted = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const prefsKey = JSON.stringify(prefs);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model) return;
|
||||||
|
const prefsChanged = lastPrefsKey.current !== prefsKey;
|
||||||
|
lastPrefsKey.current = prefsKey;
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
const id = nextRenderJobId();
|
||||||
|
latestJob.current = id;
|
||||||
|
setState((s) => ({ ...s, rendering: true }));
|
||||||
|
const client = getRenderClient();
|
||||||
|
client.render({ id, model, prefs: JSON.parse(prefsKey) as RenderPrefsV1 }).then(
|
||||||
|
(res) => {
|
||||||
|
if (!mounted.current || latestJob.current !== id) return;
|
||||||
|
setState({ bytes: res.bytes, pages: res.pages, issues: res.issues, rendering: false, error: null, mode: client.mode, lastMs: res.ms });
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
if (!mounted.current || latestJob.current !== id) return;
|
||||||
|
// Superseded while still the newest here means another caller (export) took the worker; keep
|
||||||
|
// the old PDF and let the next model or prefs change render again.
|
||||||
|
const error = err instanceof Superseded ? null : err instanceof Error ? err.message : String(err);
|
||||||
|
setState((s) => ({ ...s, rendering: false, error: error ?? s.error, mode: client.mode }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prefsChanged) start();
|
||||||
|
const timer = prefsChanged ? undefined : setTimeout(start, MODEL_DEBOUNCE_MS);
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [model, prefsKey]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export interface ShortcutHandlers {
|
||||||
|
onNew?: () => void;
|
||||||
|
onSaveDraft?: () => void;
|
||||||
|
onIssue?: () => void;
|
||||||
|
onAddLine?: () => void;
|
||||||
|
onHelp?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTyping = (target: EventTarget | null) => {
|
||||||
|
const el = target as HTMLElement | null;
|
||||||
|
if (!el) return false;
|
||||||
|
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global keyboard shortcuts. Ctrl combinations work everywhere; the bare `?` is ignored
|
||||||
|
* while typing. F5 / Ctrl+R are blocked in production builds because a webview reload
|
||||||
|
* would discard in-memory state.
|
||||||
|
*/
|
||||||
|
export function useShortcuts(handlers: ShortcutHandlers) {
|
||||||
|
const ref = useRef(handlers);
|
||||||
|
ref.current = handlers;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
const ctrl = event.ctrlKey || event.metaKey;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
|
||||||
|
if (import.meta.env.PROD && (event.key === "F5" || (ctrl && key === "r"))) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (fn?: () => void) => {
|
||||||
|
event.preventDefault();
|
||||||
|
fn?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ctrl && !event.shiftKey && !event.altKey) {
|
||||||
|
if (key === "n") return run(ref.current.onNew);
|
||||||
|
if (key === "s") return run(ref.current.onSaveDraft);
|
||||||
|
if (key === "l") return run(ref.current.onAddLine);
|
||||||
|
if (key === "enter") return run(ref.current.onIssue);
|
||||||
|
}
|
||||||
|
if (!ctrl && !event.altKey && event.key === "?" && !isTyping(event.target)) {
|
||||||
|
run(ref.current.onHelp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHORTCUT_LIST: { keys: string; action: string }[] = [
|
||||||
|
{ keys: "Ctrl + N", action: "New invoice" },
|
||||||
|
{ keys: "Ctrl + S", action: "Save draft" },
|
||||||
|
{ keys: "Ctrl + Enter", action: "Issue and export PDF" },
|
||||||
|
{ keys: "Ctrl + L", action: "Add line item" },
|
||||||
|
{ keys: "?", action: "Show this list" },
|
||||||
|
];
|
||||||
+33
-4
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import type {
|
import type {
|
||||||
BankAccount,
|
BankAccount,
|
||||||
Client,
|
Client,
|
||||||
|
DraftSummary,
|
||||||
Invoice,
|
Invoice,
|
||||||
InvoiceInput,
|
InvoiceInput,
|
||||||
InvoiceSeries,
|
InvoiceSeries,
|
||||||
@@ -9,6 +10,13 @@ import type {
|
|||||||
Settings,
|
Settings,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
export interface ArchiveStatus {
|
||||||
|
archived: boolean;
|
||||||
|
sha256?: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
archivedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getSettings: () => invoke<Settings>("get_settings"),
|
getSettings: () => invoke<Settings>("get_settings"),
|
||||||
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
|
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
|
||||||
@@ -27,10 +35,18 @@ export const api = {
|
|||||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||||
|
|
||||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||||
createInvoice: (input: InvoiceInput) => invoke<Invoice>("create_invoice", { input }),
|
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
||||||
|
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||||
|
cancelInvoice: (id: number, reason: string) =>
|
||||||
|
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||||
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
||||||
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
||||||
deleteInvoice: (id: number) => invoke<void>("delete_invoice", { id }),
|
|
||||||
|
saveDraft: (id: number | null, payload: unknown) =>
|
||||||
|
invoke<number>("save_draft", { id, payload }),
|
||||||
|
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
||||||
|
getDraft: (id: number) => invoke<unknown>("get_draft", { id }),
|
||||||
|
deleteDraft: (id: number) => invoke<void>("delete_draft", { id }),
|
||||||
|
|
||||||
importAsset: (sourcePath: string, kind: string) =>
|
importAsset: (sourcePath: string, kind: string) =>
|
||||||
invoke<string>("import_asset", { sourcePath, kind }),
|
invoke<string>("import_asset", { sourcePath, kind }),
|
||||||
@@ -38,6 +54,19 @@ export const api = {
|
|||||||
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
|
||||||
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
|
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { path }),
|
||||||
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
|
removeAsset: (path: string) => invoke<void>("remove_asset", { path }),
|
||||||
saveBinaryFile: (path: string, dataBase64: string) =>
|
|
||||||
invoke<void>("save_binary_file", { path, dataBase64 }),
|
// Raw-body commands: the payload is the bytes themselves (no base64); metadata goes in headers.
|
||||||
|
// Header values must be visible ASCII, hence the encodeURIComponent on paths.
|
||||||
|
archivePdf: (invoiceId: number, bytes: Uint8Array, fingerprint: string) =>
|
||||||
|
invoke<string>("archive_pdf", bytes, {
|
||||||
|
headers: { "x-invoice-id": String(invoiceId), "x-fingerprint": fingerprint },
|
||||||
|
}),
|
||||||
|
readArchive: async (invoiceId: number): Promise<Uint8Array> =>
|
||||||
|
new Uint8Array(await invoke<ArrayBuffer>("read_archive", { invoiceId })),
|
||||||
|
archiveStatus: (invoiceId: number) => invoke<ArchiveStatus>("archive_status", { invoiceId }),
|
||||||
|
writeExportFile: (path: string, bytes: Uint8Array) =>
|
||||||
|
invoke<string>("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }),
|
||||||
|
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||||
|
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
||||||
|
openFile: (path: string) => invoke<void>("open_file", { path }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { flattenPages, type CanvasLike, type FlattenResult, type PdfDocLike } from "../pdf/flatten";
|
||||||
|
|
||||||
|
/** Lets the browser paint and handle input; falls back to a timer when rAF is paused (hidden window). */
|
||||||
|
export const yieldToBrowser = (): Promise<void> =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (!done) {
|
||||||
|
done = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(finish, 50);
|
||||||
|
requestAnimationFrame(() => setTimeout(finish, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** JPEG bytes of a canvas via the browser's encoder. */
|
||||||
|
export function encodeCanvasJpeg(canvas: CanvasLike, quality: number): Promise<Uint8Array> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
(canvas as unknown as HTMLCanvasElement).toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob) return reject(new Error("the browser could not encode the page image (out of memory?)"));
|
||||||
|
blob.arrayBuffer().then((buf) => resolve(new Uint8Array(buf)), reject);
|
||||||
|
},
|
||||||
|
"image/jpeg",
|
||||||
|
quality,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserFlattenOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (page: number, pages: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flattens PDF bytes at 300 DPI with pdf.js and a DOM canvas; opens and closes the document itself. */
|
||||||
|
export async function flattenInBrowser(bytes: Uint8Array, opts: BrowserFlattenOptions = {}): Promise<FlattenResult> {
|
||||||
|
const { openPdf, closePdf } = await import("./pdfjs");
|
||||||
|
const doc = await openPdf(bytes);
|
||||||
|
try {
|
||||||
|
return await flattenPages({
|
||||||
|
pdf: doc as unknown as PdfDocLike,
|
||||||
|
createCanvas: (width, height) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
return canvas as unknown as CanvasLike;
|
||||||
|
},
|
||||||
|
encodeJpeg: encodeCanvasJpeg,
|
||||||
|
yieldToUi: yieldToBrowser,
|
||||||
|
signal: opts.signal,
|
||||||
|
onProgress: opts.onProgress,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await closePdf(doc).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "./draft";
|
||||||
|
import { EMPTY_ITEM } from "./types";
|
||||||
|
|
||||||
|
const defaults: InvoiceFormState = {
|
||||||
|
invoiceDate: "2026-01-01",
|
||||||
|
dueDate: "2026-01-31",
|
||||||
|
clientId: null,
|
||||||
|
clientName: "",
|
||||||
|
clientAddress: "",
|
||||||
|
clientGstin: "",
|
||||||
|
poNumber: "",
|
||||||
|
placeOfSupply: "29",
|
||||||
|
saveClient: false,
|
||||||
|
items: [{ ...EMPTY_ITEM, hsnSac: "9983" }],
|
||||||
|
discount: 0,
|
||||||
|
taxRate: 18,
|
||||||
|
reverseCharge: false,
|
||||||
|
bankId: 2,
|
||||||
|
signaturePath: "/sig/default.png",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const filled: InvoiceFormState = {
|
||||||
|
invoiceDate: "2026-03-05",
|
||||||
|
dueDate: "2026-04-04",
|
||||||
|
clientId: 7,
|
||||||
|
clientName: "Acme Pvt Ltd",
|
||||||
|
clientAddress: "1 Main Rd\nKolkata",
|
||||||
|
clientGstin: "19AAAAA0000A1Z5",
|
||||||
|
poNumber: "PO-9",
|
||||||
|
placeOfSupply: "19",
|
||||||
|
saveClient: true,
|
||||||
|
items: [
|
||||||
|
{ ...EMPTY_ITEM, description: "Design", mode: "rate", rate: 500, unit: "hour", quantity: 4, hsnSac: "9983" },
|
||||||
|
{ ...EMPTY_ITEM, description: "Fee", amount: 1200, sortOrder: 1, hsnSac: "9983" },
|
||||||
|
],
|
||||||
|
discount: 100,
|
||||||
|
taxRate: 12,
|
||||||
|
reverseCharge: true,
|
||||||
|
bankId: null,
|
||||||
|
signaturePath: null,
|
||||||
|
notes: "Thanks",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("draft payload", () => {
|
||||||
|
it("round-trips through JSON", () => {
|
||||||
|
const json = JSON.parse(JSON.stringify(toDraftPayload(filled)));
|
||||||
|
expect(fromDraftPayload(json, defaults)).toEqual(filled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not store derived totals", () => {
|
||||||
|
const keys = Object.keys(toDraftPayload(filled));
|
||||||
|
for (const k of ["total", "subtotal", "cgst", "sgst", "igst", "amountInWords"]) {
|
||||||
|
expect(keys).not.toContain(k);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaults for missing fields", () => {
|
||||||
|
const out = fromDraftPayload({ clientName: "Only name" }, defaults);
|
||||||
|
expect(out).toEqual({ ...defaults, clientName: "Only name" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates garbage input and partial items", () => {
|
||||||
|
expect(fromDraftPayload(null, defaults)).toEqual(defaults);
|
||||||
|
expect(fromDraftPayload("x", defaults)).toEqual(defaults);
|
||||||
|
const out = fromDraftPayload({ items: [{ description: "A" }], discount: "oops" }, defaults);
|
||||||
|
expect(out.discount).toBe(0);
|
||||||
|
expect(out.items).toHaveLength(1);
|
||||||
|
expect(out.items[0]).toMatchObject({ description: "A", mode: "fixed", quantity: 1, hsnSac: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { EMPTY_ITEM } from "./types";
|
||||||
|
import type { InvoiceItem, LineMode, LineUnit } from "./types";
|
||||||
|
|
||||||
|
/** Everything that defines an invoice on the New invoice form. Derived totals are not part of it. */
|
||||||
|
export interface InvoiceFormState {
|
||||||
|
invoiceDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
clientId: number | null;
|
||||||
|
clientName: string;
|
||||||
|
clientAddress: string;
|
||||||
|
clientGstin: string;
|
||||||
|
poNumber: string;
|
||||||
|
placeOfSupply: string;
|
||||||
|
saveClient: boolean;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
discount: number;
|
||||||
|
taxRate: number;
|
||||||
|
reverseCharge: boolean;
|
||||||
|
bankId: number | null;
|
||||||
|
signaturePath: string | null;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (v: unknown, fallback: string) => (typeof v === "string" ? v : fallback);
|
||||||
|
const num = (v: unknown, fallback: number) =>
|
||||||
|
typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
||||||
|
const bool = (v: unknown, fallback: boolean) => (typeof v === "boolean" ? v : fallback);
|
||||||
|
const nullableNum = (v: unknown, fallback: number | null) =>
|
||||||
|
v === null ? null : typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
||||||
|
|
||||||
|
function itemFromJson(raw: unknown, index: number): InvoiceItem {
|
||||||
|
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
description: str(r.description, ""),
|
||||||
|
mode: (r.mode === "rate" ? "rate" : "fixed") as LineMode,
|
||||||
|
rate: num(r.rate, EMPTY_ITEM.rate),
|
||||||
|
unit: str(r.unit, EMPTY_ITEM.unit) as LineUnit,
|
||||||
|
quantity: num(r.quantity, EMPTY_ITEM.quantity),
|
||||||
|
amount: num(r.amount, EMPTY_ITEM.amount),
|
||||||
|
sortOrder: index,
|
||||||
|
hsnSac: str(r.hsnSac, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDraftPayload(state: InvoiceFormState): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
invoiceDate: state.invoiceDate,
|
||||||
|
dueDate: state.dueDate,
|
||||||
|
clientId: state.clientId,
|
||||||
|
clientName: state.clientName,
|
||||||
|
clientAddress: state.clientAddress,
|
||||||
|
clientGstin: state.clientGstin,
|
||||||
|
poNumber: state.poNumber,
|
||||||
|
placeOfSupply: state.placeOfSupply,
|
||||||
|
saveClient: state.saveClient,
|
||||||
|
items: state.items.map((item, index) => ({ ...item, sortOrder: index })),
|
||||||
|
discount: state.discount,
|
||||||
|
taxRate: state.taxRate,
|
||||||
|
reverseCharge: state.reverseCharge,
|
||||||
|
bankId: state.bankId,
|
||||||
|
signaturePath: state.signaturePath,
|
||||||
|
notes: state.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tolerant: any missing or wrongly typed field falls back to `defaults`. */
|
||||||
|
export function fromDraftPayload(json: unknown, defaults: InvoiceFormState): InvoiceFormState {
|
||||||
|
const r = (json && typeof json === "object" ? json : {}) as Record<string, unknown>;
|
||||||
|
const items = Array.isArray(r.items) && r.items.length > 0 ? r.items.map(itemFromJson) : defaults.items;
|
||||||
|
return {
|
||||||
|
invoiceDate: str(r.invoiceDate, defaults.invoiceDate),
|
||||||
|
dueDate: str(r.dueDate, defaults.dueDate),
|
||||||
|
clientId: nullableNum(r.clientId, defaults.clientId),
|
||||||
|
clientName: str(r.clientName, defaults.clientName),
|
||||||
|
clientAddress: str(r.clientAddress, defaults.clientAddress),
|
||||||
|
clientGstin: str(r.clientGstin, defaults.clientGstin),
|
||||||
|
poNumber: str(r.poNumber, defaults.poNumber),
|
||||||
|
placeOfSupply: str(r.placeOfSupply, defaults.placeOfSupply),
|
||||||
|
saveClient: bool(r.saveClient, defaults.saveClient),
|
||||||
|
items,
|
||||||
|
discount: num(r.discount, defaults.discount),
|
||||||
|
taxRate: num(r.taxRate, defaults.taxRate),
|
||||||
|
reverseCharge: bool(r.reverseCharge, defaults.reverseCharge),
|
||||||
|
bankId: nullableNum(r.bankId, defaults.bankId),
|
||||||
|
signaturePath:
|
||||||
|
r.signaturePath === null || typeof r.signaturePath === "string"
|
||||||
|
? r.signaturePath
|
||||||
|
: defaults.signaturePath,
|
||||||
|
notes: str(r.notes, defaults.notes),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { flattenInBrowser } from "./browserFlatten";
|
||||||
|
import type { ExportDeps } from "./exportFlow";
|
||||||
|
import { renderInvoicePdf } from "./pdf";
|
||||||
|
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||||
|
import type { BankAccount, Settings } from "./types";
|
||||||
|
|
||||||
|
/** The real collaborators of the export flow: Tauri commands, the render client, pdf.js and the save dialog. */
|
||||||
|
export function createExportDeps(ctx: { settings: Settings; bank: BankAccount | null }): ExportDeps {
|
||||||
|
return {
|
||||||
|
issueInvoice: (input, renderPrefs) => api.issueInvoice(input, renderPrefs),
|
||||||
|
render: async (invoice) => {
|
||||||
|
const { bytes, fingerprint } = await renderInvoicePdf(invoice, ctx.settings, ctx.bank);
|
||||||
|
return { bytes, fingerprint };
|
||||||
|
},
|
||||||
|
archivePdf: api.archivePdf,
|
||||||
|
readArchive: api.readArchive,
|
||||||
|
flatten: (bytes, opts) => flattenInBrowser(bytes, opts),
|
||||||
|
assemble: async (pages, title) => (await getRenderClient().assemble({ id: nextRenderJobId(), pages, title })).bytes,
|
||||||
|
getLastExportDir: api.getLastExportDir,
|
||||||
|
save: ({ defaultPath }) => save({ defaultPath, filters: [{ name: "PDF document", extensions: ["pdf"] }] }),
|
||||||
|
writeExportFile: api.writeExportFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { FlattenCancelled, FlattenError } from "../pdf/flatten";
|
||||||
|
import { exportInvoice, ExportError, isFlattenFailure, issueAndArchive, renderAndArchive, type ExportDeps, type ExportProgress } from "./exportFlow";
|
||||||
|
import type { Invoice, InvoiceInput } from "./types";
|
||||||
|
|
||||||
|
const bytes = (s: string) => new TextEncoder().encode(s);
|
||||||
|
const text = (b: Uint8Array) => new TextDecoder().decode(b);
|
||||||
|
|
||||||
|
const invoice = (over: Partial<Invoice> = {}): Invoice =>
|
||||||
|
({
|
||||||
|
id: 7,
|
||||||
|
number: "INV/2026-007",
|
||||||
|
clientName: "Acme Learning",
|
||||||
|
invoiceDate: "2026-09-15",
|
||||||
|
status: "issued",
|
||||||
|
renderPrefs: '{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"template"}',
|
||||||
|
archivedPdfSha256: "a".repeat(64),
|
||||||
|
archivedFingerprint: "fp-issued",
|
||||||
|
items: [],
|
||||||
|
...over,
|
||||||
|
}) as Invoice;
|
||||||
|
|
||||||
|
function fakes(over: Partial<ExportDeps> = {}) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const written: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||||
|
const deps: ExportDeps = {
|
||||||
|
issueInvoice: vi.fn(async () => (calls.push("issue"), invoice({ archivedPdfSha256: null, archivedFingerprint: null }))),
|
||||||
|
render: vi.fn(async () => (calls.push("render"), { bytes: bytes("RENDERED"), fingerprint: "fp-now" })),
|
||||||
|
archivePdf: vi.fn(async () => (calls.push("archive"), "b".repeat(64))),
|
||||||
|
readArchive: vi.fn(async () => (calls.push("readArchive"), bytes("ARCHIVED"))),
|
||||||
|
flatten: vi.fn(async (b, o) => {
|
||||||
|
calls.push("flatten");
|
||||||
|
o.onProgress?.(1, 2);
|
||||||
|
o.onProgress?.(2, 2);
|
||||||
|
return {
|
||||||
|
pages: [1, 2].map((n) => ({ jpeg: bytes(`${text(b)}-jpeg${n}`), widthPt: 595, heightPt: 842, pxWidth: 2480, pxHeight: 3508, renderMs: 1, encodeMs: 1 })),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
assemble: vi.fn(async (pages, title) => (calls.push("assemble"), bytes(`FLAT[${title}|${pages.map((p: { jpeg: Uint8Array }) => text(p.jpeg)).join(",")}]`))),
|
||||||
|
getLastExportDir: vi.fn(async () => "/home/u/Documents"),
|
||||||
|
save: vi.fn(async ({ defaultPath }) => (calls.push("save"), defaultPath)),
|
||||||
|
writeExportFile: vi.fn(async (path, b) => (calls.push("write"), written.push({ path, bytes: b }), path)),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
return { deps, calls, written };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("issueAndArchive / renderAndArchive", () => {
|
||||||
|
it("issues, renders the issued invoice and archives it with the layout fingerprint", async () => {
|
||||||
|
const { deps, calls } = fakes();
|
||||||
|
const out = await issueAndArchive({} as InvoiceInput, { pageSize: "LETTER" }, deps);
|
||||||
|
expect(calls).toEqual(["issue", "render", "archive"]);
|
||||||
|
expect(deps.issueInvoice).toHaveBeenCalledWith({}, { pageSize: "LETTER" });
|
||||||
|
// The render is of the invoice the backend returned (so its frozen prefs), not of the form.
|
||||||
|
expect((deps.render as ReturnType<typeof vi.fn>).mock.calls[0][0].renderPrefs).toContain("LETTER");
|
||||||
|
expect(deps.archivePdf).toHaveBeenCalledWith(7, bytes("RENDERED"), "fp-now");
|
||||||
|
expect(out).toMatchObject({ archived: true });
|
||||||
|
expect(out.invoice).toMatchObject({ archivedPdfSha256: "b".repeat(64), archivedFingerprint: "fp-now" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a render failure keeps the invoice and reports the error", async () => {
|
||||||
|
const { deps } = fakes({ render: vi.fn(async () => Promise.reject(new Error("worker died"))) });
|
||||||
|
const out = await issueAndArchive({} as InvoiceInput, {}, deps);
|
||||||
|
expect(out.archived).toBe(false);
|
||||||
|
expect(out.invoice.id).toBe(7);
|
||||||
|
expect(out.error).toContain("worker died");
|
||||||
|
expect(deps.archivePdf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an archive failure keeps the invoice and reports the error", async () => {
|
||||||
|
const { deps } = fakes({ archivePdf: vi.fn(async () => Promise.reject("disk full")) });
|
||||||
|
const out = await issueAndArchive({} as InvoiceInput, {}, deps);
|
||||||
|
expect(out).toMatchObject({ archived: false });
|
||||||
|
expect(out.error).toContain("disk full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed issue throws: nothing was issued, nothing is rendered", async () => {
|
||||||
|
const { deps } = fakes({ issueInvoice: vi.fn(async () => Promise.reject(new Error("series locked"))) });
|
||||||
|
await expect(issueAndArchive({} as InvoiceInput, {}, deps)).rejects.toThrow("series locked");
|
||||||
|
expect(deps.render).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renderAndArchive retries for an invoice issued without an archive", async () => {
|
||||||
|
const { deps } = fakes();
|
||||||
|
const out = await renderAndArchive(invoice({ archivedPdfSha256: null, archivedFingerprint: null }), deps);
|
||||||
|
expect(out.archived).toBe(true);
|
||||||
|
expect(out.invoice.archivedPdfSha256).toBe("b".repeat(64));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportInvoice", () => {
|
||||||
|
it("original: exports the archived bytes exactly and never renders", async () => {
|
||||||
|
const { deps, written } = fakes();
|
||||||
|
const r = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, deps);
|
||||||
|
expect(deps.render).not.toHaveBeenCalled();
|
||||||
|
expect(deps.readArchive).toHaveBeenCalledWith(7);
|
||||||
|
expect(text(written[0].bytes)).toBe("ARCHIVED");
|
||||||
|
expect(r).toMatchObject({ mode: "searchable", path: "/home/u/Documents/INV-2026-007_Acme Learning_2026-09-15.pdf" });
|
||||||
|
expect(r?.identicalToIssued).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rerender: renders now (not the archive) and reports whether the layout matches the issued one", async () => {
|
||||||
|
const same = fakes({ render: vi.fn(async () => ({ bytes: bytes("NEW"), fingerprint: "fp-issued" })) });
|
||||||
|
const a = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, same.deps);
|
||||||
|
expect(same.deps.readArchive).not.toHaveBeenCalled();
|
||||||
|
expect(text(same.written[0].bytes)).toBe("NEW");
|
||||||
|
expect(a?.identicalToIssued).toBe(true);
|
||||||
|
|
||||||
|
const diff = fakes();
|
||||||
|
const b = await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, diff.deps);
|
||||||
|
expect(b?.identicalToIssued).toBe(false);
|
||||||
|
// The archive is never touched by a re-render.
|
||||||
|
expect(diff.deps.archivePdf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rerender of an invoice with nothing archived has nothing to compare with", async () => {
|
||||||
|
const { deps } = fakes();
|
||||||
|
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null, archivedFingerprint: null }), mode: "searchable", source: "rerender" }, deps);
|
||||||
|
expect(r && "identicalToIssued" in r).toBe(false);
|
||||||
|
expect(deps.archivePdf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("not archived: renders, archives, then exports that render and returns the healed invoice", async () => {
|
||||||
|
const { deps, calls, written } = fakes();
|
||||||
|
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null, archivedFingerprint: null }), mode: "searchable", source: "original" }, deps);
|
||||||
|
expect(calls).toEqual(["render", "archive", "save", "write"]);
|
||||||
|
expect(text(written[0].bytes)).toBe("RENDERED");
|
||||||
|
expect(r?.invoice.archivedPdfSha256).toBe("b".repeat(64));
|
||||||
|
expect(r?.archiveError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("not archived and the archive fails: still exports the render and says so", async () => {
|
||||||
|
const { deps, written } = fakes({ archivePdf: vi.fn(async () => Promise.reject(new Error("read-only"))) });
|
||||||
|
const r = await exportInvoice({ invoice: invoice({ archivedPdfSha256: null }), mode: "searchable", source: "original" }, deps);
|
||||||
|
expect(text(written[0].bytes)).toBe("RENDERED");
|
||||||
|
expect(r?.archiveError).toContain("read-only");
|
||||||
|
expect(r?.invoice.archivedPdfSha256).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattened: flattens the chosen source, assembles with the invoice number as title, names the file -flat", async () => {
|
||||||
|
const { deps, written } = fakes();
|
||||||
|
const progress: ExportProgress[] = [];
|
||||||
|
const r = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original", onProgress: (p) => progress.push(p) }, deps);
|
||||||
|
expect((deps.flatten as ReturnType<typeof vi.fn>).mock.calls[0][0]).toEqual(bytes("ARCHIVED"));
|
||||||
|
expect(text(written[0].bytes)).toBe("FLAT[INV/2026-007|ARCHIVED-jpeg1,ARCHIVED-jpeg2]");
|
||||||
|
expect(r?.path.endsWith("-flat.pdf")).toBe(true);
|
||||||
|
expect(progress.map((p) => p.phase)).toEqual(["rendering", "flattening", "flattening", "assembling", "saving"]);
|
||||||
|
expect(progress[1]).toMatchObject({ page: 1, pages: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattened rerender flattens the fresh render", async () => {
|
||||||
|
const { deps, written } = fakes();
|
||||||
|
await exportInvoice({ invoice: invoice(), mode: "flattened", source: "rerender" }, deps);
|
||||||
|
expect(text(written[0].bytes)).toContain("RENDERED-jpeg1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a flatten failure surfaces as FLATTEN_FAILED: no searchable fallback, nothing saved", async () => {
|
||||||
|
const { deps, calls } = fakes({ flatten: vi.fn(async () => Promise.reject(new FlattenError(2, "out of memory"))) });
|
||||||
|
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(FlattenError);
|
||||||
|
expect(isFlattenFailure(err)).toBe(true);
|
||||||
|
expect(calls).not.toContain("save");
|
||||||
|
expect(calls).not.toContain("write");
|
||||||
|
expect(deps.flatten).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an assemble failure is also FLATTEN_FAILED", async () => {
|
||||||
|
const { deps } = fakes({ assemble: vi.fn(async () => Promise.reject(new Error("worker timed out"))) });
|
||||||
|
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(ExportError);
|
||||||
|
expect(err.code).toBe("FLATTEN_FAILED");
|
||||||
|
expect(err.message).toContain("worker timed out");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel: an aborted signal stops the flow with FlattenCancelled and saves nothing", async () => {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const { deps, calls } = fakes({
|
||||||
|
flatten: vi.fn(async (_b, o) => {
|
||||||
|
ctl.abort();
|
||||||
|
o.onProgress?.(1, 2);
|
||||||
|
return { pages: [] };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const err = await exportInvoice({ invoice: invoice(), mode: "flattened", source: "original", signal: ctl.signal }, deps).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(FlattenCancelled);
|
||||||
|
expect(calls).not.toContain("assemble");
|
||||||
|
expect(calls).not.toContain("save");
|
||||||
|
|
||||||
|
const pre = new AbortController();
|
||||||
|
pre.abort();
|
||||||
|
const f2 = fakes();
|
||||||
|
await expect(exportInvoice({ invoice: invoice(), mode: "searchable", source: "original", signal: pre.signal }, f2.deps)).rejects.toBeInstanceOf(FlattenCancelled);
|
||||||
|
expect(f2.deps.readArchive).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a FlattenCancelled thrown by flatten passes through unchanged", async () => {
|
||||||
|
const { deps } = fakes({ flatten: vi.fn(async () => Promise.reject(new FlattenCancelled())) });
|
||||||
|
await expect(exportInvoice({ invoice: invoice(), mode: "flattened", source: "original" }, deps)).rejects.toBeInstanceOf(FlattenCancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismissing the save dialog resolves null and writes nothing", async () => {
|
||||||
|
const { deps, calls } = fakes({ save: vi.fn(async () => null) });
|
||||||
|
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, deps)).toBeNull();
|
||||||
|
expect(calls).not.toContain("write");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the dialog in the last folder, handles Windows folders, and survives a missing folder", async () => {
|
||||||
|
const win = fakes({ getLastExportDir: vi.fn(async () => "C:\\Users\\Sample\\Documents") });
|
||||||
|
await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, win.deps);
|
||||||
|
expect((win.deps.save as ReturnType<typeof vi.fn>).mock.calls[0][0].defaultPath).toBe("C:\\Users\\Sample\\Documents\\INV-2026-007_Acme Learning_2026-09-15.pdf");
|
||||||
|
|
||||||
|
const none = fakes({ getLastExportDir: vi.fn(async () => Promise.reject(new Error("no dir"))) });
|
||||||
|
await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, none.deps);
|
||||||
|
expect((none.deps.save as ReturnType<typeof vi.fn>).mock.calls[0][0].defaultPath).toBe("INV-2026-007_Acme Learning_2026-09-15.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a write failure is WRITE_FAILED; a render failure on re-render is RENDER_FAILED", async () => {
|
||||||
|
const w = fakes({ writeExportFile: vi.fn(async () => Promise.reject("permission denied")) });
|
||||||
|
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "original" }, w.deps).catch((e) => e)).toMatchObject({ code: "WRITE_FAILED" });
|
||||||
|
const r = fakes({ render: vi.fn(async () => Promise.reject(new Error("fonts"))) });
|
||||||
|
expect(await exportInvoice({ invoice: invoice(), mode: "searchable", source: "rerender" }, r.deps).catch((e) => e)).toMatchObject({ code: "RENDER_FAILED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { FlattenCancelled } from "../pdf/flatten";
|
||||||
|
import type { FlattenResult } from "../pdf/flatten";
|
||||||
|
import type { AssemblePageData } from "../pdf/render/jobs";
|
||||||
|
import { exportFileName, type ExportMode } from "./filename";
|
||||||
|
import type { Invoice, InvoiceInput } from "./types";
|
||||||
|
|
||||||
|
export type { ExportMode };
|
||||||
|
export type ExportSource = "original" | "rerender";
|
||||||
|
|
||||||
|
/** Everything the flow touches outside itself, so it can run against fakes. */
|
||||||
|
export interface ExportDeps {
|
||||||
|
issueInvoice(input: InvoiceInput, renderPrefs: object): Promise<Invoice>;
|
||||||
|
/** Renders with the invoice's frozen prefs and supplier snapshot and the current template code. */
|
||||||
|
render(invoice: Invoice): Promise<{ bytes: Uint8Array; fingerprint: string }>;
|
||||||
|
archivePdf(invoiceId: number, bytes: Uint8Array, fingerprint: string): Promise<string>;
|
||||||
|
readArchive(invoiceId: number): Promise<Uint8Array>;
|
||||||
|
/** Rasterises PDF bytes page by page. Throws FlattenError / FlattenCancelled. */
|
||||||
|
flatten(bytes: Uint8Array, opts: { signal?: AbortSignal; onProgress?: (page: number, pages: number) => void }): Promise<FlattenResult>;
|
||||||
|
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
|
||||||
|
getLastExportDir(): Promise<string>;
|
||||||
|
/** The save dialog: the chosen path, or null when it is dismissed. */
|
||||||
|
save(opts: { defaultPath: string }): Promise<string | null>;
|
||||||
|
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportPhase = "rendering" | "flattening" | "assembling" | "saving";
|
||||||
|
|
||||||
|
export interface ExportProgress {
|
||||||
|
phase: ExportPhase;
|
||||||
|
/** While flattening: the page being rendered and the page count. */
|
||||||
|
page?: number;
|
||||||
|
pages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExportErrorCode = "RENDER_FAILED" | "ARCHIVE_FAILED" | "FLATTEN_FAILED" | "WRITE_FAILED";
|
||||||
|
|
||||||
|
export class ExportError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: ExportErrorCode,
|
||||||
|
message: string,
|
||||||
|
readonly cause?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ExportError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||||
|
|
||||||
|
export const isFlattenFailure = (e: unknown): boolean => (e as { code?: unknown } | null)?.code === "FLATTEN_FAILED";
|
||||||
|
export const isExportCancelled = (e: unknown): boolean => e instanceof FlattenCancelled || (e as { name?: string } | null)?.name === "FlattenCancelled";
|
||||||
|
|
||||||
|
const checkAbort = (signal?: AbortSignal) => {
|
||||||
|
if (signal?.aborted) throw new FlattenCancelled();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- archive at issue ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ArchiveOutcome {
|
||||||
|
/** The invoice with its archive fields filled in when archiving worked. */
|
||||||
|
invoice: Invoice;
|
||||||
|
archived: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields the backend sets when it archives, mirrored locally so the UI needs no refetch. */
|
||||||
|
const withArchive = (invoice: Invoice, sha256: string, fingerprint: string): Invoice => ({
|
||||||
|
...invoice,
|
||||||
|
archivedPdfSha256: sha256,
|
||||||
|
archivedFingerprint: fingerprint,
|
||||||
|
archivedAt: invoice.archivedAt ?? new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an issued invoice with its frozen prefs and archives the result. Never throws: a failure
|
||||||
|
* comes back as `archived: false` with the reason, because the invoice already exists.
|
||||||
|
*/
|
||||||
|
export async function renderAndArchive(invoice: Invoice, deps: Pick<ExportDeps, "render" | "archivePdf">): Promise<ArchiveOutcome> {
|
||||||
|
let rendered: { bytes: Uint8Array; fingerprint: string };
|
||||||
|
try {
|
||||||
|
rendered = await deps.render(invoice);
|
||||||
|
} catch (e) {
|
||||||
|
return { invoice, archived: false, error: `The PDF could not be rendered: ${messageOf(e)}` };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
|
||||||
|
return { invoice: withArchive(invoice, sha, rendered.fingerprint), archived: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { invoice, archived: false, error: `The PDF could not be archived: ${messageOf(e)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues the invoice, then renders it with the prefs the backend froze and archives that PDF. The
|
||||||
|
* issue call's own failure throws (nothing was issued); everything after it is reported, never thrown.
|
||||||
|
*/
|
||||||
|
export async function issueAndArchive(
|
||||||
|
input: InvoiceInput,
|
||||||
|
renderPrefs: object,
|
||||||
|
deps: Pick<ExportDeps, "issueInvoice" | "render" | "archivePdf">,
|
||||||
|
): Promise<ArchiveOutcome> {
|
||||||
|
const invoice = await deps.issueInvoice(input, renderPrefs);
|
||||||
|
return renderAndArchive(invoice, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- export ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ExportRequest {
|
||||||
|
invoice: Invoice;
|
||||||
|
mode: ExportMode;
|
||||||
|
source: ExportSource;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (p: ExportProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportResult {
|
||||||
|
path: string;
|
||||||
|
mode: ExportMode;
|
||||||
|
/** Only for a re-render of an archived invoice: did the layout come out the same as at issue? */
|
||||||
|
identicalToIssued?: boolean;
|
||||||
|
/** The invoice with archive fields filled in when this export had to archive it first. */
|
||||||
|
invoice: Invoice;
|
||||||
|
/** The export used a fresh render because archiving the missing original failed. */
|
||||||
|
archiveError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(dir: string, name: string): string {
|
||||||
|
if (!dir) return name;
|
||||||
|
const sep = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
|
||||||
|
return /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${sep}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces the PDF bytes for one export and saves them. Returns null when the save dialog is
|
||||||
|
* dismissed. Errors: FlattenCancelled when aborted; an ExportError (or a FlattenError, which carries
|
||||||
|
* the same `FLATTEN_FAILED` code) otherwise. A failed flatten is never replaced by a searchable export
|
||||||
|
* or a lower resolution; the caller offers that choice.
|
||||||
|
*/
|
||||||
|
export async function exportInvoice(req: ExportRequest, deps: ExportDeps): Promise<ExportResult | null> {
|
||||||
|
const { mode, source, signal, onProgress } = req;
|
||||||
|
let invoice = req.invoice;
|
||||||
|
checkAbort(signal);
|
||||||
|
|
||||||
|
let bytes: Uint8Array;
|
||||||
|
let identicalToIssued: boolean | undefined;
|
||||||
|
let archiveError: string | undefined;
|
||||||
|
|
||||||
|
if (source === "rerender") {
|
||||||
|
onProgress?.({ phase: "rendering" });
|
||||||
|
const rendered = await renderOrThrow(invoice, deps);
|
||||||
|
bytes = rendered.bytes;
|
||||||
|
if (invoice.archivedPdfSha256 && invoice.archivedFingerprint) {
|
||||||
|
identicalToIssued = rendered.fingerprint === invoice.archivedFingerprint;
|
||||||
|
}
|
||||||
|
} else if (invoice.archivedPdfSha256) {
|
||||||
|
onProgress?.({ phase: "rendering" });
|
||||||
|
bytes = await deps.readArchive(invoice.id);
|
||||||
|
} else {
|
||||||
|
// Issued but never archived (the render failed at issue): do it now, then use that file.
|
||||||
|
onProgress?.({ phase: "rendering" });
|
||||||
|
const rendered = await renderOrThrow(invoice, deps);
|
||||||
|
bytes = rendered.bytes;
|
||||||
|
try {
|
||||||
|
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
|
||||||
|
invoice = withArchive(invoice, sha, rendered.fingerprint);
|
||||||
|
} catch (e) {
|
||||||
|
// The invoice is intact; export the fresh render and let the caller say the archive is still missing.
|
||||||
|
archiveError = `The PDF could not be archived: ${messageOf(e)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkAbort(signal);
|
||||||
|
|
||||||
|
if (mode === "flattened") {
|
||||||
|
try {
|
||||||
|
const flat = await deps.flatten(bytes, {
|
||||||
|
signal,
|
||||||
|
onProgress: (page, pages) => onProgress?.({ phase: "flattening", page, pages }),
|
||||||
|
});
|
||||||
|
checkAbort(signal);
|
||||||
|
onProgress?.({ phase: "assembling" });
|
||||||
|
// The title is the invoice number only; anything more would leak what the image-only file hides.
|
||||||
|
bytes = await deps.assemble(
|
||||||
|
flat.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })),
|
||||||
|
invoice.number,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (isExportCancelled(e) || isFlattenFailure(e)) throw e;
|
||||||
|
throw new ExportError("FLATTEN_FAILED", `The flattened PDF could not be built: ${messageOf(e)}`, e);
|
||||||
|
}
|
||||||
|
checkAbort(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await deps.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const name = exportFileName({ number: invoice.number, client: invoice.clientName, date: invoice.invoiceDate, mode });
|
||||||
|
onProgress?.({ phase: "saving" });
|
||||||
|
const chosen = await deps.save({ defaultPath: joinPath(dir, name) });
|
||||||
|
if (!chosen) return null;
|
||||||
|
|
||||||
|
let path: string;
|
||||||
|
try {
|
||||||
|
path = (await deps.writeExportFile(chosen, bytes)) || chosen;
|
||||||
|
} catch (e) {
|
||||||
|
throw new ExportError("WRITE_FAILED", `The file could not be saved: ${messageOf(e)}`, e);
|
||||||
|
}
|
||||||
|
return { path, mode, invoice, ...(identicalToIssued === undefined ? {} : { identicalToIssued }), ...(archiveError ? { archiveError } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderOrThrow(invoice: Invoice, deps: Pick<ExportDeps, "render">) {
|
||||||
|
try {
|
||||||
|
return await deps.render(invoice);
|
||||||
|
} catch (e) {
|
||||||
|
throw new ExportError("RENDER_FAILED", `The PDF could not be rendered: ${messageOf(e)}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ExportMode } from "./filename";
|
||||||
|
|
||||||
|
const KEY = "voiced.exportMode";
|
||||||
|
|
||||||
|
/** The last export mode this viewer used; searchable until one is chosen. A per-viewer convenience only. */
|
||||||
|
export function loadExportMode(): ExportMode {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(KEY);
|
||||||
|
if (v === "searchable" || v === "flattened") return v;
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked or empty; the default is fine.
|
||||||
|
}
|
||||||
|
return "searchable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveExportMode(mode: ExportMode): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, mode);
|
||||||
|
} catch {
|
||||||
|
// Not remembering is harmless.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPORT_MODE_LABEL: Record<ExportMode, string> = {
|
||||||
|
searchable: "Searchable PDF (copy-pasteable text)",
|
||||||
|
flattened: "Flattened PDF (image only)",
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { exportFileName, type ExportFileNameInput } from "./filename";
|
||||||
|
|
||||||
|
const base: ExportFileNameInput = {
|
||||||
|
number: "INV-2026-001",
|
||||||
|
client: "Acme Ltd",
|
||||||
|
date: "2026-04-01",
|
||||||
|
mode: "searchable",
|
||||||
|
};
|
||||||
|
const name = (over: Partial<ExportFileNameInput> = {}) => exportFileName({ ...base, ...over });
|
||||||
|
|
||||||
|
describe("exportFileName", () => {
|
||||||
|
it("uses number_client_date by default", () => {
|
||||||
|
expect(name()).toBe("INV-2026-001_Acme Ltd_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns a slash in the invoice number into a dash", () => {
|
||||||
|
expect(name({ number: "INV/2026-001" })).toBe("INV-2026-001_Acme Ltd_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds -flat for the flattened export", () => {
|
||||||
|
expect(name({ mode: "flattened" })).toBe("INV-2026-001_Acme Ltd_2026-04-01-flat.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours a custom pattern", () => {
|
||||||
|
expect(name({ pattern: "{date} {client} {number}" })).toBe("2026-04-01 Acme Ltd INV-2026-001.pdf");
|
||||||
|
expect(name({ pattern: "{number}" })).toBe("INV-2026-001.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default pattern for a blank pattern", () => {
|
||||||
|
expect(name({ pattern: " " })).toBe(name());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces every Windows-forbidden character", () => {
|
||||||
|
expect(name({ client: 'A<B>C:D"E/F\\G|H?I*J' })).toBe("INV-2026-001_A-B-C-D-E-F-G-H-I-J_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces control characters", () => {
|
||||||
|
expect(name({ client: "A\u0000B\tC\nD\u007fE" })).toBe("INV-2026-001_A-B-C-D-E_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses runs of separators and spaces", () => {
|
||||||
|
expect(name({ client: "A///B C___D" })).toBe("INV-2026-001_A-B C_D_2026-04-01.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims dots and spaces from the ends", () => {
|
||||||
|
expect(name({ pattern: " {client}. . " })).toBe("Acme Ltd.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "Acme Ltd. " })).toBe("Acme Ltd.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "...hidden" })).toBe("hidden.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the separators left behind by empty parts", () => {
|
||||||
|
expect(name({ client: "" })).toBe("INV-2026-001_2026-04-01.pdf");
|
||||||
|
expect(name({ client: "", date: "" })).toBe("INV-2026-001.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes reserved device names", () => {
|
||||||
|
expect(name({ pattern: "{client}", client: "CON" })).toBe("_CON.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "nul" })).toBe("_nul.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "Aux" })).toBe("_Aux.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "PRN" })).toBe("_PRN.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes COM1-9 and LPT1-9 but not COM10 or lookalikes", () => {
|
||||||
|
expect(name({ pattern: "{client}", client: "COM1" })).toBe("_COM1.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "com9" })).toBe("_com9.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "LPT3" })).toBe("_LPT3.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "COM10" })).toBe("COM10.pdf");
|
||||||
|
expect(name({ pattern: "{client}", client: "CONSOLE" })).toBe("CONSOLE.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a reserved name that has an extension-like tail", () => {
|
||||||
|
expect(name({ pattern: "{client}", client: "CON.txt" })).toBe("_CON.txt.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Indic client names intact", () => {
|
||||||
|
expect(name({ client: "சென்னை ஸ்டூடியோ" })).toBe(
|
||||||
|
"INV-2026-001_சென்னை ஸ்டூடியோ_2026-04-01.pdf",
|
||||||
|
);
|
||||||
|
expect(name({ client: "ਭਾਰਤ/ਕੰਪਨੀ" })).toContain("ਭਾਰਤ-ਕੰਪਨੀ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises Unicode to NFC", () => {
|
||||||
|
const decomposed = "Café";
|
||||||
|
expect(name({ pattern: "{client}", client: decomposed })).toBe("Café.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the stem at 120 characters and cuts the client first", () => {
|
||||||
|
const out = name({ client: "x".repeat(300) });
|
||||||
|
const stem = out.slice(0, -".pdf".length);
|
||||||
|
expect(stem.length).toBeLessThanOrEqual(120);
|
||||||
|
expect(out.startsWith("INV-2026-001_x")).toBe(true);
|
||||||
|
expect(out.endsWith("_2026-04-01.pdf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the number whole even when it alone is long", () => {
|
||||||
|
const number = "N".repeat(110);
|
||||||
|
const out = name({ number, client: "y".repeat(200) });
|
||||||
|
expect(out.startsWith(number)).toBe(true);
|
||||||
|
expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-caps an absurdly long number", () => {
|
||||||
|
const out = name({ number: "9".repeat(500) });
|
||||||
|
expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not split a surrogate pair when truncating", () => {
|
||||||
|
const out = name({ pattern: "{client}", client: "\u{1F600}".repeat(100) });
|
||||||
|
const stem = out.slice(0, -".pdf".length);
|
||||||
|
expect(stem.length).toBeLessThanOrEqual(120);
|
||||||
|
expect([...stem].every((ch) => ch === "\u{1F600}")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds -flat after truncation", () => {
|
||||||
|
const out = name({ client: "z".repeat(300), mode: "flattened" });
|
||||||
|
expect(out.endsWith("_2026-04-01-flat.pdf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a non-empty fallback", () => {
|
||||||
|
expect(name({ number: "", client: "", date: "" })).toBe("invoice.pdf");
|
||||||
|
expect(name({ pattern: "...", number: "x" })).toBe("invoice.pdf");
|
||||||
|
expect(name({ number: "///", client: "***", date: "|" })).toBe("invoice.pdf");
|
||||||
|
expect(name({ number: "", client: "", date: "", mode: "flattened" })).toBe("invoice-flat.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the result never ends in a dot or space", () => {
|
||||||
|
const out = name({ client: "Trailing dot.", date: "" });
|
||||||
|
expect(out).not.toMatch(/[. ]\.pdf$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export type ExportMode = "searchable" | "flattened";
|
||||||
|
|
||||||
|
export interface ExportFileNameInput {
|
||||||
|
/** Tokens: {number}, {client}, {date}. */
|
||||||
|
pattern?: string;
|
||||||
|
number: string;
|
||||||
|
client: string;
|
||||||
|
date: string;
|
||||||
|
mode: ExportMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PATTERN = "{number}_{client}_{date}";
|
||||||
|
const MAX_STEM = 120;
|
||||||
|
const FALLBACK = "invoice";
|
||||||
|
|
||||||
|
// Characters Windows forbids in file names, plus control characters.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
|
||||||
|
// The device name matters even with an extension ("CON.txt" is still the console).
|
||||||
|
const RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i;
|
||||||
|
const EDGE_JUNK = /^[.\s_-]+|[.\s_-]+$/g;
|
||||||
|
|
||||||
|
function collapse(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(FORBIDDEN, "-")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/_{2,}/g, "_")
|
||||||
|
.replace(/ ?([-_]) ?/g, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPart(value: string): string {
|
||||||
|
return collapse(value.normalize("NFC")).replace(EDGE_JUNK, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cut to at most `max` UTF-16 units without splitting a surrogate pair. */
|
||||||
|
function truncate(value: string, max: number): string {
|
||||||
|
let out = "";
|
||||||
|
for (const ch of value) {
|
||||||
|
if (out.length + ch.length > max) break;
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(pattern: string, parts: { number: string; client: string; date: string }): string {
|
||||||
|
return pattern.replace(/\{(number|client|date)\}/g, (_, key: keyof typeof parts) => parts[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file name that is valid on Windows, macOS and Linux for an exported invoice PDF. */
|
||||||
|
export function exportFileName(input: ExportFileNameInput): string {
|
||||||
|
const pattern = input.pattern?.trim() ? input.pattern : DEFAULT_PATTERN;
|
||||||
|
const parts = {
|
||||||
|
number: cleanPart(input.number),
|
||||||
|
client: cleanPart(input.client),
|
||||||
|
date: cleanPart(input.date),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stem = fill(pattern, parts);
|
||||||
|
if (stem.length > MAX_STEM) {
|
||||||
|
// The invoice number is what identifies the file, so the client name gives way first.
|
||||||
|
const overflow = stem.length - MAX_STEM;
|
||||||
|
const client = truncate(parts.client, Math.max(0, parts.client.length - overflow));
|
||||||
|
stem = fill(pattern, { ...parts, client });
|
||||||
|
}
|
||||||
|
stem = cleanPart(truncate(cleanPart(stem), MAX_STEM));
|
||||||
|
if (!stem) stem = FALLBACK;
|
||||||
|
if (RESERVED.test(stem.split(".")[0])) stem = `_${stem}`;
|
||||||
|
|
||||||
|
return `${stem}${input.mode === "flattened" ? "-flat" : ""}.pdf`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeLineAmount, computeTotals } from "./invoice";
|
||||||
|
import { EMPTY_ITEM, type InvoiceItem } from "./types";
|
||||||
|
|
||||||
|
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
|
||||||
|
// and the stored totals stay identical.
|
||||||
|
|
||||||
|
const fixed = (amount: number): InvoiceItem => ({ ...EMPTY_ITEM, mode: "fixed", amount });
|
||||||
|
const rate = (r: number, quantity: number): InvoiceItem => ({
|
||||||
|
...EMPTY_ITEM,
|
||||||
|
mode: "rate",
|
||||||
|
rate: r,
|
||||||
|
quantity,
|
||||||
|
unit: "hour",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeTotals", () => {
|
||||||
|
it("splits 18% on 7310.00 into 657.90 CGST and SGST", () => {
|
||||||
|
const t = computeTotals([fixed(7310)], 0, "cgst_sgst", 18);
|
||||||
|
expect(t.subtotal).toBe(7310);
|
||||||
|
expect(t.cgst).toBe(657.9);
|
||||||
|
expect(t.sgst).toBe(657.9);
|
||||||
|
expect(t.igst).toBe(0);
|
||||||
|
expect(t.total).toBe(8625.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds odd paise half up for IGST and per head for CGST/SGST", () => {
|
||||||
|
const igst = computeTotals([fixed(33.33)], 0, "igst", 5);
|
||||||
|
expect(igst.igst).toBe(1.67);
|
||||||
|
expect(igst.total).toBe(35);
|
||||||
|
const split = computeTotals([fixed(33.33)], 0, "cgst_sgst", 5);
|
||||||
|
expect([split.cgst, split.sgst]).toEqual([0.83, 0.83]);
|
||||||
|
expect(split.total).toBe(34.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles fractional quantities", () => {
|
||||||
|
const t = computeTotals([rate(1200, 1.5)], 0, "none", 0);
|
||||||
|
expect(t.subtotal).toBe(1800);
|
||||||
|
expect(t.total).toBe(1800);
|
||||||
|
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the discount to the subtotal", () => {
|
||||||
|
const items = [fixed(100), fixed(50.5)];
|
||||||
|
const over = computeTotals(items, 999.99, "igst", 18);
|
||||||
|
expect(over.discount).toBe(150.5);
|
||||||
|
expect(over.taxable).toBe(0);
|
||||||
|
expect(over.total).toBe(0);
|
||||||
|
expect(computeTotals(items, -5, "none", 0).discount).toBe(0);
|
||||||
|
expect(computeTotals(items, 50.5, "none", 0).taxable).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the rate when no tax applies", () => {
|
||||||
|
const t = computeTotals([fixed(1000)], 0, "none", 18);
|
||||||
|
expect(t.taxTotal).toBe(0);
|
||||||
|
expect(t.total).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
+32
-20
@@ -1,12 +1,23 @@
|
|||||||
import { round2 } from "./format";
|
|
||||||
import type { InvoiceItem, TaxType } from "./types";
|
import type { InvoiceItem, TaxType } from "./types";
|
||||||
|
|
||||||
|
// The preview must show exactly what Rust stores (src-tauri/src/gst.rs), so money is
|
||||||
|
// computed in integer paise with round-half-up, the same way.
|
||||||
|
|
||||||
|
const toPaise = (rupees: number) => Math.round((Number.isFinite(rupees) ? rupees : 0) * 100);
|
||||||
|
|
||||||
|
function linePaise(item: InvoiceItem): number {
|
||||||
|
if (item.mode === "rate") {
|
||||||
|
return Math.round(toPaise(item.rate || 0) * (item.quantity || 0));
|
||||||
|
}
|
||||||
|
return toPaise(item.amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round-half-up division for non-negative integers. */
|
||||||
|
const divRound = (num: number, den: number) => Math.floor((num + den / 2) / den);
|
||||||
|
|
||||||
/** Amount for a single line: a fixed total, or rate × quantity. */
|
/** Amount for a single line: a fixed total, or rate × quantity. */
|
||||||
export function computeLineAmount(item: InvoiceItem): number {
|
export function computeLineAmount(item: InvoiceItem): number {
|
||||||
if (item.mode === "rate") {
|
return linePaise(item) / 100;
|
||||||
return round2((item.rate || 0) * (item.quantity || 0));
|
|
||||||
}
|
|
||||||
return round2(item.amount || 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Totals {
|
export interface Totals {
|
||||||
@@ -26,29 +37,30 @@ export function computeTotals(
|
|||||||
taxType: TaxType,
|
taxType: TaxType,
|
||||||
taxRate: number,
|
taxRate: number,
|
||||||
): Totals {
|
): Totals {
|
||||||
const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0));
|
const subtotal = items.reduce((sum, item) => sum + linePaise(item), 0);
|
||||||
const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal));
|
const discountPaise = Math.min(Math.max(toPaise(discount), 0), Math.max(subtotal, 0));
|
||||||
const taxable = round2(subtotal - discountValue);
|
const taxable = subtotal - discountPaise;
|
||||||
|
const rateBp = Math.round((Number.isFinite(taxRate) ? taxRate : 0) * 100);
|
||||||
|
|
||||||
let cgst = 0;
|
let cgst = 0;
|
||||||
let sgst = 0;
|
let sgst = 0;
|
||||||
let igst = 0;
|
let igst = 0;
|
||||||
if (taxType === "cgst_sgst") {
|
if (taxType === "cgst_sgst") {
|
||||||
cgst = round2((taxable * (taxRate / 2)) / 100);
|
cgst = divRound(taxable * rateBp, 20_000);
|
||||||
sgst = round2((taxable * (taxRate / 2)) / 100);
|
sgst = cgst;
|
||||||
} else if (taxType === "igst") {
|
} else if (taxType === "igst") {
|
||||||
igst = round2((taxable * taxRate) / 100);
|
igst = divRound(taxable * rateBp, 10_000);
|
||||||
}
|
}
|
||||||
const taxTotal = round2(cgst + sgst + igst);
|
const taxTotal = cgst + sgst + igst;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subtotal,
|
subtotal: subtotal / 100,
|
||||||
discount: discountValue,
|
discount: discountPaise / 100,
|
||||||
taxable,
|
taxable: taxable / 100,
|
||||||
cgst,
|
cgst: cgst / 100,
|
||||||
sgst,
|
sgst: sgst / 100,
|
||||||
igst,
|
igst: igst / 100,
|
||||||
taxTotal,
|
taxTotal: taxTotal / 100,
|
||||||
total: round2(taxable + taxTotal),
|
total: (taxable + taxTotal) / 100,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-80
@@ -1,96 +1,72 @@
|
|||||||
import { pdf } from "@react-pdf/renderer";
|
import pkg from "../../package.json";
|
||||||
import { save } from "@tauri-apps/plugin-dialog";
|
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
import { registerPdfFonts } from "../pdf/fonts";
|
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
|
||||||
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
|
import { vendorFromSnapshot } from "../pdf/model/vendor";
|
||||||
|
import { Superseded } from "../pdf/render/client";
|
||||||
|
import type { RenderResult } from "../pdf/render/jobs";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
|
import { imageSizeFromDataUri } from "./imageSize";
|
||||||
|
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||||
import type { BankAccount, Invoice, Settings } from "./types";
|
import type { BankAccount, Invoice, Settings } from "./types";
|
||||||
|
|
||||||
function blobToBase64(blob: Blob): Promise<string> {
|
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
|
||||||
return new Promise((resolve, reject) => {
|
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const result = String(reader.result ?? "");
|
|
||||||
resolve(result.substring(result.indexOf(",") + 1));
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(reader.error);
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildPdfProps(
|
/**
|
||||||
|
* The render model for an invoice whose images are already loaded (the live preview caches them).
|
||||||
|
* Supplier and bank come from the snapshots frozen at issue; live settings are only a fallback.
|
||||||
|
*/
|
||||||
|
export function modelFromAssets(
|
||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
bank: BankAccount | null,
|
bank: BankAccount | null,
|
||||||
): Promise<InvoicePdfProps> {
|
logoUri: string | null,
|
||||||
const logoDataUri = settings.logoPath ? await api.readAssetDataUri(settings.logoPath) : null;
|
signatureDataUri: string | null,
|
||||||
const signatureDataUri = invoice.signaturePath
|
): RenderModel {
|
||||||
? await api.readAssetDataUri(invoice.signaturePath)
|
return buildRenderModel({
|
||||||
: null;
|
invoice,
|
||||||
|
|
||||||
let resolvedBank = bank;
|
|
||||||
if (!resolvedBank && invoice.bankSnapshot) {
|
|
||||||
try {
|
|
||||||
resolvedBank = JSON.parse(invoice.bankSnapshot) as BankAccount;
|
|
||||||
} catch {
|
|
||||||
resolvedBank = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
vendor: settings,
|
vendor: settings,
|
||||||
bank: resolvedBank,
|
bank,
|
||||||
logoDataUri,
|
logo: logoUri ? { dataUri: logoUri, ...(imageSizeFromDataUri(logoUri) ?? UNKNOWN_LOGO_SIZE) } : null,
|
||||||
signatureDataUri,
|
signatureDataUri,
|
||||||
number: invoice.number,
|
appVersion: pkg.version,
|
||||||
invoiceDate: invoice.invoiceDate,
|
|
||||||
dueDate: invoice.dueDate,
|
|
||||||
clientName: invoice.clientName,
|
|
||||||
clientAddress: invoice.clientAddress,
|
|
||||||
clientGstin: invoice.clientGstin,
|
|
||||||
poNumber: invoice.poNumber,
|
|
||||||
placeOfSupplyStateCode: invoice.placeOfSupplyStateCode,
|
|
||||||
items: invoice.items,
|
|
||||||
subtotal: invoice.subtotal,
|
|
||||||
discount: invoice.discount,
|
|
||||||
taxType: invoice.taxType,
|
|
||||||
taxRate: invoice.taxRate,
|
|
||||||
cgst: invoice.cgstAmount,
|
|
||||||
sgst: invoice.sgstAmount,
|
|
||||||
igst: invoice.igstAmount,
|
|
||||||
total: invoice.total,
|
|
||||||
amountInWords: invoice.amountInWords,
|
|
||||||
notes: invoice.notes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render props straight to a PDF blob. */
|
|
||||||
export async function renderPdfBlob(props: InvoicePdfProps): Promise<Blob> {
|
|
||||||
registerPdfFonts();
|
|
||||||
return pdf(<InvoiceDocument {...props} />).toBlob();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render a PDF and prompt the user for a save location. */
|
|
||||||
export async function exportPdfFromProps(
|
|
||||||
props: InvoicePdfProps,
|
|
||||||
fileName: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const blob = await renderPdfBlob(props);
|
|
||||||
const dataBase64 = await blobToBase64(blob);
|
|
||||||
const suggested = fileName.replace(/[^A-Za-z0-9._-]+/g, "-") || "invoice";
|
|
||||||
const path = await save({
|
|
||||||
defaultPath: `${suggested}.pdf`,
|
|
||||||
filters: [{ name: "PDF document", extensions: ["pdf"] }],
|
|
||||||
});
|
});
|
||||||
if (!path) return null;
|
|
||||||
await api.saveBinaryFile(path, dataBase64);
|
|
||||||
return path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportInvoicePdf(
|
/** Loads the invoice's logo and signature, then builds its render model. */
|
||||||
|
export async function buildModelForInvoice(invoice: Invoice, settings: Settings, bank: BankAccount | null): Promise<RenderModel> {
|
||||||
|
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
|
||||||
|
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
|
||||||
|
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
|
||||||
|
return modelFromAssets(invoice, settings, bank, logoUri, signatureDataUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page setup for an invoice: what was frozen at issue, else the user's current default. */
|
||||||
|
export const prefsForInvoice = (invoice: Invoice, settings: Settings): RenderPrefsV1 =>
|
||||||
|
parsePrefs(invoice.renderPrefs || settings.renderPrefs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an issued invoice to PDF bytes through the shared render client (worker, or the main
|
||||||
|
* thread if the worker cannot start), with the layout audit issues and fingerprint.
|
||||||
|
*/
|
||||||
|
export async function renderInvoicePdf(
|
||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
bank: BankAccount | null,
|
bank: BankAccount | null,
|
||||||
): Promise<string | null> {
|
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
|
||||||
const props = await buildPdfProps(invoice, settings, bank);
|
): Promise<RenderResult> {
|
||||||
return exportPdfFromProps(props, invoice.number);
|
const model = await buildModelForInvoice(invoice, settings, bank);
|
||||||
|
return renderModelWithRetry(model, prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a ready model in the shared client. */
|
||||||
|
export async function renderModelWithRetry(model: RenderModel, prefs: RenderPrefsV1): Promise<RenderResult> {
|
||||||
|
// The live preview shares the client; if it takes the worker first this render is superseded, so go again.
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await getRenderClient().render({ id: nextRenderJobId(), model, prefs });
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof Superseded) || attempt >= 4) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { getDocument, GlobalWorkerOptions, type PDFDocumentProxy, type PDFPageProxy, type RenderTask } from "pdfjs-dist";
|
||||||
|
import { installStreamAsyncIterator } from "./streamPolyfill";
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||||
|
installStreamAsyncIterator();
|
||||||
|
|
||||||
|
export type { PDFDocumentProxy, PDFPageProxy };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens PDF bytes. The bytes are copied because pdf.js transfers the buffer to its worker, which
|
||||||
|
* would detach the caller's array. Glyphs are drawn as paths (`disableFontFace`) so the preview looks
|
||||||
|
* the same on WebKitGTK and WebView2. (pdf.js 6 dropped `isEvalSupported`; it no longer evaluates font code.)
|
||||||
|
*/
|
||||||
|
export function openPdf(bytes: Uint8Array): Promise<PDFDocumentProxy> {
|
||||||
|
return getDocument({ data: bytes.slice(), disableFontFace: true }).promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the document and its worker resources. */
|
||||||
|
export function closePdf(doc: PDFDocumentProxy): Promise<void> {
|
||||||
|
return doc.loadingTask.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = new WeakMap<object, RenderTask>();
|
||||||
|
|
||||||
|
/** Cancels whatever render is running for `slot` (a canvas, or any object the caller keys on). */
|
||||||
|
export function cancelPageRender(slot: object): void {
|
||||||
|
tasks.get(slot)?.cancel();
|
||||||
|
tasks.delete(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws `page` into `canvas` at `scale` device pixels per PDF point, sizing the backing store to
|
||||||
|
* match. Starting a render for a slot cancels its previous one; a cancelled render rejects with
|
||||||
|
* pdf.js's RenderingCancelledException (see isRenderCancelled).
|
||||||
|
*/
|
||||||
|
export async function renderPageToCanvas(page: PDFPageProxy, canvas: HTMLCanvasElement, scale: number, slot: object = canvas): Promise<void> {
|
||||||
|
cancelPageRender(slot);
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const task = page.render({ canvas, viewport });
|
||||||
|
tasks.set(slot, task);
|
||||||
|
try {
|
||||||
|
await task.promise;
|
||||||
|
} finally {
|
||||||
|
if (tasks.get(slot) === task) tasks.delete(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isRenderCancelled = (err: unknown): boolean => (err as { name?: string } | null)?.name === "RenderingCancelledException";
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "./types";
|
||||||
|
import { computeLineAmount, computeTotals } from "./invoice";
|
||||||
|
import { amountInWords } from "./numberToWords";
|
||||||
|
|
||||||
|
/** The form fields the preview reads. */
|
||||||
|
export interface PreviewForm {
|
||||||
|
invoiceDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
clientId: number | null;
|
||||||
|
clientName: string;
|
||||||
|
clientAddress: string;
|
||||||
|
clientGstin: string;
|
||||||
|
poNumber: string;
|
||||||
|
placeOfSupply: string;
|
||||||
|
discount: number;
|
||||||
|
taxRate: number;
|
||||||
|
reverseCharge: boolean;
|
||||||
|
signaturePath: string | null;
|
||||||
|
notes: string;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tax treatment Rust derives from the supplier state and the place of supply
|
||||||
|
* (src-tauri/src/gst.rs); the form shows and sends the same value.
|
||||||
|
*/
|
||||||
|
export function deriveTaxType(settings: Settings, placeOfSupply: string): TaxType {
|
||||||
|
if (settings.gstRegistration === "unregistered") return "none";
|
||||||
|
return !placeOfSupply || placeOfSupply === settings.vendorStateCode ? "cgst_sgst" : "igst";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors vendor_snapshot() in src-tauri/src/commands/invoice.rs. */
|
||||||
|
export function vendorSnapshotJson(settings: Settings): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
vendorName: settings.vendorName,
|
||||||
|
vendorAddress: settings.vendorAddress,
|
||||||
|
vendorEmail: settings.vendorEmail,
|
||||||
|
vendorPhone: settings.vendorPhone,
|
||||||
|
vendorPan: settings.vendorPan,
|
||||||
|
vendorGstin: settings.gstRegistration === "unregistered" ? "" : settings.vendorGstin.trim(),
|
||||||
|
vendorStateCode: settings.vendorStateCode,
|
||||||
|
logoPath: settings.logoPath,
|
||||||
|
signaturePath: settings.signaturePath,
|
||||||
|
gstRegistration: settings.gstRegistration,
|
||||||
|
signatoryName: settings.signatoryName,
|
||||||
|
signatoryDesignation: settings.signatoryDesignation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draft-status Invoice built from the live form, so the real renderer can draw it before anything
|
||||||
|
* is issued. Totals use the same paise rounding Rust stores. `number` is the next number (a peek),
|
||||||
|
* not yet consumed.
|
||||||
|
*/
|
||||||
|
export function formToPreviewInvoice(form: PreviewForm, number: string, settings: Settings, bank: BankAccount | null): Invoice {
|
||||||
|
const taxType = deriveTaxType(settings, form.placeOfSupply);
|
||||||
|
const registered = settings.gstRegistration !== "unregistered";
|
||||||
|
const totals = computeTotals(form.items, form.discount, taxType, form.taxRate);
|
||||||
|
const items = form.items.map((item, i) => ({ ...item, amount: computeLineAmount(item), sortOrder: i }));
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
number,
|
||||||
|
seriesId: null,
|
||||||
|
invoiceDate: form.invoiceDate,
|
||||||
|
dueDate: form.dueDate,
|
||||||
|
clientId: form.clientId,
|
||||||
|
clientName: form.clientName,
|
||||||
|
clientAddress: form.clientAddress,
|
||||||
|
clientGstin: form.clientGstin,
|
||||||
|
poNumber: form.poNumber,
|
||||||
|
placeOfSupplyStateCode: form.placeOfSupply,
|
||||||
|
subtotal: totals.subtotal,
|
||||||
|
discount: totals.discount,
|
||||||
|
taxType,
|
||||||
|
taxRate: form.taxRate,
|
||||||
|
cgstAmount: totals.cgst,
|
||||||
|
sgstAmount: totals.sgst,
|
||||||
|
igstAmount: totals.igst,
|
||||||
|
total: totals.total,
|
||||||
|
amountInWords: amountInWords(totals.total),
|
||||||
|
bankAccountId: bank?.id ?? null,
|
||||||
|
bankSnapshot: bank ? JSON.stringify(bank) : "",
|
||||||
|
signaturePath: form.signaturePath,
|
||||||
|
notes: form.notes,
|
||||||
|
status: "draft",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
docType: registered ? "tax_invoice" : "invoice",
|
||||||
|
reverseCharge: registered && form.reverseCharge,
|
||||||
|
vendorSnapshot: vendorSnapshotJson(settings),
|
||||||
|
snapshotOrigin: "issued",
|
||||||
|
cancelledAt: null,
|
||||||
|
cancelReason: "",
|
||||||
|
archivedPdfSha256: null,
|
||||||
|
renderPrefs: "",
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createAutoClient, type RenderClient } from "../pdf/render/client";
|
||||||
|
|
||||||
|
let client: RenderClient | null = null;
|
||||||
|
let lastId = 0;
|
||||||
|
|
||||||
|
/** The one render client (and so one worker) shared by the preview and export. */
|
||||||
|
export function getRenderClient(): RenderClient {
|
||||||
|
client ??= createAutoClient();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nextRenderJobId = (): number => ++lastId;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { CanvasLike } from "../pdf/flatten";
|
||||||
|
import { runAssembleJob } from "../pdf/render/assembleJob";
|
||||||
|
import { renderInvoicePdf } from "../pdf/render/invoice";
|
||||||
|
import { ensureSetup } from "../pdf/templates/harness";
|
||||||
|
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||||
|
import goldens from "../pdf/testing/goldens.json";
|
||||||
|
import { loadCanvas } from "../pdf/testing/rasterize";
|
||||||
|
import { selfTestFixtures } from "../pdf/testing/selfTestFixtures";
|
||||||
|
import { reportToText, runSelfTest, type SelfTestDeps, type SelfTestDoc } from "./selfTest";
|
||||||
|
|
||||||
|
const canvasLib = await loadCanvas();
|
||||||
|
if (!canvasLib) console.warn("[selfTest.test] @napi-rs/canvas is not installed: skipping the self-test run under Node");
|
||||||
|
|
||||||
|
function nodeDeps(over: Partial<SelfTestDeps> = {}): SelfTestDeps {
|
||||||
|
ensureSetup();
|
||||||
|
return {
|
||||||
|
env: () => ({ userAgent: "node", isSecureContext: true, renderMode: "worker" }),
|
||||||
|
fixtures: selfTestFixtures(),
|
||||||
|
goldens: goldens as GoldenMap,
|
||||||
|
render: async (f) => {
|
||||||
|
const r = await renderInvoicePdf({ model: f.model, prefs: f.prefs });
|
||||||
|
return { bytes: r.bytes, pages: r.pages, issues: r.issues, fingerprint: r.fingerprint, ms: 1 };
|
||||||
|
},
|
||||||
|
openPdf: async (bytes) => {
|
||||||
|
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
|
||||||
|
const doc = await task.promise;
|
||||||
|
return Object.assign(doc, { close: () => task.destroy() }) as unknown as SelfTestDoc;
|
||||||
|
},
|
||||||
|
createCanvas: (w, h) => canvasLib!.createCanvas(w, h) as unknown as CanvasLike,
|
||||||
|
encodeJpeg: async (c, q) => new Uint8Array(await (c as unknown as { encode(f: "jpeg", q: number): Promise<Buffer> }).encode("jpeg", Math.round(q * 100))),
|
||||||
|
assemble: async (pages, title) => (await runAssembleJob({ id: 1, pages, title })).bytes,
|
||||||
|
startFrameMonitor: () => () => 0,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!canvasLib)("runSelfTest under Node", () => {
|
||||||
|
it("passes every check against the committed goldens", async () => {
|
||||||
|
const updates: number[] = [];
|
||||||
|
const report = await runSelfTest(nodeDeps(), (r) => updates.push(r.sections.length));
|
||||||
|
const failed = report.sections.flatMap((s) => s.checks.filter((c) => c.status === "fail").map((c) => `${s.title}: ${c.label}: ${c.detail}`));
|
||||||
|
expect(failed).toEqual([]);
|
||||||
|
expect(report.passed && report.done).toBe(true);
|
||||||
|
expect(updates.length).toBeGreaterThan(10);
|
||||||
|
const labels = report.sections.flatMap((s) => s.checks.map((c) => c.label));
|
||||||
|
for (const l of ["Page count", "Layout audit", "Rasterise page 1", "Text extraction", "Layout fingerprint matches the Node golden", "Longest main-thread stall", "Assemble the image-only PDF"]) {
|
||||||
|
expect(labels).toContain(l);
|
||||||
|
}
|
||||||
|
expect(reportToText(report)).toContain("RESULT: all checks passed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a fingerprint difference clearly, with both values", async () => {
|
||||||
|
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
|
||||||
|
const id = Object.keys(tampered)[0];
|
||||||
|
tampered[id].fingerprint = "0".repeat(64);
|
||||||
|
const report = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
|
||||||
|
const check = report.sections.flatMap((s) => s.checks).find((c) => c.label.startsWith("Layout fingerprint"))!;
|
||||||
|
expect(check.status).toBe("fail");
|
||||||
|
expect(check.detail).toContain("0".repeat(64));
|
||||||
|
expect(check.detail).toContain("this webview");
|
||||||
|
expect(report.passed).toBe(false);
|
||||||
|
expect(reportToText(report)).toContain("RESULT: FAILED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the text check when the page does not contain the expected strings, and a render error stops that fixture", async () => {
|
||||||
|
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
|
||||||
|
const id = Object.keys(tampered)[0];
|
||||||
|
tampered[id].totalText = "₹9,999,999.99";
|
||||||
|
const a = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
|
||||||
|
expect(a.sections.flatMap((s) => s.checks).find((c) => c.label === "Text extraction")).toMatchObject({ status: "fail" });
|
||||||
|
|
||||||
|
const b = await runSelfTest(nodeDeps({ render: async () => Promise.reject(new Error("worker could not start")), fixtures: selfTestFixtures().slice(0, 1) }));
|
||||||
|
const sec = b.sections.find((s) => s.title.startsWith("Classic, A4"))!;
|
||||||
|
expect(sec.checks.map((c) => c.status)).toEqual(["fail"]);
|
||||||
|
expect(sec.checks[0].detail).toContain("worker could not start");
|
||||||
|
expect(b.sections.some((s) => s.title.startsWith("300 DPI"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails the stall check when the longest frame gap exceeds the budget", async () => {
|
||||||
|
const report = await runSelfTest(nodeDeps({ startFrameMonitor: () => () => 900 }));
|
||||||
|
const stall = report.sections.flatMap((s) => s.checks).find((c) => c.label === "Longest main-thread stall")!;
|
||||||
|
expect(stall.status).toBe("fail");
|
||||||
|
expect(stall.detail).toContain("900 ms");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { flattenPages, type CanvasLike, type FlattenResult, type PageLike } from "../pdf/flatten";
|
||||||
|
import type { AssemblePageData } from "../pdf/render/jobs";
|
||||||
|
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||||
|
import type { SelfTestFixture } from "../pdf/testing/selfTestFixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Diagnostics self-test: renders fixtures through the app's real render client, rasterises and
|
||||||
|
* reads them back with pdf.js, compares with the Node goldens, and times a 300 DPI flatten. Everything
|
||||||
|
* environment-specific is injected, so the same checks run in the webview and under Node.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CheckStatus = "pass" | "fail" | "info";
|
||||||
|
|
||||||
|
export interface Check {
|
||||||
|
label: string;
|
||||||
|
status: CheckStatus;
|
||||||
|
detail?: string;
|
||||||
|
ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
title: string;
|
||||||
|
checks: Check[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestReport {
|
||||||
|
startedAt: string;
|
||||||
|
sections: Section[];
|
||||||
|
done: boolean;
|
||||||
|
/** No check failed (informational rows never fail). */
|
||||||
|
passed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestPage extends PageLike {
|
||||||
|
getTextContent(): Promise<{ items: Array<{ str?: string }> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestDoc {
|
||||||
|
numPages: number;
|
||||||
|
getPage(n: number): Promise<SelfTestPage>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestRender {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
pages: number;
|
||||||
|
issues: Array<{ severity: string; message?: string }>;
|
||||||
|
fingerprint: string;
|
||||||
|
ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfTestDeps {
|
||||||
|
env(): { userAgent: string; isSecureContext: boolean; renderMode: string };
|
||||||
|
fixtures: SelfTestFixture[];
|
||||||
|
goldens: GoldenMap;
|
||||||
|
/** Renders in the app's real client (the worker in the app). */
|
||||||
|
render(fixture: SelfTestFixture): Promise<SelfTestRender>;
|
||||||
|
openPdf(bytes: Uint8Array): Promise<SelfTestDoc>;
|
||||||
|
createCanvas(width: number, height: number): CanvasLike;
|
||||||
|
encodeJpeg(canvas: CanvasLike, quality: number): Promise<Uint8Array>;
|
||||||
|
/** Wraps JPEG pages into an image-only PDF through the real client. */
|
||||||
|
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
|
||||||
|
/** Starts watching frame gaps; the returned function stops it and gives the longest gap in ms. */
|
||||||
|
startFrameMonitor(): () => number;
|
||||||
|
yieldToUi?: () => Promise<void>;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest allowed main-thread stall while flattening (the plan's budget). */
|
||||||
|
export const MAX_FLATTEN_STALL_MS = 500;
|
||||||
|
/** A rendered invoice page is never blank: at least this share of pixels must carry ink. */
|
||||||
|
export const MIN_INK_RATIO = 0.003;
|
||||||
|
|
||||||
|
const squash = (s: string) => s.replace(/\s+/g, "");
|
||||||
|
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||||
|
const short = (fp: string) => (fp.length > 16 ? `${fp.slice(0, 8)}…${fp.slice(-6)}` : fp);
|
||||||
|
const mb = (n: number) => `${(n / 1048576).toFixed(2)} MB`;
|
||||||
|
|
||||||
|
/** Share of non-white pixels of page 1 drawn at 1 px per point. */
|
||||||
|
async function inkRatioOfFirstPage(doc: SelfTestDoc, deps: SelfTestDeps): Promise<number> {
|
||||||
|
const page = await doc.getPage(1);
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const w = Math.ceil(viewport.width);
|
||||||
|
const h = Math.ceil(viewport.height);
|
||||||
|
const canvas = deps.createCanvas(w, h);
|
||||||
|
try {
|
||||||
|
const ctx = canvas.getContext("2d") as {
|
||||||
|
fillStyle: unknown;
|
||||||
|
fillRect(x: number, y: number, w: number, h: number): void;
|
||||||
|
getImageData(x: number, y: number, w: number, h: number): { data: ArrayLike<number> };
|
||||||
|
};
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
await page.render({ canvasContext: ctx, canvas, viewport, intent: "print" }).promise;
|
||||||
|
const data = ctx.getImageData(0, 0, w, h).data;
|
||||||
|
let ink = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) if (data[i] < 250 || data[i + 1] < 250 || data[i + 2] < 250) ink++;
|
||||||
|
return ink / (w * h);
|
||||||
|
} finally {
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pageTexts(doc: SelfTestDoc): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let n = 1; n <= doc.numPages; n++) {
|
||||||
|
const content = await (await doc.getPage(n)).getTextContent();
|
||||||
|
out.push(content.items.map((i) => i.str ?? "").join(" "));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSelfTest(deps: SelfTestDeps, onUpdate?: (report: SelfTestReport) => void): Promise<SelfTestReport> {
|
||||||
|
const now = deps.now ?? (() => performance.now());
|
||||||
|
const report: SelfTestReport = { startedAt: new Date().toISOString(), sections: [], done: false, passed: true };
|
||||||
|
const emit = () => {
|
||||||
|
report.passed = report.sections.every((s) => s.checks.every((c) => c.status !== "fail"));
|
||||||
|
onUpdate?.({ ...report, sections: report.sections.map((s) => ({ ...s, checks: [...s.checks] })) });
|
||||||
|
};
|
||||||
|
const section = (title: string): Section => {
|
||||||
|
const s: Section = { title, checks: [] };
|
||||||
|
report.sections.push(s);
|
||||||
|
emit();
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const add = (s: Section, check: Check) => {
|
||||||
|
s.checks.push(check);
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
/** Runs a check body that returns a Check, turning a throw into a failure. */
|
||||||
|
const timed = async (s: Section, label: string, body: () => Promise<Omit<Check, "label">>) => {
|
||||||
|
const t0 = now();
|
||||||
|
try {
|
||||||
|
const r = await body();
|
||||||
|
add(s, { label, ms: Math.round(now() - t0), ...r });
|
||||||
|
} catch (e) {
|
||||||
|
add(s, { label, status: "fail", detail: errText(e), ms: Math.round(now() - t0) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const env = section("Webview");
|
||||||
|
const info = deps.env();
|
||||||
|
add(env, { label: "User agent", status: "info", detail: info.userAgent });
|
||||||
|
add(env, { label: "Secure context", status: "info", detail: String(info.isSecureContext) });
|
||||||
|
|
||||||
|
const rendered = new Map<string, SelfTestRender>();
|
||||||
|
for (const fixture of deps.fixtures) {
|
||||||
|
const sec = section(fixture.label);
|
||||||
|
const golden = deps.goldens[fixture.id];
|
||||||
|
let r: SelfTestRender | null = null;
|
||||||
|
await timed(sec, "Render in the app's client", async () => {
|
||||||
|
r = await deps.render(fixture);
|
||||||
|
rendered.set(fixture.id, r);
|
||||||
|
return { status: "pass", detail: `${r.pages} page${r.pages === 1 ? "" : "s"}, ${(r.bytes.byteLength / 1024).toFixed(0)} KB, ${r.ms} ms in the render client` };
|
||||||
|
});
|
||||||
|
if (!r) continue;
|
||||||
|
const res: SelfTestRender = r;
|
||||||
|
|
||||||
|
if (!golden) {
|
||||||
|
add(sec, { label: "Golden", status: "fail", detail: `goldens.json has no entry for ${fixture.id}; regenerate it with UPDATE_GOLDENS=1` });
|
||||||
|
} else {
|
||||||
|
add(sec, {
|
||||||
|
label: "Page count",
|
||||||
|
status: res.pages === golden.pages ? "pass" : "fail",
|
||||||
|
detail: res.pages === golden.pages ? `${res.pages}` : `expected ${golden.pages}, got ${res.pages}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = res.issues.filter((i) => i.severity === "error");
|
||||||
|
add(sec, {
|
||||||
|
label: "Layout audit",
|
||||||
|
status: errors.length === 0 ? "pass" : "fail",
|
||||||
|
detail: errors.length === 0 ? `no errors, ${res.issues.length} warning${res.issues.length === 1 ? "" : "s"}` : errors.slice(0, 3).map((e) => e.message ?? "error").join("; "),
|
||||||
|
});
|
||||||
|
|
||||||
|
let doc: SelfTestDoc | null = null;
|
||||||
|
try {
|
||||||
|
doc = await deps.openPdf(res.bytes);
|
||||||
|
} catch (e) {
|
||||||
|
add(sec, { label: "Open with pdf.js", status: "fail", detail: errText(e) });
|
||||||
|
}
|
||||||
|
if (doc) {
|
||||||
|
const opened = doc;
|
||||||
|
await timed(sec, "Rasterise page 1", async () => {
|
||||||
|
const ratio = await inkRatioOfFirstPage(opened, deps);
|
||||||
|
return { status: ratio >= MIN_INK_RATIO ? "pass" : "fail", detail: `${(ratio * 100).toFixed(1)}% of pixels carry ink${ratio >= MIN_INK_RATIO ? "" : " (blank page?)"}` };
|
||||||
|
});
|
||||||
|
await timed(sec, "Text extraction", async () => {
|
||||||
|
const texts = await pageTexts(opened);
|
||||||
|
const flat = squash(texts.join(" "));
|
||||||
|
const wanted = golden
|
||||||
|
? [fixture.invoiceNumber, golden.totalText, golden.pageLabel]
|
||||||
|
: [fixture.invoiceNumber, fixture.totalText, `Page 1 of ${res.pages}`];
|
||||||
|
const missing = wanted.filter((w) => !flat.includes(squash(w)));
|
||||||
|
return { status: missing.length === 0 ? "pass" : "fail", detail: missing.length === 0 ? `found ${wanted.join(", ")}` : `missing ${missing.map((m) => `"${m}"`).join(", ")}` };
|
||||||
|
});
|
||||||
|
await opened.close().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (golden) {
|
||||||
|
const same = res.fingerprint === golden.fingerprint;
|
||||||
|
add(sec, {
|
||||||
|
label: "Layout fingerprint matches the Node golden",
|
||||||
|
status: same ? "pass" : "fail",
|
||||||
|
detail: same
|
||||||
|
? short(res.fingerprint)
|
||||||
|
: `golden ${golden.fingerprint}\nthis webview ${res.fingerprint}\nThe layout differs from Node: text measuring or the JavaScript engine does not agree with the goldens.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten timing on the longest rendered fixture.
|
||||||
|
const multi = [...rendered.entries()].sort((a, b) => b[1].pages - a[1].pages)[0];
|
||||||
|
if (multi) {
|
||||||
|
const [id, src] = multi;
|
||||||
|
const fixture = deps.fixtures.find((f) => f.id === id)!;
|
||||||
|
const sec = section(`300 DPI flatten (${fixture.label})`);
|
||||||
|
const held: { flat: FlattenResult | null } = { flat: null };
|
||||||
|
const stop = deps.startFrameMonitor();
|
||||||
|
await timed(sec, `Flatten ${src.pages} page${src.pages === 1 ? "" : "s"}`, async () => {
|
||||||
|
const doc = await deps.openPdf(src.bytes);
|
||||||
|
let flat: FlattenResult;
|
||||||
|
try {
|
||||||
|
flat = await flattenPages({
|
||||||
|
pdf: doc as unknown as Parameters<typeof flattenPages>[0]["pdf"],
|
||||||
|
createCanvas: deps.createCanvas,
|
||||||
|
encodeJpeg: deps.encodeJpeg,
|
||||||
|
yieldToUi: deps.yieldToUi,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await doc.close().catch(() => {});
|
||||||
|
}
|
||||||
|
held.flat = flat;
|
||||||
|
const total = flat.pages.reduce((n, p) => n + p.jpeg.byteLength, 0);
|
||||||
|
return {
|
||||||
|
status: "pass",
|
||||||
|
detail: flat.pages.map((p, i) => `page ${i + 1}: render ${p.renderMs} ms + encode ${p.encodeMs} ms, ${p.pxWidth}x${p.pxHeight}, ${mb(p.jpeg.byteLength)}`).join("\n") + `\ntotal ${mb(total)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const longest = Math.round(stop());
|
||||||
|
const done = held.flat;
|
||||||
|
add(sec, {
|
||||||
|
label: "Longest main-thread stall",
|
||||||
|
status: !done ? "info" : longest <= MAX_FLATTEN_STALL_MS ? "pass" : "fail",
|
||||||
|
detail: !done ? "not measured" : `${longest} ms (budget ${MAX_FLATTEN_STALL_MS} ms)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
await timed(sec, "Assemble the image-only PDF", async () => {
|
||||||
|
const out = await deps.assemble(
|
||||||
|
done.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })),
|
||||||
|
fixture.invoiceNumber,
|
||||||
|
);
|
||||||
|
const doc = await deps.openPdf(out);
|
||||||
|
try {
|
||||||
|
const texts = await pageTexts(doc);
|
||||||
|
const textItems = texts.filter((t) => t.trim() !== "").length;
|
||||||
|
const ok = doc.numPages === src.pages && textItems === 0;
|
||||||
|
return { status: ok ? "pass" : "fail", detail: `${doc.numPages} page${doc.numPages === 1 ? "" : "s"}, ${textItems === 0 ? "no extractable text" : `${textItems} page(s) still have text`}, ${mb(out.byteLength)}` };
|
||||||
|
} finally {
|
||||||
|
await doc.close().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known only now: the client falls back to the main thread on its first failed start.
|
||||||
|
const renderMode = deps.env().renderMode;
|
||||||
|
add(env, { label: "Render client", status: "info", detail: renderMode === "worker" ? "worker" : "main thread (the worker could not start)" });
|
||||||
|
|
||||||
|
report.done = true;
|
||||||
|
emit();
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportToText(report: SelfTestReport): string {
|
||||||
|
const lines = [`Voiced diagnostics ${report.startedAt}`, report.done ? (report.passed ? "RESULT: all checks passed" : "RESULT: FAILED") : "RESULT: running", ""];
|
||||||
|
for (const s of report.sections) {
|
||||||
|
lines.push(`== ${s.title}`);
|
||||||
|
for (const c of s.checks) {
|
||||||
|
lines.push(`[${c.status.toUpperCase()}] ${c.label}${c.ms !== undefined ? ` (${c.ms} ms)` : ""}`);
|
||||||
|
if (c.detail) for (const l of c.detail.split("\n")) lines.push(` ${l}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import goldens from "../pdf/testing/goldens.json";
|
||||||
|
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||||
|
import { selfTestFixtures } from "../pdf/testing/selfTestFixtures";
|
||||||
|
import type { CanvasLike } from "../pdf/flatten";
|
||||||
|
import { encodeCanvasJpeg, yieldToBrowser } from "./browserFlatten";
|
||||||
|
import { renderModelWithRetry } from "./pdf";
|
||||||
|
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||||
|
import type { SelfTestDeps, SelfTestDoc } from "./selfTest";
|
||||||
|
|
||||||
|
/** The self-test wired to this webview: the real render client, pdf.js and DOM canvases. */
|
||||||
|
export function browserSelfTestDeps(): SelfTestDeps {
|
||||||
|
return {
|
||||||
|
env: () => ({
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
isSecureContext: window.isSecureContext,
|
||||||
|
renderMode: getRenderClient().mode,
|
||||||
|
}),
|
||||||
|
fixtures: selfTestFixtures(),
|
||||||
|
goldens: goldens as GoldenMap,
|
||||||
|
render: async (fixture) => {
|
||||||
|
const r = await renderModelWithRetry(fixture.model, fixture.prefs);
|
||||||
|
return { bytes: r.bytes, pages: r.pages, issues: r.issues, fingerprint: r.fingerprint, ms: r.ms };
|
||||||
|
},
|
||||||
|
openPdf: async (bytes) => {
|
||||||
|
const { openPdf, closePdf } = await import("./pdfjs");
|
||||||
|
const doc = await openPdf(bytes);
|
||||||
|
return Object.assign(doc, { close: () => closePdf(doc) }) as unknown as SelfTestDoc;
|
||||||
|
},
|
||||||
|
createCanvas: (width, height) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
return canvas as unknown as CanvasLike;
|
||||||
|
},
|
||||||
|
encodeJpeg: encodeCanvasJpeg,
|
||||||
|
assemble: async (pages, title) => (await getRenderClient().assemble({ id: nextRenderJobId(), pages, title })).bytes,
|
||||||
|
startFrameMonitor: () => {
|
||||||
|
let last = performance.now();
|
||||||
|
let longest = 0;
|
||||||
|
let running = true;
|
||||||
|
const tick = () => {
|
||||||
|
if (!running) return;
|
||||||
|
const t = performance.now();
|
||||||
|
longest = Math.max(longest, t - last);
|
||||||
|
last = t;
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
running = false;
|
||||||
|
// A frame that never arrived because the thread was still busy counts too.
|
||||||
|
return Math.max(longest, performance.now() - last);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
yieldToUi: yieldToBrowser,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { api } from "./api";
|
||||||
|
import { createExportDeps } from "./exportDeps";
|
||||||
|
import { exportInvoice, issueAndArchive } from "./exportFlow";
|
||||||
|
import { parsePrefs } from "../pdf/model/prefs";
|
||||||
|
import { bytesEqual, dirOf, type StepLog } from "./selfTestSteps";
|
||||||
|
import type { BankAccount, Invoice, InvoiceInput, Settings } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end-to-end invoice flow through the real IPC, run against the fresh database the self-test
|
||||||
|
* launches with (it seeds the unregistered vendor, a default bank and an active series).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const today = () => {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hex = (bytes: Uint8Array) => Array.from(bytes.slice(0, 5), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
const kb = (n: number) => `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
|
||||||
|
export async function runE2eSteps(log: StepLog, reportPath: string): Promise<void> {
|
||||||
|
const outDir = dirOf(reportPath);
|
||||||
|
const searchablePath = `${outDir}/e2e-searchable.pdf`;
|
||||||
|
const flattenedPath = `${outDir}/e2e-flattened.pdf`;
|
||||||
|
|
||||||
|
let s!: Settings;
|
||||||
|
let bank!: BankAccount;
|
||||||
|
let invoice: Invoice | null = null;
|
||||||
|
|
||||||
|
const loaded = await log.run("e2e: get settings and default bank", async () => {
|
||||||
|
s = await api.getSettings();
|
||||||
|
const banks = await api.listBanks();
|
||||||
|
const found = banks.find((b) => b.id === s.defaultBankId) ?? banks[0];
|
||||||
|
if (!found) throw new Error("the fresh database has no bank account");
|
||||||
|
bank = found;
|
||||||
|
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
|
||||||
|
});
|
||||||
|
if (!loaded.ok) return;
|
||||||
|
|
||||||
|
const prefs = parsePrefs(s.renderPrefs);
|
||||||
|
const input: InvoiceInput = {
|
||||||
|
invoiceDate: today(),
|
||||||
|
dueDate: "",
|
||||||
|
clientId: null,
|
||||||
|
clientName: "Self-test Client Pvt Ltd",
|
||||||
|
clientAddress: "42 Example Road, Indiranagar, Bengaluru - 560038",
|
||||||
|
clientGstin: "",
|
||||||
|
poNumber: "PO-SELFTEST",
|
||||||
|
placeOfSupplyStateCode: "",
|
||||||
|
discount: 0,
|
||||||
|
taxType: s.gstRegistration === "unregistered" ? "none" : s.defaultTaxType,
|
||||||
|
taxRate: s.gstRegistration === "unregistered" ? 0 : s.defaultTaxRate,
|
||||||
|
reverseCharge: false,
|
||||||
|
bankAccountId: bank.id,
|
||||||
|
signaturePath: null,
|
||||||
|
notes: "Self-test invoice",
|
||||||
|
saveClient: false,
|
||||||
|
draftId: null,
|
||||||
|
items: [
|
||||||
|
{ description: "Voice-over, 60 second spot", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 4500, sortOrder: 0 },
|
||||||
|
{ description: "Studio session", mode: "rate", rate: 1200, unit: "hour", quantity: 2, amount: 0, sortOrder: 1 },
|
||||||
|
{ description: "Revision round", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 850.5, sortOrder: 2 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = createExportDeps({ settings: s, bank });
|
||||||
|
// What the export wrote, per path, so the checks need no way to read arbitrary files back.
|
||||||
|
const written = new Map<string, Uint8Array>();
|
||||||
|
const exportWith = (mode: "searchable" | "flattened", source: "original" | "rerender", path: string) =>
|
||||||
|
exportInvoice(
|
||||||
|
{ invoice: inv, mode, source },
|
||||||
|
{
|
||||||
|
...deps,
|
||||||
|
save: async () => path,
|
||||||
|
writeExportFile: async (p, bytes) => {
|
||||||
|
written.set(p, bytes);
|
||||||
|
return deps.writeExportFile(p, bytes);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let inv!: Invoice;
|
||||||
|
const issued = await log.run("e2e: issue and archive", async () => {
|
||||||
|
const outcome = await issueAndArchive(input, prefs, deps);
|
||||||
|
invoice = outcome.invoice;
|
||||||
|
return {
|
||||||
|
ok: outcome.archived,
|
||||||
|
detail: `${outcome.invoice.number} (id ${outcome.invoice.id}), total ${outcome.invoice.total}, archived=${outcome.archived}${outcome.error ? `, error: ${outcome.error}` : ""}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!issued.ok || !invoice) return;
|
||||||
|
inv = invoice as Invoice;
|
||||||
|
|
||||||
|
await log.run("e2e: archive status", async () => {
|
||||||
|
const st = await api.archiveStatus(inv.id);
|
||||||
|
const matches = st.archived && st.sha256 === inv.archivedPdfSha256 && st.fingerprint === inv.archivedFingerprint;
|
||||||
|
return { ok: matches, detail: `archived=${st.archived}, sha ${st.sha256?.slice(0, 12)}, fingerprint ${st.fingerprint?.slice(0, 12)}, matches issue result: ${matches}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: export searchable original", async () => {
|
||||||
|
const r = await exportWith("searchable", "original", searchablePath);
|
||||||
|
return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: export flattened original", async () => {
|
||||||
|
const r = await exportWith("flattened", "original", flattenedPath);
|
||||||
|
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: export searchable re-render", async () => {
|
||||||
|
const r = await exportWith("searchable", "rerender", `${outDir}/e2e-rerender.pdf`);
|
||||||
|
return { ok: !!r && r.identicalToIssued !== undefined, detail: `identicalToIssued=${r?.identicalToIssued}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: archive bytes equal the searchable export", async () => {
|
||||||
|
const archived = await api.readArchive(inv.id);
|
||||||
|
const exported = written.get(searchablePath);
|
||||||
|
const eq = !!exported && bytesEqual(archived, exported);
|
||||||
|
return { ok: eq, detail: `${kb(archived.byteLength)} header ${hex(archived)}, equal to export file: ${eq}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: last export directory", async () => {
|
||||||
|
const dir = await api.getLastExportDir();
|
||||||
|
return { ok: dir === outDir, detail: `${dir} (expected ${outDir})` };
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: cancel keeps archive readable", async () => {
|
||||||
|
const before = await api.readArchive(inv.id);
|
||||||
|
const cancelled = await api.cancelInvoice(inv.id, "self-test");
|
||||||
|
const after = await api.readArchive(inv.id);
|
||||||
|
const st = await api.archiveStatus(inv.id);
|
||||||
|
return {
|
||||||
|
ok: cancelled.status === "cancelled" && st.archived && bytesEqual(before, after),
|
||||||
|
detail: `status ${cancelled.status}, archive still ${kb(after.byteLength)}, unchanged: ${bytesEqual(before, after)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await log.run("e2e: archiving different bytes is refused", async () => {
|
||||||
|
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
|
||||||
|
const before = await api.readArchive(inv.id);
|
||||||
|
try {
|
||||||
|
await api.archivePdf(inv.id, other, "tampered");
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e);
|
||||||
|
const after = await api.readArchive(inv.id);
|
||||||
|
const intact = bytesEqual(after, before);
|
||||||
|
return { ok: /cannot be replaced/.test(msg) && intact, detail: `refused: ${msg}; archive unchanged: ${intact}` };
|
||||||
|
}
|
||||||
|
return { ok: false, detail: "the replacement was accepted" };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { bytesEqual, createStepLog, dirOf } from "./selfTestSteps";
|
||||||
|
|
||||||
|
describe("createStepLog", () => {
|
||||||
|
it("records passes, expectation failures and throws without throwing itself", async () => {
|
||||||
|
let t = 0;
|
||||||
|
const log = createStepLog(() => (t += 10));
|
||||||
|
await log.run("a", async () => "fine");
|
||||||
|
await log.run("b", async () => ({ ok: false, detail: "wrong" }));
|
||||||
|
await log.run("c", async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
});
|
||||||
|
expect(log.steps).toEqual([
|
||||||
|
{ name: "a", ok: true, ms: 10, detail: "fine" },
|
||||||
|
{ name: "b", ok: false, ms: 10, detail: "wrong" },
|
||||||
|
{ name: "c", ok: false, ms: 10, detail: "Error: boom" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirOf / bytesEqual", () => {
|
||||||
|
it("splits posix and windows paths", () => {
|
||||||
|
expect(dirOf("/tmp/x/report.json")).toBe("/tmp/x");
|
||||||
|
expect(dirOf("C:\\out\\report.json")).toBe("C:\\out");
|
||||||
|
expect(dirOf("/report.json")).toBe("/");
|
||||||
|
expect(dirOf("report.json")).toBe("");
|
||||||
|
});
|
||||||
|
it("compares bytes", () => {
|
||||||
|
expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true);
|
||||||
|
expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false);
|
||||||
|
expect(bytesEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/** Step log of the unattended end-to-end self-test: one entry per step with its verdict and timing. */
|
||||||
|
|
||||||
|
export interface StepResult {
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
ms: number;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepLog {
|
||||||
|
steps: StepResult[];
|
||||||
|
/**
|
||||||
|
* Runs one step. The body returns its detail text, or throws to fail. A body may also return
|
||||||
|
* `{ ok: false, detail }` for a failed expectation. Never throws; resolves to the recorded result.
|
||||||
|
*/
|
||||||
|
run(name: string, body: () => Promise<string | { ok: boolean; detail: string }>): Promise<StepResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errText = (e: unknown) => (e instanceof Error ? `${e.name}: ${e.message}` : String(e));
|
||||||
|
|
||||||
|
export function createStepLog(now: () => number = () => performance.now()): StepLog {
|
||||||
|
const steps: StepResult[] = [];
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
async run(name, body) {
|
||||||
|
const t0 = now();
|
||||||
|
let result: StepResult;
|
||||||
|
try {
|
||||||
|
const r = await body();
|
||||||
|
const { ok, detail } = typeof r === "string" ? { ok: true, detail: r } : r;
|
||||||
|
result = { name, ok, ms: Math.round(now() - t0), detail };
|
||||||
|
} catch (e) {
|
||||||
|
result = { name, ok: false, ms: Math.round(now() - t0), detail: errText(e) };
|
||||||
|
}
|
||||||
|
steps.push(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The directory part of an absolute path, whichever separator it uses. */
|
||||||
|
export function dirOf(path: string): string {
|
||||||
|
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
||||||
|
return i <= 0 ? path.slice(0, i + 1) : path.slice(0, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.byteLength !== b.byteLength) return false;
|
||||||
|
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* WebKitGTK (2.50) lacks ReadableStream.prototype[Symbol.asyncIterator], which pdf.js uses in
|
||||||
|
* getTextContent. Installs a minimal version when it is missing; a no-op elsewhere.
|
||||||
|
*/
|
||||||
|
export function installStreamAsyncIterator(): void {
|
||||||
|
const proto = (globalThis as { ReadableStream?: { prototype: object } }).ReadableStream?.prototype as
|
||||||
|
| Record<symbol, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (!proto || Symbol.asyncIterator in proto) return;
|
||||||
|
proto[Symbol.asyncIterator] = function (this: ReadableStream<unknown>) {
|
||||||
|
const reader = this.getReader();
|
||||||
|
const iterator = {
|
||||||
|
next: () => reader.read(),
|
||||||
|
return: async () => {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
reader.releaseLock();
|
||||||
|
return { done: true as const, value: undefined };
|
||||||
|
},
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return iterator;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return iterator;
|
||||||
|
};
|
||||||
|
}
|
||||||
+68
-12
@@ -19,6 +19,14 @@ export interface Settings {
|
|||||||
currency: string;
|
currency: string;
|
||||||
onboarded: boolean;
|
onboarded: boolean;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
gstRegistration: GstRegistration;
|
||||||
|
defaultHsnSac: string;
|
||||||
|
signatoryName: string;
|
||||||
|
signatoryDesignation: string;
|
||||||
|
/** The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. */
|
||||||
|
renderPrefs: string;
|
||||||
|
/** Folder of the last export; kept by the backend (saving settings does not change it). */
|
||||||
|
lastExportDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BankAccount {
|
export interface BankAccount {
|
||||||
@@ -61,9 +69,41 @@ export interface InvoiceItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
/** Optional so older callers that build items by hand keep compiling; stored as "" when absent. */
|
||||||
|
hsnSac?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GstRegistration = "unregistered" | "regular";
|
||||||
|
|
||||||
|
/** What the webview sends to issue an invoice; totals, tax and words are derived in Rust. */
|
||||||
export interface InvoiceInput {
|
export interface InvoiceInput {
|
||||||
|
invoiceDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
clientId: number | null;
|
||||||
|
clientName: string;
|
||||||
|
clientAddress: string;
|
||||||
|
clientGstin: string;
|
||||||
|
poNumber: string;
|
||||||
|
placeOfSupplyStateCode: string;
|
||||||
|
discount: number;
|
||||||
|
taxType: TaxType;
|
||||||
|
taxRate: number;
|
||||||
|
reverseCharge: boolean;
|
||||||
|
bankAccountId: number | null;
|
||||||
|
signaturePath: string | null;
|
||||||
|
notes: string;
|
||||||
|
saveClient: boolean;
|
||||||
|
draftId: number | null;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceStatus = "draft" | "issued" | "cancelled";
|
||||||
|
|
||||||
|
/** An invoice as stored: all amounts are derived on the Rust side. */
|
||||||
|
export interface Invoice {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
seriesId: number | null;
|
||||||
invoiceDate: string;
|
invoiceDate: string;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
clientId: number | null;
|
clientId: number | null;
|
||||||
@@ -85,18 +125,30 @@ export interface InvoiceInput {
|
|||||||
bankSnapshot: string;
|
bankSnapshot: string;
|
||||||
signaturePath: string | null;
|
signaturePath: string | null;
|
||||||
notes: string;
|
notes: string;
|
||||||
saveClient: boolean;
|
status: InvoiceStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
docType: "invoice" | "tax_invoice";
|
||||||
|
reverseCharge: boolean;
|
||||||
|
/** JSON of the supplier details at issue time; empty when unknown. */
|
||||||
|
vendorSnapshot: string;
|
||||||
|
snapshotOrigin: string;
|
||||||
|
cancelledAt: string | null;
|
||||||
|
cancelReason: string;
|
||||||
|
archivedPdfSha256: string | null;
|
||||||
|
/** RenderPrefsV1 JSON frozen at issue; empty when none was stored. */
|
||||||
|
renderPrefs: string;
|
||||||
|
/** Layout fingerprint of the archived PDF; null when nothing is archived. */
|
||||||
|
archivedFingerprint?: string | null;
|
||||||
|
archivedAt?: string | null;
|
||||||
items: InvoiceItem[];
|
items: InvoiceItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Invoice extends Omit<InvoiceInput, "saveClient"> {
|
export interface DraftSummary {
|
||||||
id: number;
|
id: number;
|
||||||
number: string;
|
|
||||||
seriesId: number | null;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
items: InvoiceItem[];
|
clientName: string | null;
|
||||||
|
total: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceSummary {
|
export interface InvoiceSummary {
|
||||||
@@ -106,7 +158,7 @@ export interface InvoiceSummary {
|
|||||||
dueDate: string;
|
dueDate: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
total: number;
|
total: number;
|
||||||
status: string;
|
status: InvoiceStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +170,12 @@ export const EMPTY_ITEM: InvoiceItem = {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
|
hsnSac: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** GST state codes, same list as STATES in src-tauri/src/gst.rs (from India Compliance). */
|
||||||
export const INDIAN_STATES: { code: string; name: string }[] = [
|
export const INDIAN_STATES: { code: string; name: string }[] = [
|
||||||
{ code: "01", name: "Jammu & Kashmir" },
|
{ code: "01", name: "Jammu and Kashmir" },
|
||||||
{ code: "02", name: "Himachal Pradesh" },
|
{ code: "02", name: "Himachal Pradesh" },
|
||||||
{ code: "03", name: "Punjab" },
|
{ code: "03", name: "Punjab" },
|
||||||
{ code: "04", name: "Chandigarh" },
|
{ code: "04", name: "Chandigarh" },
|
||||||
@@ -145,18 +199,20 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
|
|||||||
{ code: "22", name: "Chhattisgarh" },
|
{ code: "22", name: "Chhattisgarh" },
|
||||||
{ code: "23", name: "Madhya Pradesh" },
|
{ code: "23", name: "Madhya Pradesh" },
|
||||||
{ code: "24", name: "Gujarat" },
|
{ code: "24", name: "Gujarat" },
|
||||||
{ code: "26", name: "Dadra & Nagar Haveli and Daman & Diu" },
|
{ code: "26", name: "Dadra and Nagar Haveli and Daman and Diu" },
|
||||||
{ code: "27", name: "Maharashtra" },
|
{ code: "27", name: "Maharashtra" },
|
||||||
{ code: "29", name: "Karnataka" },
|
{ code: "29", name: "Karnataka" },
|
||||||
{ code: "30", name: "Goa" },
|
{ code: "30", name: "Goa" },
|
||||||
{ code: "31", name: "Lakshadweep" },
|
{ code: "31", name: "Lakshadweep Islands" },
|
||||||
{ code: "32", name: "Kerala" },
|
{ code: "32", name: "Kerala" },
|
||||||
{ code: "33", name: "Tamil Nadu" },
|
{ code: "33", name: "Tamil Nadu" },
|
||||||
{ code: "34", name: "Puducherry" },
|
{ code: "34", name: "Puducherry" },
|
||||||
{ code: "35", name: "Andaman & Nicobar Islands" },
|
{ code: "35", name: "Andaman and Nicobar Islands" },
|
||||||
{ code: "36", name: "Telangana" },
|
{ code: "36", name: "Telangana" },
|
||||||
{ code: "37", name: "Andhra Pradesh" },
|
{ code: "37", name: "Andhra Pradesh" },
|
||||||
{ code: "38", name: "Ladakh" },
|
{ code: "38", name: "Ladakh" },
|
||||||
|
{ code: "96", name: "Other Countries" },
|
||||||
|
{ code: "97", name: "Other Territory" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const UNIT_LABELS: Record<LineUnit, string> = {
|
export const UNIT_LABELS: Record<LineUnit, string> = {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
|
||||||
|
|
||||||
|
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
|
||||||
|
describe("GSTIN checksum", () => {
|
||||||
|
it("accepts valid GSTINs", () => {
|
||||||
|
expect(gstinChecksumError("27AAPFU0939F1ZV")).toBeUndefined();
|
||||||
|
expect(gstinChecksumError("07AAGFF2194N1Z1")).toBeUndefined();
|
||||||
|
expect(gstinChecksumError("29ABCDE1234F1ZW")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a wrong check digit and bad shapes", () => {
|
||||||
|
expect(gstinChecksumError("29ABCDE1234F1Z5")).toMatch(/expected W/);
|
||||||
|
expect(gstinChecksumError("27AAPFU0939F1ZX")).toMatch(/check digit/);
|
||||||
|
expect(gstinChecksumError("27AAPFU0939F1Z")).toMatch(/15-character/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cross-checks the PAN and the state", () => {
|
||||||
|
expect(gstinFullError("27AAPFU0939F1ZV", "AAPFU0939F")).toBeUndefined();
|
||||||
|
expect(gstinFullError("27AAPFU0939F1ZV", "AAPFU0939G")).toMatch(/PAN/);
|
||||||
|
expect(gstinFullError("")).toBeUndefined();
|
||||||
|
expect(vendorGstinError("unregistered", "", "", "29")).toBeUndefined();
|
||||||
|
expect(vendorGstinError("regular", "", "", "27")).toMatch(/needs a GSTIN/);
|
||||||
|
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "29")).toMatch(/state code/);
|
||||||
|
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,50 @@ export function gstinError(value: string): string | undefined {
|
|||||||
return GSTIN_RE.test(v) ? undefined : "Enter a valid 15-character GSTIN";
|
return GSTIN_RE.test(v) ? undefined : "Enter a valid 15-character GSTIN";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GSTIN_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
|
||||||
|
/** Standard GSTIN mod-36 check digit. Returns an error message, or undefined when valid. */
|
||||||
|
export function gstinChecksumError(value: string): string | undefined {
|
||||||
|
const v = norm(value);
|
||||||
|
if (!GSTIN_RE.test(v)) return "Enter a valid 15-character GSTIN";
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const product = GSTIN_CHARS.indexOf(v[i]) * (i % 2 === 0 ? 1 : 2);
|
||||||
|
sum += Math.floor(product / 36) + (product % 36);
|
||||||
|
}
|
||||||
|
const expected = GSTIN_CHARS[(36 - (sum % 36)) % 36];
|
||||||
|
return v[14] === expected ? undefined : `GSTIN check digit looks wrong (expected ${expected})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full GSTIN check: shape, check digit and, when a PAN is given, that the GSTIN
|
||||||
|
* contains that PAN. Empty (or "NA") is valid; the field is optional.
|
||||||
|
*/
|
||||||
|
export function gstinFullError(value: string, pan = ""): string | undefined {
|
||||||
|
const v = norm(value);
|
||||||
|
if (!v || v === "NA") return undefined;
|
||||||
|
const base = gstinError(v) ?? gstinChecksumError(v);
|
||||||
|
if (base) return base;
|
||||||
|
if (pan.trim() && v.slice(2, 12) !== norm(pan)) return "GSTIN does not contain the PAN";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supplier GSTIN rules for a registered business; an unregistered one needs none. */
|
||||||
|
export function vendorGstinError(
|
||||||
|
registration: string,
|
||||||
|
gstin: string,
|
||||||
|
pan: string,
|
||||||
|
stateCode: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (registration === "unregistered") return undefined;
|
||||||
|
const g = norm(gstin);
|
||||||
|
if (!g) return "A registered business needs a GSTIN";
|
||||||
|
const base = gstinFullError(g, pan);
|
||||||
|
if (base) return base;
|
||||||
|
if (g.slice(0, 2) !== stateCode) return `GSTIN starts with ${g.slice(0, 2)}, but your state code is ${stateCode}`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function emailError(value: string): string | undefined {
|
export function emailError(value: string): string | undefined {
|
||||||
if (!value.trim()) return undefined;
|
if (!value.trim()) return undefined;
|
||||||
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
|
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import React from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles/carbon.scss";
|
import "./styles/carbon.scss";
|
||||||
|
import { maybeRunSelfTest } from "./selftestBoot";
|
||||||
|
|
||||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void maybeRunSelfTest();
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
import { createElement } from "react";
|
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
|
||||||
import { InvoiceDocument, type InvoicePdfProps } from "./InvoiceDocument";
|
|
||||||
import { registerPdfFonts } from "./fonts";
|
|
||||||
import { describeBox, findOverlaps, renderPdfText, type RenderedPdf, type TextBox } from "./testing/pdfText";
|
|
||||||
import type { BankAccount, InvoiceItem, Settings } from "../lib/types";
|
|
||||||
|
|
||||||
registerPdfFonts(path.resolve(process.cwd(), "public/fonts"));
|
|
||||||
|
|
||||||
/** Set PDF_OUT_DIR to also write the rendered PDFs there for visual review. */
|
|
||||||
const OUT_DIR = process.env.PDF_OUT_DIR ?? "";
|
|
||||||
|
|
||||||
const logoDataUri = `data:image/png;base64,${readFileSync(
|
|
||||||
path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"),
|
|
||||||
).toString("base64")}`;
|
|
||||||
|
|
||||||
const vendor: Settings = {
|
|
||||||
vendorName: "Test Vendor",
|
|
||||||
vendorAddress: "1 Test Road, Bangalore - 560001",
|
|
||||||
vendorEmail: "vendor@example.com",
|
|
||||||
vendorPhone: "9000000000",
|
|
||||||
vendorPan: "ABCDE1234F",
|
|
||||||
vendorGstin: "",
|
|
||||||
vendorStateCode: "29",
|
|
||||||
logoPath: null,
|
|
||||||
signaturePath: null,
|
|
||||||
defaultBankId: 1,
|
|
||||||
defaultTaxRate: 18,
|
|
||||||
defaultTaxType: "cgst_sgst",
|
|
||||||
paymentTermsDays: 30,
|
|
||||||
currency: "INR",
|
|
||||||
onboarded: true,
|
|
||||||
theme: "g10",
|
|
||||||
};
|
|
||||||
|
|
||||||
const bank: BankAccount = {
|
|
||||||
id: 1,
|
|
||||||
label: "Primary",
|
|
||||||
bankName: "State Bank of India",
|
|
||||||
accountName: "Test Vendor",
|
|
||||||
accountNo: "000000000000",
|
|
||||||
branch: "000001",
|
|
||||||
ifsc: "TEST0000001",
|
|
||||||
isDefault: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeItems = (n: number): InvoiceItem[] =>
|
|
||||||
Array.from({ length: n }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
|
|
||||||
mode: i % 2 === 0 ? "rate" : "fixed",
|
|
||||||
rate: 1200,
|
|
||||||
unit: "hour",
|
|
||||||
quantity: 1 + (i % 3),
|
|
||||||
amount: 2500 + i * 10,
|
|
||||||
sortOrder: i,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
||||||
const lineAmount = (it: InvoiceItem) => (it.mode === "rate" ? it.rate * it.quantity : it.amount);
|
|
||||||
|
|
||||||
function build(over: Partial<InvoicePdfProps> & { items: InvoiceItem[] }): InvoicePdfProps {
|
|
||||||
const subtotal = round2(over.items.reduce((s, it) => s + lineAmount(it), 0));
|
|
||||||
const discount = over.discount ?? 0;
|
|
||||||
const taxType = over.taxType ?? "cgst_sgst";
|
|
||||||
const taxRate = over.taxRate ?? 18;
|
|
||||||
const taxable = subtotal - discount;
|
|
||||||
const tax = taxType === "none" ? 0 : round2((taxable * taxRate) / 100);
|
|
||||||
const cgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
|
|
||||||
const sgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
|
|
||||||
const igst = taxType === "igst" ? tax : 0;
|
|
||||||
return {
|
|
||||||
vendor,
|
|
||||||
bank,
|
|
||||||
logoDataUri,
|
|
||||||
signatureDataUri: null,
|
|
||||||
number: "INV-0001",
|
|
||||||
invoiceDate: "2026-09-15",
|
|
||||||
dueDate: "2026-10-15",
|
|
||||||
clientName: "Acme Learning Pvt Ltd",
|
|
||||||
clientAddress: "42 MG Road\nBangalore 560001",
|
|
||||||
clientGstin: "29ABCDE1234F1Z5",
|
|
||||||
poNumber: "",
|
|
||||||
placeOfSupplyStateCode: "29",
|
|
||||||
subtotal,
|
|
||||||
discount,
|
|
||||||
taxType,
|
|
||||||
taxRate,
|
|
||||||
cgst,
|
|
||||||
sgst,
|
|
||||||
igst,
|
|
||||||
total: round2(taxable + cgst + sgst + igst),
|
|
||||||
amountInWords: "Rupees One Lakh Only",
|
|
||||||
notes: "",
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixtureA = () =>
|
|
||||||
build({
|
|
||||||
items: makeItems(3),
|
|
||||||
notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest.",
|
|
||||||
poNumber: "PO-2026-0042",
|
|
||||||
});
|
|
||||||
const fixtureB = () => build({ items: makeItems(60) });
|
|
||||||
const fixtureD = () =>
|
|
||||||
build({
|
|
||||||
items: makeItems(5),
|
|
||||||
logoDataUri: null,
|
|
||||||
signatureDataUri: null,
|
|
||||||
vendor: {
|
|
||||||
...vendor,
|
|
||||||
vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt",
|
|
||||||
vendorEmail: "vendor.name@examplestudios.co.in",
|
|
||||||
vendorAddress: "13-B type House, Test Road\nTest Town, Cooke Town Extension\nBangalore - 560005",
|
|
||||||
},
|
|
||||||
clientAddress: "42 MG Road, Ashok Nagar\nBangalore 560001",
|
|
||||||
taxType: "igst",
|
|
||||||
taxRate: 18,
|
|
||||||
discount: 500,
|
|
||||||
});
|
|
||||||
|
|
||||||
const render = (p: InvoicePdfProps) => renderPdfText(createElement(InvoiceDocument, p));
|
|
||||||
|
|
||||||
const save = (name: string, pdf: RenderedPdf) => {
|
|
||||||
if (!OUT_DIR) return;
|
|
||||||
mkdirSync(OUT_DIR, { recursive: true });
|
|
||||||
writeFileSync(path.join(OUT_DIR, name), pdf.bytes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MARGIN_L = 39.5;
|
|
||||||
const MARGIN_R = 555.8;
|
|
||||||
const find = (items: TextBox[], str: string) => items.find((t) => t.str.trim() === str);
|
|
||||||
/** Letter-spaced labels come out of pdf.js with spaces between glyph runs ("D E S C R I P TI O N"). */
|
|
||||||
const findSpaced = (items: TextBox[], str: string) =>
|
|
||||||
items.find((t) => t.str.replace(/\s+/g, "").toUpperCase() === str.toUpperCase());
|
|
||||||
const textOf = (items: TextBox[]) => items.map((t) => t.str).join(" ");
|
|
||||||
|
|
||||||
/** Assertions that apply to every page of every fixture. */
|
|
||||||
function checkCommon(pdf: RenderedPdf) {
|
|
||||||
const n = pdf.pages.length;
|
|
||||||
expect(pdf.raw).not.toContain("/BaseFont /Helvetica");
|
|
||||||
|
|
||||||
pdf.pages.forEach((page, idx) => {
|
|
||||||
const k = idx + 1;
|
|
||||||
const overlaps = findOverlaps(page).map(([a, b]) => `${describeBox(a)} x ${describeBox(b)}`);
|
|
||||||
expect(overlaps, `page ${k} overlaps`).toEqual([]);
|
|
||||||
|
|
||||||
for (const t of page.items) {
|
|
||||||
const isFooter = t.top > 780;
|
|
||||||
expect(t.x0, `page ${k} ${describeBox(t)} left`).toBeGreaterThanOrEqual(MARGIN_L);
|
|
||||||
expect(t.x1, `page ${k} ${describeBox(t)} right`).toBeLessThanOrEqual(MARGIN_R);
|
|
||||||
if (isFooter) expect(t.bottom, `page ${k} ${describeBox(t)} bottom`).toBeLessThanOrEqual(842);
|
|
||||||
else expect(t.top, `page ${k} ${describeBox(t)} top`).toBeGreaterThanOrEqual(MARGIN_L);
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = `Page ${k} of ${n}`;
|
|
||||||
const pageLabels = page.items.filter((t) => /^Page \d+ of \d+$/.test(t.str.trim()));
|
|
||||||
expect(pageLabels.map((t) => t.str.trim()), `page ${k} page label`).toEqual([label]);
|
|
||||||
expect(
|
|
||||||
page.items.filter((t) => t.str.includes("Generated with Voiced")).length,
|
|
||||||
`page ${k} footer`,
|
|
||||||
).toBe(1);
|
|
||||||
|
|
||||||
const text = textOf(page.items);
|
|
||||||
expect(text).not.toContain("¹");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkHeader(pdf: RenderedPdf, hasDue = true) {
|
|
||||||
const { items } = pdf.pages[0];
|
|
||||||
const metaStrs = ["Invoice No.", "Date", ...(hasDue ? ["Due Date"] : [])];
|
|
||||||
const metaIdx = items.filter((t) => t.top < 150 && (t.str.trim() === "INVOICE" || /TAX INVOICE/i.test(t.str)));
|
|
||||||
const title = metaIdx[0] ?? find(items, "INVOICE");
|
|
||||||
expect(title, "title").toBeDefined();
|
|
||||||
const meta: TextBox[] = [title as TextBox];
|
|
||||||
for (const s of metaStrs) {
|
|
||||||
const lab = find(items, s);
|
|
||||||
expect(lab, s).toBeDefined();
|
|
||||||
meta.push(lab as TextBox);
|
|
||||||
// value: item on the same baseline to the right of the label
|
|
||||||
const val = items.find((t) => t !== lab && Math.abs(t.bottom - (lab as TextBox).bottom) < 3 && t.x0 > (lab as TextBox).x1 - 0.5);
|
|
||||||
expect(val, `${s} value`).toBeDefined();
|
|
||||||
meta.push(val as TextBox);
|
|
||||||
}
|
|
||||||
for (const m of meta) expect(m.x0, describeBox(m)).toBeGreaterThanOrEqual(362.5);
|
|
||||||
const headerBottom = Math.max(...meta.map((m) => m.bottom));
|
|
||||||
for (const t of items) {
|
|
||||||
if (meta.includes(t) || t.top > headerBottom + 20) continue;
|
|
||||||
if (t.x0 < 339.5) expect(t.x1, `header left ${describeBox(t)}`).toBeLessThanOrEqual(339.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkRupee(pdf: RenderedPdf) {
|
|
||||||
const all = pdf.pages.flatMap((p) => p.items);
|
|
||||||
const total = all.filter((t) => t.str.includes("Subtotal") || t.str.trim() === "Total");
|
|
||||||
expect(total.length).toBeGreaterThan(0);
|
|
||||||
const amounts = all.filter((t) => /\d\.\d\d$/.test(t.str.trim()));
|
|
||||||
expect(amounts.length).toBeGreaterThan(0);
|
|
||||||
for (const a of amounts) expect(a.str, describeBox(a)).toContain("₹");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Table-structure checks for multi-page documents. */
|
|
||||||
function checkTable(pdf: RenderedPdf) {
|
|
||||||
let totalPage = -1;
|
|
||||||
let subtotalPage = -1;
|
|
||||||
pdf.pages.forEach((page, idx) => {
|
|
||||||
const k = idx + 1;
|
|
||||||
const head = findSpaced(page.items, "DESCRIPTION");
|
|
||||||
const isTotals = (t: TextBox) => {
|
|
||||||
// amounts at or below the totals block start
|
|
||||||
const sub = find(page.items, "Subtotal");
|
|
||||||
return !!sub && t.top >= sub.top - 1;
|
|
||||||
};
|
|
||||||
const rows = page.items.filter(
|
|
||||||
(t) => /^₹[\d,]+\.\d\d$/.test(t.str.trim()) && t.x0 > 440 && !isTotals(t) && (!head || t.top > head.top),
|
|
||||||
);
|
|
||||||
if (head) {
|
|
||||||
expect(rows.length, `page ${k} has header but no rows`).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
if (idx > 0 && rows.length > 0) expect(head, `page ${k} header repeats`).toBeDefined();
|
|
||||||
if (find(page.items, "Subtotal")) subtotalPage = k;
|
|
||||||
if (find(page.items, "Total")) totalPage = k;
|
|
||||||
});
|
|
||||||
expect(subtotalPage).toBeGreaterThan(0);
|
|
||||||
expect(totalPage, "Subtotal and Total on same page").toBe(subtotalPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("InvoiceDocument fixture A (3 items, stacked logo)", () => {
|
|
||||||
let pdf: RenderedPdf;
|
|
||||||
beforeAll(async () => {
|
|
||||||
pdf = await render(fixtureA());
|
|
||||||
save("new-A.pdf", pdf);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a single page", () => expect(pdf.pages.length).toBe(1));
|
|
||||||
it("has no overlaps, margin breaches, stray Helvetica, page labels or footer gaps", () => checkCommon(pdf));
|
|
||||||
it("keeps header columns apart", () => checkHeader(pdf));
|
|
||||||
it("prints rupee signs", () => checkRupee(pdf));
|
|
||||||
it("has table structure", () => checkTable(pdf));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("InvoiceDocument fixture B (60 items)", () => {
|
|
||||||
let pdf: RenderedPdf;
|
|
||||||
beforeAll(async () => {
|
|
||||||
pdf = await render(fixtureB());
|
|
||||||
save("new-B-60rows.pdf", pdf);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("spans multiple pages", () => expect(pdf.pages.length).toBeGreaterThan(1));
|
|
||||||
it("common checks", () => checkCommon(pdf));
|
|
||||||
it("header", () => checkHeader(pdf));
|
|
||||||
it("rupee", () => checkRupee(pdf));
|
|
||||||
it("table", () => checkTable(pdf));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("InvoiceDocument fixture C (1..45 items sweep)", () => {
|
|
||||||
it("never strands a header and always keeps Subtotal/Total together", async () => {
|
|
||||||
for (let n = 1; n <= 45; n++) {
|
|
||||||
const pdf = await render(build({ items: makeItems(n) }));
|
|
||||||
try {
|
|
||||||
checkCommon(pdf);
|
|
||||||
checkHeader(pdf);
|
|
||||||
checkRupee(pdf);
|
|
||||||
checkTable(pdf);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`items=${n} (${pdf.pages.length} pages): ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("InvoiceDocument fixture D (long vendor strings, no logo, IGST, discount)", () => {
|
|
||||||
let pdf: RenderedPdf;
|
|
||||||
beforeAll(async () => {
|
|
||||||
pdf = await render(fixtureD());
|
|
||||||
save("new-D.pdf", pdf);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("common checks", () => checkCommon(pdf));
|
|
||||||
it("header", () => checkHeader(pdf));
|
|
||||||
it("rupee", () => checkRupee(pdf));
|
|
||||||
it("table", () => checkTable(pdf));
|
|
||||||
it("shows IGST and discount", () => {
|
|
||||||
const text = textOf(pdf.pages.flatMap((p) => p.items));
|
|
||||||
expect(text).toContain("IGST");
|
|
||||||
expect(text).toContain("Discount");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
import { Document, Image, Page, StyleSheet, Text, View, type TextProps } from "@react-pdf/renderer";
|
|
||||||
import type { ComponentProps, PropsWithChildren } from "react";
|
|
||||||
import { UNIT_LABELS, type BankAccount, type InvoiceItem, type Settings, type TaxType } from "../lib/types";
|
|
||||||
import { computeLineAmount } from "../lib/invoice";
|
|
||||||
import { imageSizeFromDataUri, type ImageSize } from "../lib/imageSize";
|
|
||||||
|
|
||||||
const amountFmt = new Intl.NumberFormat("en-IN", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const money = (n: number) => `${"₹"}${amountFmt.format(Number.isFinite(n) ? n : 0)}`;
|
|
||||||
|
|
||||||
const dateFmt = (iso: string) => {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(`${iso}T00:00:00`);
|
|
||||||
if (Number.isNaN(d.getTime())) return iso;
|
|
||||||
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One entry per line, so no Text ever contains "\n" (react-pdf pulls in an unembedded Helvetica for it). */
|
|
||||||
const splitLines = (value: string): string[] =>
|
|
||||||
value
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
export interface InvoicePdfProps {
|
|
||||||
vendor: Settings;
|
|
||||||
bank: BankAccount | null;
|
|
||||||
logoDataUri: string | null;
|
|
||||||
signatureDataUri: string | null;
|
|
||||||
number: string;
|
|
||||||
invoiceDate: string;
|
|
||||||
dueDate: string;
|
|
||||||
clientName: string;
|
|
||||||
clientAddress: string;
|
|
||||||
clientGstin: string;
|
|
||||||
poNumber: string;
|
|
||||||
placeOfSupplyStateCode: string;
|
|
||||||
items: InvoiceItem[];
|
|
||||||
subtotal: number;
|
|
||||||
discount: number;
|
|
||||||
taxType: TaxType;
|
|
||||||
taxRate: number;
|
|
||||||
cgst: number;
|
|
||||||
sgst: number;
|
|
||||||
igst: number;
|
|
||||||
total: number;
|
|
||||||
amountInWords: string;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Layout rules (see the rendering notes in the project plan):
|
|
||||||
* - Every child of a flex row has an explicit width (width = minWidth = maxWidth), or is the single
|
|
||||||
* fill child with { flexGrow: 1, flexBasis: 0 }. react-pdf wraps text once, at the first measure;
|
|
||||||
* a box that shrinks afterwards keeps its stale lines and the text overflows into its neighbour.
|
|
||||||
* - No lineHeight on the Page or on any View. Every static Text sets fontSize and an absolute
|
|
||||||
* lineHeight ("Npt") on the same node. The render-prop footer Text has no lineHeight anywhere above it.
|
|
||||||
* - Never pin the height of a box that holds text; textkit truncates to the box height.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// A4 content box: 595.28 pt wide minus 2 x 40 pt page padding.
|
|
||||||
const CONTENT_W = 515;
|
|
||||||
const SUPPLIER_W = 299;
|
|
||||||
const HEADER_GAP = 24;
|
|
||||||
const META_W = CONTENT_W - SUPPLIER_W - HEADER_GAP;
|
|
||||||
const HALF_W = 245;
|
|
||||||
const LOGO_MAX_W = 170;
|
|
||||||
const LOGO_MAX_H = 54;
|
|
||||||
const LOGO_GAP = 14;
|
|
||||||
const MIN_TEXT_W = 160;
|
|
||||||
|
|
||||||
const pinned = (w: number) => ({ width: w, minWidth: w, maxWidth: w });
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
page: {
|
|
||||||
fontFamily: "IBM Plex Sans",
|
|
||||||
fontSize: 9.5,
|
|
||||||
color: "#161616",
|
|
||||||
paddingTop: 40,
|
|
||||||
paddingBottom: 48,
|
|
||||||
paddingHorizontal: 40,
|
|
||||||
},
|
|
||||||
body: { fontSize: 9.5, lineHeight: "13.8pt" },
|
|
||||||
header: { flexDirection: "row", alignItems: "flex-start" },
|
|
||||||
supplier: { ...pinned(SUPPLIER_W) },
|
|
||||||
supplierRow: { flexDirection: "row" },
|
|
||||||
supplierStacked: { flexDirection: "column" },
|
|
||||||
supplierTextRow: { flexGrow: 1, flexBasis: 0 },
|
|
||||||
supplierTextStacked: { ...pinned(SUPPLIER_W) },
|
|
||||||
vendorName: { fontSize: 18, lineHeight: "22pt", fontWeight: 600, marginBottom: 3 },
|
|
||||||
vendorLine: { color: "#393939" },
|
|
||||||
meta: { ...pinned(META_W), marginLeft: HEADER_GAP, textAlign: "right" },
|
|
||||||
// marginRight cancels the letterSpacing react-pdf adds after the last glyph, so the title's
|
|
||||||
// right edge lines up with the values below it.
|
|
||||||
title: {
|
|
||||||
fontSize: 18,
|
|
||||||
lineHeight: "22pt",
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: 1.5,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
marginBottom: 8,
|
|
||||||
marginRight: -1.5,
|
|
||||||
},
|
|
||||||
metaRow: { flexDirection: "row", alignItems: "flex-start" },
|
|
||||||
metaLabel: { ...pinned(66), textAlign: "left", color: "#525252" },
|
|
||||||
metaValue: { flexGrow: 1, flexBasis: 0, textAlign: "right", fontWeight: 600 },
|
|
||||||
divider: { borderBottomWidth: 1, borderBottomColor: "#e0e0e0", marginTop: 16, marginBottom: 16 },
|
|
||||||
split: { flexDirection: "row", gap: HEADER_GAP },
|
|
||||||
blockLabel: {
|
|
||||||
fontSize: 7.5,
|
|
||||||
lineHeight: "10pt",
|
|
||||||
letterSpacing: 1,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "#525252",
|
|
||||||
marginBottom: 3,
|
|
||||||
},
|
|
||||||
strong: { fontWeight: 600 },
|
|
||||||
half: { ...pinned(HALF_W) },
|
|
||||||
// No border or padding on the table container: with one, react-pdf can leave a page that holds
|
|
||||||
// only the repeated header row. The top rule belongs to the header row instead.
|
|
||||||
table: { marginTop: 22 },
|
|
||||||
tr: {
|
|
||||||
flexDirection: "row",
|
|
||||||
borderBottomWidth: 0.6,
|
|
||||||
borderBottomColor: "#e0e0e0",
|
|
||||||
paddingVertical: 6,
|
|
||||||
},
|
|
||||||
trHead: { borderTopWidth: 1.4, borderTopColor: "#161616", borderBottomColor: "#161616" },
|
|
||||||
th: {
|
|
||||||
fontSize: 7.5,
|
|
||||||
lineHeight: "10pt",
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: "#525252",
|
|
||||||
paddingBottom: 4,
|
|
||||||
},
|
|
||||||
colDesc: { flex: 5, paddingRight: 8 },
|
|
||||||
colRate: { flex: 2, textAlign: "right" },
|
|
||||||
colQty: { flex: 2, textAlign: "right" },
|
|
||||||
colTotal: { flex: 2, textAlign: "right" },
|
|
||||||
totalsWrap: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12 },
|
|
||||||
totals: { ...pinned(250) },
|
|
||||||
totalsRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2.5 },
|
|
||||||
grand: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
borderTopWidth: 1.4,
|
|
||||||
borderTopColor: "#161616",
|
|
||||||
marginTop: 4,
|
|
||||||
paddingTop: 6,
|
|
||||||
},
|
|
||||||
grandText: { fontSize: 13, lineHeight: "17pt", fontWeight: 600 },
|
|
||||||
words: { marginTop: 12, fontStyle: "italic" },
|
|
||||||
notes: { marginTop: 14 },
|
|
||||||
bottom: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginTop: 34 },
|
|
||||||
bank: { ...pinned(280) },
|
|
||||||
signature: { ...pinned(200), textAlign: "right" },
|
|
||||||
signatureImg: { maxHeight: 58, maxWidth: 190, objectFit: "contain", marginLeft: "auto" },
|
|
||||||
signatureName: {
|
|
||||||
marginTop: 4,
|
|
||||||
paddingTop: 3,
|
|
||||||
borderTopWidth: 0.8,
|
|
||||||
borderTopColor: "#8d8d8d",
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
fineprint: { fontSize: 7.5, lineHeight: "10.5pt", color: "#6f6f6f", textAlign: "right", marginTop: 6 },
|
|
||||||
// The footer holds the page-number render prop, so nothing in it sets a lineHeight and it has
|
|
||||||
// no fixed height: react-pdf multiplies a render-prop lineHeight again on every relayout, and a
|
|
||||||
// fixed-height box draws no text when the line is taller than the box.
|
|
||||||
footer: {
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 24,
|
|
||||||
left: 40,
|
|
||||||
right: 40,
|
|
||||||
flexDirection: "row",
|
|
||||||
borderTopWidth: 0.6,
|
|
||||||
borderTopColor: "#e0e0e0",
|
|
||||||
paddingTop: 6,
|
|
||||||
},
|
|
||||||
footerLeft: {
|
|
||||||
...pinned(170),
|
|
||||||
fontSize: 7.5,
|
|
||||||
color: "#6f6f6f",
|
|
||||||
maxLines: 1,
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
},
|
|
||||||
footerCenter: { flexGrow: 1, flexBasis: 0, fontSize: 7.5, color: "#6f6f6f", textAlign: "center" },
|
|
||||||
footerRight: { ...pinned(90), fontSize: 7.5, color: "#6f6f6f", textAlign: "right" },
|
|
||||||
});
|
|
||||||
|
|
||||||
type TxtProps = PropsWithChildren<TextProps>;
|
|
||||||
|
|
||||||
/** Text with the body size and an absolute line height; later styles override both together. */
|
|
||||||
function Txt({ style, ...rest }: TxtProps) {
|
|
||||||
const extra = Array.isArray(style) ? style : style ? [style] : [];
|
|
||||||
return <Text {...rest} style={[styles.body, ...extra]} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LogoPlan {
|
|
||||||
stacked: boolean;
|
|
||||||
style: ComponentProps<typeof Image>["style"];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Size the logo from its own aspect inside a 170 x 54 pt box. When it would leave the supplier
|
|
||||||
* text less than 160 pt, the logo goes above the text instead of beside it.
|
|
||||||
*/
|
|
||||||
function planLogo(size: ImageSize | null): LogoPlan {
|
|
||||||
if (!size) {
|
|
||||||
return {
|
|
||||||
stacked: true,
|
|
||||||
style: {
|
|
||||||
...pinned(LOGO_MAX_W),
|
|
||||||
height: LOGO_MAX_H,
|
|
||||||
objectFit: "contain",
|
|
||||||
objectPositionX: 0,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const aspect = size.width / size.height;
|
|
||||||
const h = Math.min(LOGO_MAX_H, LOGO_MAX_W / aspect);
|
|
||||||
const w = h * aspect;
|
|
||||||
const stacked = SUPPLIER_W - w - LOGO_GAP < MIN_TEXT_W;
|
|
||||||
return {
|
|
||||||
stacked,
|
|
||||||
style: {
|
|
||||||
...pinned(w),
|
|
||||||
height: h,
|
|
||||||
objectFit: "contain",
|
|
||||||
marginRight: stacked ? 0 : LOGO_GAP,
|
|
||||||
marginBottom: stacked ? 8 : 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InvoiceDocument(props: InvoicePdfProps) {
|
|
||||||
const {
|
|
||||||
vendor,
|
|
||||||
bank,
|
|
||||||
logoDataUri,
|
|
||||||
signatureDataUri,
|
|
||||||
number,
|
|
||||||
invoiceDate,
|
|
||||||
dueDate,
|
|
||||||
clientName,
|
|
||||||
clientAddress,
|
|
||||||
clientGstin,
|
|
||||||
poNumber,
|
|
||||||
placeOfSupplyStateCode,
|
|
||||||
items,
|
|
||||||
subtotal,
|
|
||||||
discount,
|
|
||||||
taxType,
|
|
||||||
taxRate,
|
|
||||||
cgst,
|
|
||||||
sgst,
|
|
||||||
igst,
|
|
||||||
total,
|
|
||||||
amountInWords,
|
|
||||||
notes,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const isTaxInvoice = taxType !== "none";
|
|
||||||
const logo = logoDataUri ? planLogo(imageSizeFromDataUri(logoDataUri)) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Document title={`${isTaxInvoice ? "Tax Invoice" : "Invoice"} ${number}`} author={vendor.vendorName}>
|
|
||||||
<Page size="A4" style={styles.page}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={[styles.supplier, logo?.stacked ? styles.supplierStacked : styles.supplierRow]}>
|
|
||||||
{logoDataUri && logo ? <Image style={logo.style} src={logoDataUri} /> : null}
|
|
||||||
<View style={logo?.stacked ? styles.supplierTextStacked : styles.supplierTextRow}>
|
|
||||||
<Txt style={styles.vendorName}>{vendor.vendorName || "Vendor"}</Txt>
|
|
||||||
{splitLines(vendor.vendorAddress).map((line, i) => (
|
|
||||||
<Txt key={`addr-${i}`} style={styles.vendorLine}>
|
|
||||||
{line}
|
|
||||||
</Txt>
|
|
||||||
))}
|
|
||||||
{vendor.vendorEmail ? <Txt style={styles.vendorLine}>{vendor.vendorEmail}</Txt> : null}
|
|
||||||
{vendor.vendorPhone ? <Txt style={styles.vendorLine}>{vendor.vendorPhone}</Txt> : null}
|
|
||||||
{vendor.vendorPan ? <Txt style={styles.vendorLine}>PAN: {vendor.vendorPan}</Txt> : null}
|
|
||||||
{vendor.vendorGstin ? <Txt style={styles.vendorLine}>GSTIN: {vendor.vendorGstin}</Txt> : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.meta}>
|
|
||||||
<Txt style={styles.title}>{isTaxInvoice ? "Tax Invoice" : "Invoice"}</Txt>
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<Txt style={styles.metaLabel}>Invoice No.</Txt>
|
|
||||||
<Txt style={styles.metaValue}>{number}</Txt>
|
|
||||||
</View>
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<Txt style={styles.metaLabel}>Date</Txt>
|
|
||||||
<Txt style={styles.metaValue}>{dateFmt(invoiceDate)}</Txt>
|
|
||||||
</View>
|
|
||||||
{dueDate ? (
|
|
||||||
<View style={styles.metaRow}>
|
|
||||||
<Txt style={styles.metaLabel}>Due Date</Txt>
|
|
||||||
<Txt style={styles.metaValue}>{dateFmt(dueDate)}</Txt>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.divider} />
|
|
||||||
|
|
||||||
<View style={styles.split}>
|
|
||||||
<View style={styles.half}>
|
|
||||||
<Txt style={styles.blockLabel}>Billed To</Txt>
|
|
||||||
<Txt style={styles.strong}>{clientName || "—"}</Txt>
|
|
||||||
{splitLines(clientAddress).map((line, i) => (
|
|
||||||
<Txt key={`client-${i}`}>{line}</Txt>
|
|
||||||
))}
|
|
||||||
{clientGstin ? <Txt>GSTIN: {clientGstin}</Txt> : null}
|
|
||||||
{placeOfSupplyStateCode ? <Txt>Place of Supply: {placeOfSupplyStateCode}</Txt> : null}
|
|
||||||
</View>
|
|
||||||
<View style={styles.half}>
|
|
||||||
{poNumber ? (
|
|
||||||
<>
|
|
||||||
<Txt style={styles.blockLabel}>PO Number</Txt>
|
|
||||||
<Txt style={styles.strong}>{poNumber}</Txt>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Zero-height guard: keeps the header row from being stranded at the foot of a page. */}
|
|
||||||
<View minPresenceAhead={48} />
|
|
||||||
<View style={styles.table}>
|
|
||||||
<View style={[styles.tr, styles.trHead]} fixed>
|
|
||||||
<Txt style={[styles.th, styles.colDesc]}>Description</Txt>
|
|
||||||
<Txt style={[styles.th, styles.colRate]}>Unit Price</Txt>
|
|
||||||
<Txt style={[styles.th, styles.colQty]}>Qty</Txt>
|
|
||||||
<Txt style={[styles.th, styles.colTotal]}>Total</Txt>
|
|
||||||
</View>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<View style={styles.tr} key={item.id ?? i} wrap={false}>
|
|
||||||
<View style={styles.colDesc}>
|
|
||||||
{(splitLines(item.description).length ? splitLines(item.description) : ["—"]).map(
|
|
||||||
(line, li) => (
|
|
||||||
<Txt key={`desc-${li}`}>{line}</Txt>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Txt style={styles.colRate}>
|
|
||||||
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
|
|
||||||
</Txt>
|
|
||||||
<Txt style={styles.colQty}>{item.mode === "rate" ? item.quantity : 1}</Txt>
|
|
||||||
<Txt style={styles.colTotal}>{money(computeLineAmount(item))}</Txt>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.totalsWrap} wrap={false}>
|
|
||||||
<View style={styles.totals}>
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>Subtotal</Txt>
|
|
||||||
<Txt>{money(subtotal)}</Txt>
|
|
||||||
</View>
|
|
||||||
{discount > 0 ? (
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>Discount</Txt>
|
|
||||||
<Txt>- {money(discount)}</Txt>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{taxType === "cgst_sgst" ? (
|
|
||||||
<>
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>CGST ({(taxRate / 2).toFixed(2)}%)</Txt>
|
|
||||||
<Txt>{money(cgst)}</Txt>
|
|
||||||
</View>
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>SGST ({(taxRate / 2).toFixed(2)}%)</Txt>
|
|
||||||
<Txt>{money(sgst)}</Txt>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{taxType === "igst" ? (
|
|
||||||
<View style={styles.totalsRow}>
|
|
||||||
<Txt>IGST ({taxRate.toFixed(2)}%)</Txt>
|
|
||||||
<Txt>{money(igst)}</Txt>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<View style={styles.grand}>
|
|
||||||
<Txt style={styles.grandText}>Total</Txt>
|
|
||||||
<Txt style={styles.grandText}>{money(total)}</Txt>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Txt style={styles.words} wrap={false}>
|
|
||||||
Amount in words: {amountInWords}
|
|
||||||
</Txt>
|
|
||||||
|
|
||||||
{notes ? (
|
|
||||||
<View style={styles.notes} wrap={false}>
|
|
||||||
<Txt style={styles.blockLabel}>Notes</Txt>
|
|
||||||
{splitLines(notes).map((line, i) => (
|
|
||||||
<Txt key={`note-${i}`}>{line}</Txt>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.bottom} wrap={false}>
|
|
||||||
<View style={styles.bank}>
|
|
||||||
{bank ? (
|
|
||||||
<>
|
|
||||||
<Txt style={styles.blockLabel}>Pay To</Txt>
|
|
||||||
<Txt style={styles.strong}>{bank.accountName || vendor.vendorName}</Txt>
|
|
||||||
<Txt>{bank.bankName}</Txt>
|
|
||||||
{bank.accountNo ? <Txt>Account No.: {bank.accountNo}</Txt> : null}
|
|
||||||
{bank.branch ? <Txt>Branch: {bank.branch}</Txt> : null}
|
|
||||||
{bank.ifsc ? <Txt>IFSC: {bank.ifsc}</Txt> : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.signature}>
|
|
||||||
{signatureDataUri ? (
|
|
||||||
<>
|
|
||||||
<Image style={styles.signatureImg} src={signatureDataUri} />
|
|
||||||
<Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
|
|
||||||
<Txt style={styles.fineprint}>Computer-generated invoice; no signature is required.</Txt>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.footer} fixed>
|
|
||||||
<Text style={styles.footerLeft}>{vendor.vendorName}</Text>
|
|
||||||
<Text style={styles.footerCenter}>{"Generated with Voiced — a Bongbetic product"}</Text>
|
|
||||||
<Text
|
|
||||||
style={styles.footerRight}
|
|
||||||
render={({ pageNumber, totalPages }) =>
|
|
||||||
totalPages ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Page>
|
|
||||||
</Document>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Page, View } from "@react-pdf/renderer";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { PageFrame } from "./engine/geometry";
|
||||||
|
import { chromeHeight } from "./engine/metrics";
|
||||||
|
import type { RenderModel } from "./model/build";
|
||||||
|
import { AvailableWidthProvider } from "./blocks/AvailableWidth";
|
||||||
|
import { PageChrome } from "./blocks/PageChrome";
|
||||||
|
|
||||||
|
/** Height the footer strip takes inside the bottom padding: the text line, a 6 pt gap and the rule. */
|
||||||
|
export const FOOTER_RESERVE = 24;
|
||||||
|
/** Room above the content on pages 2+ for the continuation band, inside the top margin. */
|
||||||
|
export const CONTINUATION_RESERVE = 18;
|
||||||
|
|
||||||
|
const CHROME_INK = "#6f6f6f";
|
||||||
|
const RULE = "#e0e0e0";
|
||||||
|
const FOOTER_LEFT_WIDTH = 170;
|
||||||
|
|
||||||
|
export interface InvoicePageProps {
|
||||||
|
frame: PageFrame;
|
||||||
|
model: RenderModel;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only `<Page>` in the app: size and padding come from the frame (no page-size or margin literal
|
||||||
|
* anywhere else), the flow is the template's, and the chrome (footer, continuation band) is added after
|
||||||
|
* it as fixed page-level boxes. No lineHeight here: it would reach the render-prop page-number text.
|
||||||
|
*/
|
||||||
|
export function InvoicePage({ frame, model, children }: InvoicePageProps) {
|
||||||
|
const { pad } = frame;
|
||||||
|
const marginBottom = pad.bottom - frame.footerReserve;
|
||||||
|
const ruleY = frame.page.height - marginBottom - chromeHeight("footer") - 6.6;
|
||||||
|
const bandLeft = `${model.docTitle} ${model.meta.number} · ${model.meta.date}`;
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
size={frame.page.size}
|
||||||
|
style={{ paddingTop: pad.top, paddingRight: pad.right, paddingBottom: pad.bottom, paddingLeft: pad.left, color: "#161616" }}
|
||||||
|
>
|
||||||
|
<AvailableWidthProvider width={frame.contentWidth}>{children}</AvailableWidthProvider>
|
||||||
|
<PageChrome
|
||||||
|
frame={frame}
|
||||||
|
footerLeft={model.supplier.name}
|
||||||
|
footerLeftWidth={FOOTER_LEFT_WIDTH}
|
||||||
|
footerCenter={model.footerText}
|
||||||
|
color={CHROME_INK}
|
||||||
|
band={{ left: bandLeft, right: "continued" }}
|
||||||
|
decor={[{ x: pad.left, y: ruleY, width: frame.contentWidth, height: 0.6, children: <View style={{ height: 0.6, backgroundColor: RULE }} /> }]}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Document } from "@react-pdf/renderer";
|
||||||
|
import type { PageFrame } from "./engine/geometry";
|
||||||
|
import type { RenderModel } from "./model/build";
|
||||||
|
import type { RenderPrefsV1 } from "./model/prefs";
|
||||||
|
import type { PlacedLogo, TemplateDefinition } from "./templates/contract";
|
||||||
|
import { InvoicePage } from "./InvoicePage";
|
||||||
|
|
||||||
|
export const PDF_PRODUCER = "Voiced / react-pdf 4.9.0";
|
||||||
|
|
||||||
|
export interface InvoicePdfDocProps {
|
||||||
|
model: RenderModel;
|
||||||
|
frame: PageFrame;
|
||||||
|
template: TemplateDefinition;
|
||||||
|
logo: PlacedLogo | null;
|
||||||
|
prefs: RenderPrefsV1;
|
||||||
|
/** Forwarded to the Document; renderCore reads the layout tree from it. */
|
||||||
|
onRender?: (props: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The invoice date at 00:00 UTC. Fixed dates make the bytes of one invoice deterministic. */
|
||||||
|
function documentDate(iso: string): Date {
|
||||||
|
const d = new Date(`${iso}T00:00:00Z`);
|
||||||
|
return Number.isNaN(d.getTime()) ? new Date(0) : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoicePdf({ model, frame, template, logo, prefs, onRender }: InvoicePdfDocProps) {
|
||||||
|
const Layout = template.Layout;
|
||||||
|
const date = documentDate(model.meta.isoDate);
|
||||||
|
return (
|
||||||
|
<Document
|
||||||
|
title={model.pdfTitle}
|
||||||
|
author={model.supplier.name}
|
||||||
|
subject={`${model.docTitle} ${model.meta.number} for ${model.client.name}`}
|
||||||
|
keywords={`${model.meta.number}, ${model.client.name}`}
|
||||||
|
creator={`Voiced ${model.appVersion} (${template.id} v${template.version})`}
|
||||||
|
producer={PDF_PRODUCER}
|
||||||
|
language="en-IN"
|
||||||
|
creationDate={date}
|
||||||
|
modificationDate={date}
|
||||||
|
onRender={onRender as never}
|
||||||
|
>
|
||||||
|
<InvoicePage frame={frame} model={model}>
|
||||||
|
<Layout model={model} frame={frame} logo={logo} prefs={prefs} />
|
||||||
|
</InvoicePage>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createElement as h, type ReactElement } from "react";
|
||||||
|
import { Document, Image, Page } from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
export interface AssemblePage {
|
||||||
|
/** A baseline JPEG; react-pdf embeds it as is (DCTDecode passthrough, no re-encoding). */
|
||||||
|
jpeg: Uint8Array;
|
||||||
|
widthPt: number;
|
||||||
|
heightPt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssembleInput {
|
||||||
|
pages: AssemblePage[];
|
||||||
|
/** The only metadata written: a flattened file must not leak the client name or GSTINs. */
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An image-only document: one full-bleed JPEG per page. No author, subject or keywords, and the
|
||||||
|
* title is whatever the caller passes (the invoice number). The images go in as untyped Blobs because
|
||||||
|
* that is the form react-pdf's browser build resolves without sniffing the type.
|
||||||
|
*/
|
||||||
|
export function assembleImagePdf({ pages, title }: AssembleInput): ReactElement {
|
||||||
|
return h(
|
||||||
|
Document,
|
||||||
|
{ title },
|
||||||
|
...pages.map((p, i) =>
|
||||||
|
h(
|
||||||
|
Page,
|
||||||
|
{ key: i, size: [p.widthPt, p.heightPt] as [number, number], style: { padding: 0 }, wrap: false },
|
||||||
|
h(Image, {
|
||||||
|
src: new Blob([p.jpeg as BlobPart]) as never,
|
||||||
|
style: { width: p.widthPt, height: p.heightPt },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
|
||||||
|
const Ctx = createContext<number | null>(null);
|
||||||
|
|
||||||
|
/** Publishes the content width in pt of the box its children are laid out in. */
|
||||||
|
export function AvailableWidthProvider({ width, children }: { width: number; children?: ReactNode }) {
|
||||||
|
return <Ctx.Provider value={width}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content width (box width minus its padding and border) available to the calling component. */
|
||||||
|
export function useAvailableWidth(): number {
|
||||||
|
const w = useContext(Ctx);
|
||||||
|
if (w === null) throw new Error("useAvailableWidth outside an AvailableWidthProvider (the page frame provides the root)");
|
||||||
|
return w;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { FILL } from "../engine/columns";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elastic gap before the closing block (amount in words, bank, signatory). It grows to push the closing
|
||||||
|
* block to the foot of whichever page it ends on. `closingH` is the closing block's height: as
|
||||||
|
* minPresenceAhead it keeps the spacer from staying on the previous page while the block starts the
|
||||||
|
* next one (without it the spacer stays behind and the block sits at the top of the new page).
|
||||||
|
*/
|
||||||
|
export function BottomSpacer({ closingH }: { closingH: number }) {
|
||||||
|
return <View style={FILL} {...pdfFlags({ minPresenceAhead: closingH })} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { DECOR_ID } from "../engine/ids";
|
||||||
|
import type { Sides } from "../engine/geometry";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
|
||||||
|
/** A decorative box placed in PAGE space (pt from the page's top-left corner). */
|
||||||
|
export interface DecorItem {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstPageHeaderProps {
|
||||||
|
/** The page padding from the frame: a header that is first in the flow starts at (pad.left, pad.top). */
|
||||||
|
pad: Sides;
|
||||||
|
/** Page-space position of the header's top-left corner; defaults to (pad.left, pad.top). */
|
||||||
|
pageOrigin?: { x: number; y: number };
|
||||||
|
/** First-page-only decor, in page space. Converted here to offsets from the header's own corner. */
|
||||||
|
decor?: DecorItem[];
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First thing in the flow, `wrap={false}`. First-page-only decor (Svg, Image, bands) lives here as
|
||||||
|
* absolute children instead of at page level: a non-fixed absolute Svg at page level that straddles the
|
||||||
|
* bottom margin makes react-pdf's paginate() loop forever, or drags all later content to the next page.
|
||||||
|
* Inside this unbreakable block it is laid out once with the header, on page 1.
|
||||||
|
*
|
||||||
|
* Absolute offsets are measured from the header's own corner; the header's padding does not shift them.
|
||||||
|
*/
|
||||||
|
export function FirstPageHeader({ pad, pageOrigin, decor = [], style, children }: FirstPageHeaderProps) {
|
||||||
|
const ox = pageOrigin?.x ?? pad.left;
|
||||||
|
const oy = pageOrigin?.y ?? pad.top;
|
||||||
|
return (
|
||||||
|
<View style={style as never} {...pdfFlags({ wrap: false })}>
|
||||||
|
{decor.map((d, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
id={DECOR_ID}
|
||||||
|
style={{ position: "absolute", left: d.x - ox, top: d.y - oy, width: d.width, height: d.height }}
|
||||||
|
>
|
||||||
|
{d.children}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { getMeasurer, measureToken } from "../engine/measure";
|
||||||
|
import { minSizeOf, resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { Txt, type TxtProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface FitInfo {
|
||||||
|
size: number;
|
||||||
|
fits: boolean;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FitTextProps extends Omit<TxtProps, "children" | "role" | "maxLines" | "width"> {
|
||||||
|
role: RoleName | TypeToken;
|
||||||
|
/** The width the single line must fit, pt. */
|
||||||
|
width: number;
|
||||||
|
/** Smallest size to shrink to; defaults to the role's minimum size. */
|
||||||
|
minSize?: number;
|
||||||
|
children: string;
|
||||||
|
/** For tests and audits only. */
|
||||||
|
onFit?: (info: FitInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP = 0.25;
|
||||||
|
|
||||||
|
/** Size at which `text` fits `width` on one line, shrinking from the role size in 0.25 pt steps. */
|
||||||
|
export function fitSize(
|
||||||
|
role: RoleName | TypeToken,
|
||||||
|
text: string,
|
||||||
|
width: number,
|
||||||
|
minSize?: number,
|
||||||
|
opts: { weight?: number; italic?: boolean; family?: string } = {},
|
||||||
|
): FitInfo {
|
||||||
|
const token = roleToken(role);
|
||||||
|
const m = getMeasurer();
|
||||||
|
const floor = Math.min(token.size, minSize ?? minSizeOf(token));
|
||||||
|
const family = opts.family ?? resolveFace(token.face);
|
||||||
|
let size = token.size;
|
||||||
|
for (;;) {
|
||||||
|
const w = measureToken(m, token, text, { ...opts, size, family });
|
||||||
|
if (w <= width + 1e-6) return { size, fits: true, width: w };
|
||||||
|
if (size <= floor + 1e-9) return { size, fits: false, width: w };
|
||||||
|
size = Math.max(floor, Math.round((size - STEP) * 100) / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The longest prefix of `text` that, followed by an ellipsis, fits `width` at `size`. */
|
||||||
|
function truncateToFit(token: TypeToken, text: string, width: number, size: number, opts: { weight?: number; italic?: boolean; family: string }): string {
|
||||||
|
const m = getMeasurer();
|
||||||
|
const chars = Array.from(text);
|
||||||
|
const widthOf = (n: number) => measureToken(m, token, chars.slice(0, n).join("").trimEnd() + "…", { ...opts, size });
|
||||||
|
let lo = 0;
|
||||||
|
let hi = chars.length;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = Math.ceil((lo + hi) / 2);
|
||||||
|
if (widthOf(mid) <= width + 1e-6) lo = mid;
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
return chars.slice(0, lo).join("").trimEnd() + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-line text that shrinks to fit. If it still does not fit at `minSize` it is cut to the longest
|
||||||
|
* prefix plus an ellipsis at that size. This is done here and not with maxLines: textkit never breaks
|
||||||
|
* inside a word, so a single over-long token stays on its one line, overflows the box and gets no
|
||||||
|
* ellipsis (verified: a 60-character e-mail in a 150 pt box ran to 257 pt). maxLines stays on as a
|
||||||
|
* backstop for multi-word text.
|
||||||
|
*/
|
||||||
|
export function FitText({ role, width, minSize, children, onFit, ...rest }: FitTextProps) {
|
||||||
|
const token = roleToken(role);
|
||||||
|
const family = resolveFace(token.face, rest.faceOverrides);
|
||||||
|
const info = fitSize(token, children, width, minSize, { weight: rest.weight, italic: rest.italic, family });
|
||||||
|
onFit?.(info);
|
||||||
|
// Leading stays the role's: only the glyph size shrinks.
|
||||||
|
const sized: TypeToken = { ...token, size: info.size };
|
||||||
|
const text = info.fits ? children : truncateToFit(token, children, width, info.size, { weight: rest.weight, italic: rest.italic, family });
|
||||||
|
return (
|
||||||
|
<Txt {...rest} role={sized} width={width} maxLines={info.fits ? undefined : 1}>
|
||||||
|
{text}
|
||||||
|
</Txt>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { getMeasurer, measureToken } from "../engine/measure";
|
||||||
|
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { chunkIdentifier } from "../model/text";
|
||||||
|
import { useAvailableWidth } from "./AvailableWidth";
|
||||||
|
import { fitSize, type FitInfo } from "./FitText";
|
||||||
|
import { Txt, type TxtProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface IdTextInfo extends FitInfo {
|
||||||
|
mode: "single" | "chunked";
|
||||||
|
chunks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdTextProps extends Omit<TxtProps, "children" | "role" | "maxLines" | "width" | "grow"> {
|
||||||
|
role: RoleName | TypeToken;
|
||||||
|
children: string;
|
||||||
|
/** Width to fit; defaults to the available width of the enclosing cell. */
|
||||||
|
width?: number;
|
||||||
|
minSize?: number;
|
||||||
|
/** For tests and audits only. */
|
||||||
|
onFit?: (info: IdTextInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text for identifiers (e-mail, URL, GSTIN, phone). textkit never breaks inside a word, so a long
|
||||||
|
* value would overflow silently. This shrinks it to the role's minimum size; if it is still too wide
|
||||||
|
* it is split at "@ . / - _" into a flex-wrap row of adjacent Text chunks (no gaps, so extraction
|
||||||
|
* joins them back into the original string).
|
||||||
|
*/
|
||||||
|
export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTextProps) {
|
||||||
|
const avail = useAvailableWidth();
|
||||||
|
const target = width ?? avail;
|
||||||
|
const token = roleToken(role);
|
||||||
|
const fit = fitSize(token, children, target, minSize, { weight: rest.weight, italic: rest.italic });
|
||||||
|
|
||||||
|
if (fit.fits) {
|
||||||
|
onFit?.({ ...fit, mode: "single", chunks: [children] });
|
||||||
|
return <Txt {...rest} role={{ ...token, size: fit.size }}>{children}</Txt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sized: TypeToken = { ...token, size: fit.size };
|
||||||
|
const m = getMeasurer();
|
||||||
|
const family = resolveFace(token.face, rest.faceOverrides);
|
||||||
|
const widthOf = (s: string) =>
|
||||||
|
measureToken(m, sized, s, { weight: rest.weight, italic: rest.italic, family });
|
||||||
|
let chunks = chunkIdentifier(children, 1);
|
||||||
|
for (let max = children.length; max >= 1; max--) {
|
||||||
|
const candidate = chunkIdentifier(children, max);
|
||||||
|
if (candidate.every((c) => widthOf(c) <= target + 1e-6)) {
|
||||||
|
chunks = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onFit?.({ ...fit, mode: "chunked", chunks });
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "row", flexWrap: "wrap", width: target, minWidth: target, maxWidth: target } as never}>
|
||||||
|
{chunks.map((c, i) => (
|
||||||
|
<Txt key={i} {...rest} align={undefined} role={sized}>
|
||||||
|
{c}
|
||||||
|
</Txt>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { colsDetailed, type ColSpec } from "../engine/columns";
|
||||||
|
import { PlanError } from "../engine/errors";
|
||||||
|
import { TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "../engine/ids";
|
||||||
|
import { drawnText, type Measurer } from "../engine/measure";
|
||||||
|
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { AvailableWidthProvider } from "./AvailableWidth";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
import { Cell, Row } from "./Row";
|
||||||
|
import { Txt } from "./Txt";
|
||||||
|
|
||||||
|
/** A cell: one string, or several (one Txt each, stacked), for multi-line values. */
|
||||||
|
export type TableCellValue = string | string[];
|
||||||
|
|
||||||
|
const cellParts = (v: TableCellValue): string[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]);
|
||||||
|
|
||||||
|
export interface TableRoles {
|
||||||
|
head: RoleName | TypeToken;
|
||||||
|
body: RoleName | TypeToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableLook {
|
||||||
|
/** Horizontal cell padding, pt per side. Default 0. */
|
||||||
|
padX?: number;
|
||||||
|
/** Vertical cell padding, pt per side. Default 4. */
|
||||||
|
padY?: number;
|
||||||
|
/** Bottom border of each row in pt (counted in the row height). Default 0. */
|
||||||
|
border?: number;
|
||||||
|
/** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */
|
||||||
|
padRight?: number[];
|
||||||
|
faceOverrides?: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemsTablePlanInput extends TableLook {
|
||||||
|
columns: ColSpec[];
|
||||||
|
gap: number;
|
||||||
|
/** Table width in pt (the content width of the page, or of the cell holding the table). */
|
||||||
|
width: number;
|
||||||
|
header: TableCellValue[];
|
||||||
|
rows: TableCellValue[][];
|
||||||
|
measurer: Measurer;
|
||||||
|
roles: TableRoles;
|
||||||
|
/** Height of whatever follows the last row and must stay with it (the totals). */
|
||||||
|
tailH?: number;
|
||||||
|
/** Wrap-area height of one page. With `tailH` it lets the planner prove the tail can ever fit. */
|
||||||
|
contentH?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemsTablePlan {
|
||||||
|
widths: number[];
|
||||||
|
headH: number;
|
||||||
|
rowHeights: number[];
|
||||||
|
firstRowH: number;
|
||||||
|
/** Predicted line count per row (the tallest cell). */
|
||||||
|
rowLines: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellValue, inner: number, look: TableLook): { lines: number; height: number } {
|
||||||
|
const t = roleToken(role);
|
||||||
|
if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan");
|
||||||
|
let lines = 0;
|
||||||
|
for (const text of cellParts(value)) {
|
||||||
|
if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
|
||||||
|
lines += Math.max(
|
||||||
|
1,
|
||||||
|
m.wrapLines(drawnText(t, text), inner, {
|
||||||
|
face: resolveFace(t.face, look.faceOverrides),
|
||||||
|
weight: t.weight,
|
||||||
|
italic: t.italic ?? false,
|
||||||
|
size: t.size,
|
||||||
|
letterSpacing: (t.tracking ?? 0) * t.size,
|
||||||
|
}).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { lines, height: lines * t.leading };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plans an items table without rendering: column widths, the wrapped height of the header and of
|
||||||
|
* every row. Heights are estimates from the measurer (see Measurer.wrapLines) and feed
|
||||||
|
* TableGuard/BottomSpacer presence values.
|
||||||
|
*
|
||||||
|
* Throws PlanError("tail-too-tall") when the totals tail could never share a page with the header and
|
||||||
|
* the last row: `tailH <= contentH - headH - lastRowH`.
|
||||||
|
*/
|
||||||
|
export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
|
||||||
|
const { columns, gap, width, header, rows, measurer, roles, tailH, contentH } = input;
|
||||||
|
const padX = input.padX ?? 0;
|
||||||
|
const padY = input.padY ?? 4;
|
||||||
|
const border = input.border ?? 0;
|
||||||
|
if (header.length !== columns.length) throw new PlanError(`${header.length} header cells for ${columns.length} columns`, "invalid-plan");
|
||||||
|
if (rows.length === 0) throw new PlanError("an items table needs at least one row", "invalid-plan");
|
||||||
|
for (const r of rows) if (r.length !== columns.length) throw new PlanError(`row has ${r.length} cells for ${columns.length} columns`, "invalid-plan");
|
||||||
|
|
||||||
|
const { widths } = colsDetailed(width, columns, gap);
|
||||||
|
const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0)));
|
||||||
|
const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[]) => {
|
||||||
|
const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input));
|
||||||
|
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * padY + border };
|
||||||
|
};
|
||||||
|
|
||||||
|
const headH = heightOf(roles.head, header).height;
|
||||||
|
const measured = rows.map((r) => heightOf(roles.body, r));
|
||||||
|
const rowHeights = measured.map((r) => r.height);
|
||||||
|
|
||||||
|
if (tailH !== undefined && contentH !== undefined) {
|
||||||
|
const lastRowH = rowHeights[rowHeights.length - 1];
|
||||||
|
if (tailH > contentH - headH - lastRowH + 1e-6) {
|
||||||
|
throw new PlanError(
|
||||||
|
`totals tail ${tailH} pt cannot share a ${contentH} pt page with the header (${headH}) and the last row (${lastRowH})`,
|
||||||
|
"tail-too-tall",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { widths, headH, rowHeights, firstRowH: rowHeights[0], rowLines: measured.map((r) => r.lines) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableRowsProps extends TableLook {
|
||||||
|
columns: ColSpec[];
|
||||||
|
gap: number;
|
||||||
|
width: number;
|
||||||
|
header: TableCellValue[];
|
||||||
|
rows: TableCellValue[][];
|
||||||
|
roles: TableRoles;
|
||||||
|
/** Per column; default left. */
|
||||||
|
align?: Array<"left" | "right" | "center">;
|
||||||
|
/** Passed as minPresenceAhead on the last row so the totals never start a page without an item row. */
|
||||||
|
tailH?: number;
|
||||||
|
/** Content after the last row, inside the table container (the totals). */
|
||||||
|
tail?: ReactNode;
|
||||||
|
headerStyle?: Record<string, unknown>;
|
||||||
|
rowStyle?: Record<string, unknown>;
|
||||||
|
borderColor?: string;
|
||||||
|
/** Text colour of the header cells. */
|
||||||
|
headColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header (a `fixed` first child, so it repeats on every page the table spans) followed by one
|
||||||
|
* `wrap={false}` row per item. The container has no padding or border; those go on the rows, because
|
||||||
|
* the repeated header is positioned against the container's top.
|
||||||
|
*/
|
||||||
|
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, ...look }: TableRowsProps) {
|
||||||
|
const padX = look.padX ?? 0;
|
||||||
|
const padY = look.padY ?? 4;
|
||||||
|
const border = look.border ?? 0;
|
||||||
|
const { widths } = colsDetailed(width, columns, gap);
|
||||||
|
const cellStyle = (extra?: Record<string, unknown>) => ({
|
||||||
|
...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string) =>
|
||||||
|
values.map((v, i) => (
|
||||||
|
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}>
|
||||||
|
{cellParts(v).map((part, li) => (
|
||||||
|
<Txt key={li} role={role} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||||
|
{part}
|
||||||
|
</Txt>
|
||||||
|
))}
|
||||||
|
</Cell>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvailableWidthProvider width={width}>
|
||||||
|
<View>
|
||||||
|
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
|
||||||
|
{cells(roles.head, header, headColor)}
|
||||||
|
</Row>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<Row
|
||||||
|
key={i}
|
||||||
|
id={`${TABLE_ROW_PREFIX}${i}`}
|
||||||
|
widths={columns}
|
||||||
|
gap={gap}
|
||||||
|
width={width}
|
||||||
|
{...pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined })}
|
||||||
|
style={cellStyle(rowStyle)}
|
||||||
|
>
|
||||||
|
{cells(roles.body, r)}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
{tail}
|
||||||
|
</View>
|
||||||
|
</AvailableWidthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { FILL } from "../engine/columns";
|
||||||
|
import { chromeHeight } from "../engine/metrics";
|
||||||
|
import { getMeasurer, measureToken } from "../engine/measure";
|
||||||
|
import type { PageFrame } from "../engine/geometry";
|
||||||
|
import { BAND_ID, DECOR_ID, PAGE_LABEL_ID } from "../engine/ids";
|
||||||
|
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { PlanError } from "../engine/errors";
|
||||||
|
import type { DecorItem } from "./FirstPageHeader";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
import { FitText } from "./FitText";
|
||||||
|
import { RenderTxt } from "./RenderTxt";
|
||||||
|
|
||||||
|
export interface PageChromeProps {
|
||||||
|
frame: PageFrame;
|
||||||
|
/** Left footer text (e.g. the invoice number); omit for none. */
|
||||||
|
footerLeft?: string;
|
||||||
|
/**
|
||||||
|
* Pins the left text to this width (pt) and fits it to one line (shrunk, then cut with an ellipsis).
|
||||||
|
* A render-prop Text cannot be clamped, so a long supplier name there would wrap and push the footer
|
||||||
|
* up into the content. Without it the left text takes the remaining width.
|
||||||
|
*/
|
||||||
|
footerLeftWidth?: number;
|
||||||
|
/** Centre text between the left text and the page label. */
|
||||||
|
footerCenter?: string;
|
||||||
|
/** Show "Page n of N" at the right of the footer. Default true. */
|
||||||
|
pageLabel?: boolean;
|
||||||
|
/** Footer and band type; render-prop roles (no leading). Default "footer". */
|
||||||
|
role?: RoleName | TypeToken;
|
||||||
|
color?: string;
|
||||||
|
/** Continuation band for pages 2+ (shown only when pageNumber > 1). */
|
||||||
|
band?: { left?: string; right?: string };
|
||||||
|
/** Page-space top of the band. Default: the top of the reserve, pad.top - continuationReserve (>= 0). */
|
||||||
|
bandTop?: number;
|
||||||
|
/** Every-page decor in page space. */
|
||||||
|
decor?: DecorItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FitText is static text and needs a leading; the footer role has none (render-prop text), so the
|
||||||
|
* fitted left text borrows 1.4x the size, which stays within the auto-height line that chromeHeight
|
||||||
|
* reserves for the same role.
|
||||||
|
*/
|
||||||
|
function fineFooter(role: RoleName | TypeToken): TypeToken {
|
||||||
|
const t = roleToken(role);
|
||||||
|
return { ...t, leading: Math.round(t.size * 1.4 * 10) / 10 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Page 3 of 5"; before the total is known (first layout pass) just "Page 3". */
|
||||||
|
export function pageLabelText(pageNumber: number, totalPages: number | undefined): string {
|
||||||
|
return typeof totalPages === "number" && totalPages > 0 ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed chrome for every page. Put it AFTER the flow as the last child of the Page: with the legacy
|
||||||
|
* paginator a fixed node placed after the flow still repeats on every page.
|
||||||
|
*
|
||||||
|
* Everything is a page-level absolute anchored to the page edges, so none of it takes part in the flow:
|
||||||
|
* - footer: a row in the bottom margin, between the content bottom (page height - pad.bottom) and the
|
||||||
|
* physical margin. `frame.footerReserve` must cover chromeHeight(role); both texts are render-prop
|
||||||
|
* Text in an AUTO-height box (a fixed height smaller than the line draws nothing).
|
||||||
|
* - continuation band: only on pages 2+. On page 1 its render prop returns "" (0 lines, expected).
|
||||||
|
* - decor: fixed absolute boxes in page space, flagged with DECOR_ID.
|
||||||
|
*
|
||||||
|
* The page label has a pinned width taken from the widest label ("Page 99 of 99"), never from the
|
||||||
|
* current pass, because `totalPages` is undefined on the first pass and `subPageNumber` in the split pass.
|
||||||
|
*/
|
||||||
|
export function PageChrome({
|
||||||
|
frame,
|
||||||
|
footerLeft,
|
||||||
|
footerLeftWidth,
|
||||||
|
footerCenter,
|
||||||
|
pageLabel = true,
|
||||||
|
role = "footer",
|
||||||
|
color,
|
||||||
|
band,
|
||||||
|
bandTop,
|
||||||
|
decor = [],
|
||||||
|
}: PageChromeProps) {
|
||||||
|
const { pad } = frame;
|
||||||
|
const need = chromeHeight(role);
|
||||||
|
const showFooter = pageLabel || footerLeft !== undefined || footerCenter !== undefined;
|
||||||
|
if (showFooter && frame.footerReserve < need) {
|
||||||
|
throw new PlanError(`footerReserve ${frame.footerReserve} pt is under the ${need} pt a "${roleToken(role).size} pt" footer line needs`, "chrome-reserve");
|
||||||
|
}
|
||||||
|
const labelWidth = Math.ceil(measureToken(getMeasurer(), role, "Page 99 of 99")) + 2;
|
||||||
|
const marginBottom = pad.bottom - frame.footerReserve;
|
||||||
|
const bandRightWidth = band?.right !== undefined ? Math.ceil(measureToken(getMeasurer(), role, band.right)) + 2 : 0;
|
||||||
|
const top = bandTop ?? Math.max(0, pad.top - frame.continuationReserve);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{decor.map((d, i) => (
|
||||||
|
<View
|
||||||
|
key={`decor-${i}`}
|
||||||
|
id={DECOR_ID}
|
||||||
|
style={{ position: "absolute", left: d.x, top: d.y, width: d.width, height: d.height }}
|
||||||
|
{...pdfFlags({ fixed: true })}
|
||||||
|
>
|
||||||
|
{d.children}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{band && (band.left !== undefined || band.right !== undefined) && (
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", top, left: pad.left, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||||
|
{...pdfFlags({ fixed: true })}
|
||||||
|
>
|
||||||
|
{band.left !== undefined && (
|
||||||
|
<RenderTxt id={BAND_ID} role={role} color={color} grow render={({ pageNumber }) => (pageNumber > 1 ? (band.left ?? "") : "")} />
|
||||||
|
)}
|
||||||
|
{band.right !== undefined && (
|
||||||
|
<RenderTxt
|
||||||
|
id={BAND_ID}
|
||||||
|
role={role}
|
||||||
|
color={color}
|
||||||
|
align="right"
|
||||||
|
width={bandRightWidth}
|
||||||
|
render={({ pageNumber }) => (pageNumber > 1 ? (band.right ?? "") : "")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFooter && (
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", left: pad.left, bottom: marginBottom, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||||
|
{...pdfFlags({ fixed: true })}
|
||||||
|
>
|
||||||
|
{footerLeft !== undefined &&
|
||||||
|
(footerLeftWidth !== undefined ? (
|
||||||
|
<FitText role={fineFooter(role)} width={footerLeftWidth} color={color}>
|
||||||
|
{footerLeft}
|
||||||
|
</FitText>
|
||||||
|
) : (
|
||||||
|
<RenderTxt role={role} color={color} grow render={() => footerLeft} />
|
||||||
|
))}
|
||||||
|
{footerCenter !== undefined && <RenderTxt role={role} color={color} align="center" grow render={() => footerCenter} />}
|
||||||
|
{footerCenter === undefined && footerLeftWidth !== undefined && <View style={FILL as never} />}
|
||||||
|
{pageLabel && (
|
||||||
|
<RenderTxt
|
||||||
|
id={PAGE_LABEL_ID}
|
||||||
|
role={role}
|
||||||
|
color={color}
|
||||||
|
align="right"
|
||||||
|
width={labelWidth}
|
||||||
|
render={({ pageNumber, totalPages }) => pageLabelText(pageNumber, totalPages)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Text } from "@react-pdf/renderer";
|
||||||
|
import { FILL, pinned } from "../engine/columns";
|
||||||
|
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
|
||||||
|
import { assertNoLineHeight, baseTextStyle, type TxtStyleProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface RenderTxtProps extends TxtStyleProps, PdfFlagsInput {
|
||||||
|
/** Receives { pageNumber, totalPages, subPageNumber, ... } from react-pdf. */
|
||||||
|
render: (info: { pageNumber: number; totalPages: number; subPageNumber: number; subPageTotalPages: number }) => string;
|
||||||
|
role: RoleName | TypeToken;
|
||||||
|
/** react-pdf copies the id into the layout tree (the audit finds chrome nodes by it). */
|
||||||
|
id?: string;
|
||||||
|
width?: number;
|
||||||
|
grow?: boolean;
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text drawn from a render prop (page numbers, continuation band). It sets NO lineHeight, and no
|
||||||
|
* ancestor may set one either: react-pdf multiplies a render-prop lineHeight again on every relayout
|
||||||
|
* ('10pt' ends up as 5120 and nothing draws). The box is auto-height; reserve its space from font
|
||||||
|
* metrics, never a fixed height. No maxLines: a clamped render-prop Text draws nothing.
|
||||||
|
* `totalPages` is undefined on the first layout pass, so the render function must cope with that.
|
||||||
|
*/
|
||||||
|
export function RenderTxt({ id, role, render, color, align, weight, italic, width, grow, style, faceOverrides, fixed, wrap, minPresenceAhead }: RenderTxtProps) {
|
||||||
|
const token = roleToken(role);
|
||||||
|
assertNoLineHeight(style, "RenderTxt");
|
||||||
|
const merged = {
|
||||||
|
...baseTextStyle(token, { color, align, weight, italic, faceOverrides }),
|
||||||
|
...(width !== undefined ? pinned(width) : {}),
|
||||||
|
...(grow ? FILL : {}),
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
return <Text {...(id !== undefined ? { id } : {})} style={merged as never} render={render as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { Children, type ReactNode } from "react";
|
||||||
|
import { colsDetailed, FILL, pinned, type ColSpec } from "../engine/columns";
|
||||||
|
import { AvailableWidthProvider, useAvailableWidth } from "./AvailableWidth";
|
||||||
|
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
|
||||||
|
|
||||||
|
export interface RowProps extends PdfFlagsInput {
|
||||||
|
/** Copied into the layout tree (audits find rows by it). */
|
||||||
|
id?: string;
|
||||||
|
/** One spec per child, in order. */
|
||||||
|
widths: ColSpec[];
|
||||||
|
gap?: number;
|
||||||
|
/** Stack the children as a column when the row is narrower than this. */
|
||||||
|
stackBelow?: number;
|
||||||
|
/** Cross-axis alignment of the cells; never flex-end around multi-line text. */
|
||||||
|
align?: "flex-start" | "center" | "stretch";
|
||||||
|
/** Overrides the width from AvailableWidth. */
|
||||||
|
width?: number;
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flex row, pinned to its total width (a row inside a flex-start parent would otherwise shrink to its content and leave its fill cell empty), whose children each sit in a cell of explicit width: fixed and fractional cells are pinned
|
||||||
|
* (width = minWidth = maxWidth) and the fill cell uses {flexGrow: 1, flexBasis: 0}. Descendants read the
|
||||||
|
* cell's content width through useAvailableWidth(). When a column falls under its `min` (or the row is
|
||||||
|
* narrower than `stackBelow`), the children are stacked at full width instead.
|
||||||
|
*/
|
||||||
|
export function Row({ id, widths, gap = 0, stackBelow, align = "flex-start", width, style, children, fixed, wrap, minPresenceAhead }: RowProps) {
|
||||||
|
const inherited = useAvailableWidth();
|
||||||
|
const total = width ?? inherited;
|
||||||
|
const kids = Children.toArray(children);
|
||||||
|
if (kids.length !== widths.length) throw new Error(`Row: ${kids.length} children for ${widths.length} column specs`);
|
||||||
|
|
||||||
|
const { widths: w, ok } = colsDetailed(total, widths, gap);
|
||||||
|
const stacked = !ok || (stackBelow !== undefined && total < stackBelow);
|
||||||
|
const flags = { ...(id !== undefined ? { id } : {}), ...pdfFlags({ fixed, wrap, minPresenceAhead }) };
|
||||||
|
|
||||||
|
if (stacked) {
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "column", alignItems: "flex-start", ...pinned(total), ...style } as never} {...flags}>
|
||||||
|
{kids.map((child, i) => (
|
||||||
|
<View key={i} style={{ ...pinned(total), alignItems: "flex-start", ...(gap && i > 0 ? { marginTop: gap } : {}) } as never}>
|
||||||
|
<AvailableWidthProvider width={total}>{child}</AvailableWidthProvider>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: align, ...pinned(total), ...style } as never} {...flags}>
|
||||||
|
{kids.map((child, i) => {
|
||||||
|
const isFill = "fill" in widths[i];
|
||||||
|
const box = isFill ? FILL : pinned(w[i]);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={{ ...box, alignItems: "flex-start", ...(i > 0 && gap ? { marginLeft: gap } : {}) } as never}
|
||||||
|
>
|
||||||
|
<AvailableWidthProvider width={w[i]}>{child}</AvailableWidthProvider>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CellProps {
|
||||||
|
/** Padding in pt: one number for all sides, or per side. */
|
||||||
|
padding?: number | { top?: number; right?: number; bottom?: number; left?: number };
|
||||||
|
borderLeft?: number;
|
||||||
|
borderRight?: number;
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A padded/bordered box inside a Row column. It fills the column's width and republishes the content
|
||||||
|
* width (column width minus its own padding and border) to its descendants.
|
||||||
|
*/
|
||||||
|
export function Cell({ padding = 0, borderLeft = 0, borderRight = 0, style, children }: CellProps) {
|
||||||
|
const full = useAvailableWidth();
|
||||||
|
const p = typeof padding === "number" ? { top: padding, right: padding, bottom: padding, left: padding } : padding;
|
||||||
|
const inner = Math.max(0, full - (p.left ?? 0) - (p.right ?? 0) - borderLeft - borderRight);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
...pinned(full),
|
||||||
|
alignItems: "flex-start",
|
||||||
|
paddingTop: p.top ?? 0,
|
||||||
|
paddingRight: p.right ?? 0,
|
||||||
|
paddingBottom: p.bottom ?? 0,
|
||||||
|
paddingLeft: p.left ?? 0,
|
||||||
|
...(borderLeft ? { borderLeftWidth: borderLeft } : {}),
|
||||||
|
...(borderRight ? { borderRightWidth: borderRight } : {}),
|
||||||
|
...style,
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AvailableWidthProvider width={inner}>{children}</AvailableWidthProvider>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { getMeasurer, measureToken } from "../engine/measure";
|
||||||
|
import { resolveFace, roleToken } from "../fonts/roles";
|
||||||
|
import { useAvailableWidth } from "./AvailableWidth";
|
||||||
|
import { IdText } from "./IdText";
|
||||||
|
import { Txt, type TxtProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface SafeTxtProps extends Omit<TxtProps, "children" | "width" | "maxLines" | "grow"> {
|
||||||
|
children: string;
|
||||||
|
/** Width of the box; defaults to the available width of the enclosing cell. */
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free text (addresses, names, notes) that cannot overflow: normal text wraps at spaces in a box of the
|
||||||
|
* cell's width; a value with a single token wider than the cell (a long URL or an unbroken string) goes
|
||||||
|
* through IdText, which shrinks it or splits it at "@ . / - _". textkit never breaks inside a word,
|
||||||
|
* so without this such a token would run out of its box.
|
||||||
|
*/
|
||||||
|
export function SafeTxt({ children, width, align, ...rest }: SafeTxtProps) {
|
||||||
|
const avail = useAvailableWidth();
|
||||||
|
const w = width ?? avail;
|
||||||
|
const token = roleToken(rest.role);
|
||||||
|
const m = getMeasurer();
|
||||||
|
const family = resolveFace(token.face, rest.faceOverrides);
|
||||||
|
const widest = Math.max(
|
||||||
|
0,
|
||||||
|
...children.split(" ").map((word) => measureToken(m, token, word, { weight: rest.weight, italic: rest.italic, family })),
|
||||||
|
);
|
||||||
|
if (widest > w + 1e-6) {
|
||||||
|
return (
|
||||||
|
<IdText {...rest} width={w}>
|
||||||
|
{children}
|
||||||
|
</IdText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Txt {...rest} align={align} width={w}>
|
||||||
|
{children}
|
||||||
|
</Txt>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { View } from "@react-pdf/renderer";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presence to ask for in front of a table: the header plus the first row, with the first row capped at
|
||||||
|
* three body lines so an oversize first row cannot push the table to a new page every time.
|
||||||
|
*/
|
||||||
|
export function guardPresence(headH: number, firstRowH: number, bodyLeading: number): number {
|
||||||
|
return headH + Math.min(firstRowH, 3 * bodyLeading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zero-height sentinel placed immediately before a table. react-pdf's `minPresenceAhead` measures from
|
||||||
|
* the element's bottom, so this moves itself (and with it the whole table) to the next page unless the
|
||||||
|
* header and the first rows fit below it. Without it the repeated header can stay alone at a page foot.
|
||||||
|
*/
|
||||||
|
export function TableGuard({ minPresenceAhead }: { minPresenceAhead: number }) {
|
||||||
|
return <View style={{ height: 0 }} {...pdfFlags({ minPresenceAhead })} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { roleToken } from "../fonts/roles";
|
||||||
|
import { Txt, type TxtProps } from "./Txt";
|
||||||
|
|
||||||
|
export interface TrackedProps extends Omit<TxtProps, "align"> {
|
||||||
|
align: "right" | "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* textkit adds letterSpacing after every glyph, the last one included, so right- and centre-aligned
|
||||||
|
* tracked text sits one tracking step too far left. This cancels it with a negative margin: the full
|
||||||
|
* step on the right edge for `right`, half a step each side for `center` (shifting the box right by
|
||||||
|
* half a step).
|
||||||
|
*
|
||||||
|
* HAZARD: the Text box now overhangs its parent by up to one tracking step. Overflow and safe-zone
|
||||||
|
* audits must measure ink (glyph extents), not boxes, or they flag it.
|
||||||
|
*/
|
||||||
|
export function Tracked({ align, style, ...rest }: TrackedProps) {
|
||||||
|
const token = roleToken(rest.role);
|
||||||
|
const step = (token.tracking ?? 0) * token.size;
|
||||||
|
const margin =
|
||||||
|
align === "right"
|
||||||
|
? { marginRight: -step }
|
||||||
|
: { marginLeft: step / 2, marginRight: -step / 2 };
|
||||||
|
return <Txt {...rest} align={align} style={{ ...margin, ...style }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Text } from "@react-pdf/renderer";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { FILL, pinned } from "../engine/columns";
|
||||||
|
import { stack } from "../fonts/register";
|
||||||
|
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
|
||||||
|
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||||
|
|
||||||
|
export type TxtChild = string | number | Array<string | number>;
|
||||||
|
type Align = "left" | "right" | "center" | "justify";
|
||||||
|
|
||||||
|
export interface TxtStyleProps {
|
||||||
|
color?: string;
|
||||||
|
align?: Align;
|
||||||
|
weight?: TypeToken["weight"];
|
||||||
|
italic?: boolean;
|
||||||
|
/** Font family overrides (e.g. a user-imported font replacing a template face). */
|
||||||
|
faceOverrides?: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TxtProps extends TxtStyleProps, PdfFlagsInput {
|
||||||
|
/** react-pdf copies the id into the layout tree (audits and contract tests find nodes by it). */
|
||||||
|
id?: string;
|
||||||
|
role: RoleName | TypeToken;
|
||||||
|
/** Clamp to this many lines and end with an ellipsis. */
|
||||||
|
maxLines?: number;
|
||||||
|
/** Pin the box to this width in pt (width = minWidth = maxWidth). */
|
||||||
|
width?: number;
|
||||||
|
/** Take the remaining row width ({flexGrow: 1, flexBasis: 0}). */
|
||||||
|
grow?: boolean;
|
||||||
|
/** Extra style. Must not contain lineHeight: leading comes from the role. */
|
||||||
|
style?: Record<string, unknown>;
|
||||||
|
children?: TxtChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Style fields shared by Txt and RenderTxt: everything except lineHeight. */
|
||||||
|
export function baseTextStyle(token: TypeToken, o: TxtStyleProps) {
|
||||||
|
const size = token.size;
|
||||||
|
const weight = o.weight ?? token.weight;
|
||||||
|
return {
|
||||||
|
fontFamily: stack(resolveFace(token.face, o.faceOverrides)),
|
||||||
|
fontSize: size,
|
||||||
|
fontWeight: weight,
|
||||||
|
fontStyle: (o.italic ?? token.italic) ? "italic" : "normal",
|
||||||
|
...(token.tracking ? { letterSpacing: token.tracking * size } : {}),
|
||||||
|
...(token.caps ? { textTransform: "uppercase" } : {}),
|
||||||
|
...(o.color ? { color: o.color } : {}),
|
||||||
|
...(o.align ? { textAlign: o.align } : {}),
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertNoLineHeight(style: Record<string, unknown> | undefined, who: string): void {
|
||||||
|
if (style && "lineHeight" in style && process.env.NODE_ENV !== "production") {
|
||||||
|
throw new Error(`${who}: style must not contain lineHeight; the role sets the leading`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static text. Always sets family, size, weight, style and an absolute `lineHeight: "Npt"` on the
|
||||||
|
* same node; never put a lineHeight on an ancestor View.
|
||||||
|
*/
|
||||||
|
export function Txt({ id, role, color, align, weight, italic, maxLines, width, grow, style, faceOverrides, children, fixed, wrap, minPresenceAhead }: TxtProps) {
|
||||||
|
const token = roleToken(role);
|
||||||
|
if (token.leading === undefined) throw new Error("Txt needs a role with a leading; use RenderTxt for render-prop text");
|
||||||
|
assertNoLineHeight(style, "Txt");
|
||||||
|
const merged = {
|
||||||
|
...baseTextStyle(token, { color, align, weight, italic, faceOverrides }),
|
||||||
|
lineHeight: `${token.leading}pt`,
|
||||||
|
...(width !== undefined ? pinned(width) : {}),
|
||||||
|
...(grow ? FILL : {}),
|
||||||
|
...(maxLines !== undefined ? { maxLines, textOverflow: "ellipsis" } : {}),
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
return <Text {...(id !== undefined ? { id } : {})} style={merged as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })}>{children as ReactNode}</Text>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { createElement as h, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { Document, Page, View } from "@react-pdf/renderer";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stack } from "../fonts/register";
|
||||||
|
import { TYPE_ROLES } from "../fonts/roles";
|
||||||
|
import { assertNoOverlap } from "../testing/asserts";
|
||||||
|
import { renderPdfText } from "../testing/pdfText";
|
||||||
|
import { setupPdfTest } from "../testing/setup";
|
||||||
|
import { AvailableWidthProvider } from "./AvailableWidth";
|
||||||
|
import { FitText, type FitInfo } from "./FitText";
|
||||||
|
import { IdText, type IdTextInfo } from "./IdText";
|
||||||
|
import { pdfFlags } from "./pdfProps";
|
||||||
|
import { RenderTxt } from "./RenderTxt";
|
||||||
|
import { Cell, Row } from "./Row";
|
||||||
|
import { Tracked } from "./Tracked";
|
||||||
|
import { Txt } from "./Txt";
|
||||||
|
|
||||||
|
setupPdfTest();
|
||||||
|
|
||||||
|
const PAD = 20;
|
||||||
|
const PAGE_W = 400;
|
||||||
|
|
||||||
|
/** A page whose content box is `width` pt wide and published through AvailableWidth. */
|
||||||
|
function frame(children: ReactNode, width = PAGE_W - 2 * PAD): ReactElement {
|
||||||
|
return h(
|
||||||
|
Document,
|
||||||
|
null,
|
||||||
|
h(
|
||||||
|
Page,
|
||||||
|
{ size: { width: PAGE_W, height: 600 }, style: { padding: PAD } },
|
||||||
|
h(AvailableWidthProvider, { width }, children),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TxtLike = { props: { style: Record<string, unknown> } };
|
||||||
|
const styleOf = (el: unknown) => (el as TxtLike).props.style;
|
||||||
|
|
||||||
|
describe("pdfFlags", () => {
|
||||||
|
it("spreads only meaningful keys", () => {
|
||||||
|
expect(pdfFlags()).toEqual({});
|
||||||
|
expect(pdfFlags({ fixed: false, wrap: undefined, minPresenceAhead: undefined })).toEqual({});
|
||||||
|
expect(pdfFlags({ fixed: true })).toEqual({ fixed: true });
|
||||||
|
expect(pdfFlags({ wrap: true })).toEqual({});
|
||||||
|
expect(pdfFlags({ wrap: false })).toEqual({ wrap: false });
|
||||||
|
expect(pdfFlags({ minPresenceAhead: 0 })).toEqual({});
|
||||||
|
expect(pdfFlags({ minPresenceAhead: -3 })).toEqual({});
|
||||||
|
expect(pdfFlags({ minPresenceAhead: NaN })).toEqual({});
|
||||||
|
expect(pdfFlags({ minPresenceAhead: Infinity })).toEqual({});
|
||||||
|
expect(pdfFlags({ minPresenceAhead: 12.5 })).toEqual({ minPresenceAhead: 12.5 });
|
||||||
|
expect(pdfFlags({ fixed: true, wrap: false, minPresenceAhead: 9 })).toEqual({
|
||||||
|
fixed: true,
|
||||||
|
wrap: false,
|
||||||
|
minPresenceAhead: 9,
|
||||||
|
});
|
||||||
|
// Keys must be absent, not undefined: react-pdf checks presence.
|
||||||
|
expect(Object.keys(pdfFlags({ fixed: false }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches the element only when set", () => {
|
||||||
|
const off = Txt({ role: "body", children: "x", fixed: false }) as unknown as { props: Record<string, unknown> };
|
||||||
|
expect("fixed" in off.props).toBe(false);
|
||||||
|
const on = Txt({ role: "body", children: "x", fixed: true }) as unknown as { props: Record<string, unknown> };
|
||||||
|
expect(on.props.fixed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Txt", () => {
|
||||||
|
it("sets lineHeight in pt, the family stack and the role metrics", () => {
|
||||||
|
const s = styleOf(Txt({ role: "body", children: "x" }));
|
||||||
|
expect(s.lineHeight).toBe("13.8pt");
|
||||||
|
expect(s.fontFamily).toEqual(stack("IBM Plex Sans"));
|
||||||
|
expect(s.fontSize).toBe(9.5);
|
||||||
|
expect(s.fontWeight).toBe(400);
|
||||||
|
expect(s.fontStyle).toBe("normal");
|
||||||
|
expect("letterSpacing" in s).toBe(false);
|
||||||
|
expect("textTransform" in s).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies tracking, caps and overrides", () => {
|
||||||
|
const s = styleOf(Txt({ role: "title", weight: 700, italic: true, color: "#111", align: "right", children: "x" }));
|
||||||
|
expect(s.letterSpacing).toBeCloseTo(1.5, 10);
|
||||||
|
expect(s.textTransform).toBe("uppercase");
|
||||||
|
expect(s.fontWeight).toBe(700);
|
||||||
|
expect(s.fontStyle).toBe("italic");
|
||||||
|
expect(s.color).toBe("#111");
|
||||||
|
expect(s.textAlign).toBe("right");
|
||||||
|
expect(s.lineHeight).toBe("22pt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins width, grows and clamps lines", () => {
|
||||||
|
const pinnedStyle = styleOf(Txt({ role: "body", width: 80, children: "x" }));
|
||||||
|
expect([pinnedStyle.width, pinnedStyle.minWidth, pinnedStyle.maxWidth]).toEqual([80, 80, 80]);
|
||||||
|
const grown = styleOf(Txt({ role: "body", grow: true, children: "x" }));
|
||||||
|
expect([grown.flexGrow, grown.flexBasis]).toEqual([1, 0]);
|
||||||
|
const clamped = styleOf(Txt({ role: "body", maxLines: 2, children: "x" }));
|
||||||
|
expect([clamped.maxLines, clamped.textOverflow]).toEqual([2, "ellipsis"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a lineHeight in the extra style and on a role without leading", () => {
|
||||||
|
expect(() => Txt({ role: "body", style: { lineHeight: 2 }, children: "x" })).toThrow(/lineHeight/);
|
||||||
|
expect(() => Txt({ role: "footer", children: "x" })).toThrow(/leading/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every leaded role has a positive leading and footer has none", () => {
|
||||||
|
for (const [name, t] of Object.entries(TYPE_ROLES)) {
|
||||||
|
if (name === "footer") expect("leading" in t).toBe(false);
|
||||||
|
else expect((t as { leading: number }).leading).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RenderTxt", () => {
|
||||||
|
it("never sets lineHeight and rejects one", () => {
|
||||||
|
const el = RenderTxt({ role: "footer", render: () => "x" }) as unknown as {
|
||||||
|
props: { style: Record<string, unknown>; render: unknown };
|
||||||
|
};
|
||||||
|
expect("lineHeight" in el.props.style).toBe(false);
|
||||||
|
expect(typeof el.props.render).toBe("function");
|
||||||
|
expect(() => RenderTxt({ role: "footer", render: () => "x", style: { lineHeight: 1 } })).toThrow(/lineHeight/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws page numbers on every page", async () => {
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
h(
|
||||||
|
Document,
|
||||||
|
null,
|
||||||
|
h(
|
||||||
|
Page,
|
||||||
|
{ size: { width: 300, height: 120 }, style: { padding: 20 } },
|
||||||
|
h(View, { fixed: true }, h(RenderTxt, { role: "footer", render: ({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages ?? "?"}` })),
|
||||||
|
h(View, { style: { height: 200 } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(pdf.pages.length).toBeGreaterThan(1);
|
||||||
|
pdf.pages.forEach((p, i) => {
|
||||||
|
expect(p.items.map((t) => t.str)).toEqual([`Page ${i + 1} of ${pdf.pages.length}`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Row", () => {
|
||||||
|
const tops = (pdf: Awaited<ReturnType<typeof renderPdfText>>, str: string) =>
|
||||||
|
pdf.pages[0].items.find((t) => t.str.trim() === str)!;
|
||||||
|
|
||||||
|
it("lays out side by side when the fill column keeps its minimum", async () => {
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
h(Row, { widths: [{ fixed: 100 }, { fill: true, min: 100 }], gap: 10 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const l = tops(pdf, "Left");
|
||||||
|
const r = tops(pdf, "Right");
|
||||||
|
expect(l.x0).toBeCloseTo(PAD, 1);
|
||||||
|
expect(r.x0).toBeCloseTo(PAD + 110, 1);
|
||||||
|
expect(Math.abs(l.top - r.top)).toBeLessThan(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks when the fill column would drop under its min", async () => {
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
h(Row, { widths: [{ fixed: 250 }, { fill: true, min: 160 }], gap: 10 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const l = tops(pdf, "Left");
|
||||||
|
const r = tops(pdf, "Right");
|
||||||
|
expect(r.x0).toBeCloseTo(PAD, 1);
|
||||||
|
expect(r.top).toBeGreaterThan(l.bottom - 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks below stackBelow", async () => {
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
h(Row, { widths: [{ fixed: 50 }, { fill: true }], stackBelow: 500 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(tops(pdf, "Right").top).toBeGreaterThan(tops(pdf, "Left").bottom - 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a children/spec mismatch", async () => {
|
||||||
|
await expect(
|
||||||
|
renderPdfText(frame(<Row widths={[{ fixed: 10 }, { fill: true }]}><Txt role="body">x</Txt></Row>)),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a 300-character address inside its cell and clear of its neighbours", async () => {
|
||||||
|
const address = Array.from({ length: 30 }, (_, i) => `Block${i} Road`).join(", ").slice(0, 300);
|
||||||
|
expect(address.length).toBe(300);
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<Row widths={[{ fill: true, min: 100 }, { fixed: 120 }]} gap={16}>
|
||||||
|
<Cell padding={{ right: 6 }}>
|
||||||
|
<Txt role="body">{address}</Txt>
|
||||||
|
</Cell>
|
||||||
|
<Txt role="body">INVOICE No. 42</Txt>
|
||||||
|
</Row>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assertNoOverlap(pdf.pages);
|
||||||
|
const fillWidth = PAGE_W - 2 * PAD - 120 - 16;
|
||||||
|
const cellRight = PAD + fillWidth - 6;
|
||||||
|
const items = pdf.pages[0].items;
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it.str.includes("INVOICE")) expect(it.x1, it.str).toBeLessThanOrEqual(cellRight + 0.5);
|
||||||
|
}
|
||||||
|
expect(items.find((t) => t.str.includes("INVOICE"))!.x0).toBeGreaterThanOrEqual(PAD + fillWidth + 16 - 0.5);
|
||||||
|
expect(items.length).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FitText and IdText", () => {
|
||||||
|
const EMAIL60 = "Sample.longsurname.voiceover.studio.department@examplemedia.co.in".slice(0, 60);
|
||||||
|
|
||||||
|
it("shrinks a medium e-mail to fit and keeps it on one line", async () => {
|
||||||
|
const email = "vendor.name@examplestudios.co.in";
|
||||||
|
let info!: FitInfo;
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<FitText role="body" width={215} onFit={(i) => (info = i)}>
|
||||||
|
{email}
|
||||||
|
</FitText>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(info.fits).toBe(true);
|
||||||
|
expect(info.size).toBeLessThan(9.5);
|
||||||
|
expect(info.size).toBeGreaterThanOrEqual(8.5);
|
||||||
|
const items = pdf.pages[0].items;
|
||||||
|
expect(items.map((t) => t.str).join("")).toBe(email);
|
||||||
|
expect(Math.max(...items.map((t) => t.x1))).toBeLessThanOrEqual(PAD + 215 + 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clips with an ellipsis when it cannot fit at the minimum size", async () => {
|
||||||
|
let info!: FitInfo;
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<FitText role="body" width={150} onFit={(i) => (info = i)}>
|
||||||
|
{EMAIL60}
|
||||||
|
</FitText>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(info.fits).toBe(false);
|
||||||
|
expect(info.size).toBeCloseTo(8.55, 5);
|
||||||
|
const items = pdf.pages[0].items;
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
expect(items[0].str.endsWith("…")).toBe(true);
|
||||||
|
expect(EMAIL60.startsWith(items[0].str.slice(0, -1))).toBe(true);
|
||||||
|
expect(items[0].x1).toBeLessThanOrEqual(PAD + 150 + 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a 60-character e-mail into chunks inside a 150 pt cell", async () => {
|
||||||
|
let info!: IdTextInfo;
|
||||||
|
const pdf = await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<Row widths={[{ fixed: 150 }, { fill: true }]}>
|
||||||
|
<IdText role="body" onFit={(i) => (info = i)}>
|
||||||
|
{EMAIL60}
|
||||||
|
</IdText>
|
||||||
|
<Txt role="body">Next</Txt>
|
||||||
|
</Row>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(info.mode).toBe("chunked");
|
||||||
|
expect(info.chunks.join("")).toBe(EMAIL60);
|
||||||
|
const idItems = pdf.pages[0].items.filter((t) => t.str !== "Next");
|
||||||
|
// Extraction joins the chunks with no inserted characters.
|
||||||
|
expect(idItems.map((t) => t.str).join("")).toBe(EMAIL60);
|
||||||
|
for (const t of idItems) expect(t.x1, t.str).toBeLessThanOrEqual(PAD + 150 + 0.5);
|
||||||
|
assertNoOverlap(pdf.pages);
|
||||||
|
const next = pdf.pages[0].items.find((t) => t.str === "Next")!;
|
||||||
|
expect(next.x0).toBeGreaterThanOrEqual(PAD + 150 - 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an identifier on one line when it already fits", async () => {
|
||||||
|
let info!: IdTextInfo;
|
||||||
|
await renderPdfText(
|
||||||
|
frame(
|
||||||
|
<Row widths={[{ fixed: 150 }, { fill: true }]}>
|
||||||
|
<IdText role="body" onFit={(i) => (info = i)}>
|
||||||
|
29ABCDE1234F1Z5
|
||||||
|
</IdText>
|
||||||
|
<Txt role="body">x</Txt>
|
||||||
|
</Row>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(info.mode).toBe("single");
|
||||||
|
expect(info.size).toBe(9.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Tracked", () => {
|
||||||
|
const inkRight = async (el: ReactElement) => {
|
||||||
|
const pdf = await renderPdfText(frame(h(View, { style: { width: 200 } }, el)));
|
||||||
|
return pdf.pages[0].items[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
it("puts the right ink edge on the untracked edge", async () => {
|
||||||
|
const plain = await inkRight(h(Txt, { role: { ...TYPE_ROLES.title, tracking: 0 }, align: "right" }, "Invoice"));
|
||||||
|
const tracked = await inkRight(h(Tracked, { role: "title", align: "right" }, "Invoice"));
|
||||||
|
const naive = await inkRight(h(Txt, { role: "title", align: "right" }, "Invoice"));
|
||||||
|
// pdf.js x1 is the end of the last glyph, without the trailing letter spacing.
|
||||||
|
expect(plain.x1).toBeCloseTo(PAD + 200, 1);
|
||||||
|
expect(Math.abs(tracked.x1 - plain.x1)).toBeLessThanOrEqual(0.5);
|
||||||
|
// Without compensation the ink ends one tracking step early; the test would otherwise prove nothing.
|
||||||
|
expect(plain.x1 - naive.x1).toBeGreaterThan(1.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centres the ink", async () => {
|
||||||
|
const t = await inkRight(h(Tracked, { role: "title", align: "center" }, "Invoice"));
|
||||||
|
expect((t.x0 + t.x1) / 2).toBeCloseTo(PAD + 100, 0);
|
||||||
|
const naive = await inkRight(h(Txt, { role: "title", align: "center" }, "Invoice"));
|
||||||
|
expect(Math.abs((t.x0 + t.x1) / 2 - (PAD + 100))).toBeLessThan(Math.abs((naive.x0 + naive.x1) / 2 - (PAD + 100)) + 1e-6);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user