Author SHA1 Message Date
xavierk 92904f169c Add unattended end-to-end self-test mode for the production webview
VOICED_SELFTEST_OUT runs the Diagnostics checks plus an issue, archive,
export (searchable, flattened, re-render), cancel and archive-immutability
flow through the real IPC, writes a JSON report and exits. Inert without
the variable; doubles as the per-release gate on installed builds.
2026-10-04 06:17:04 +05:30
xavierk f1b7502e9d Add searchable and flattened export, history re-export and diagnostics self-test
issueAndArchive keeps an issued invoice even when rendering or archiving
fails (Issued - not archived, with a Render & archive action). The export
menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is
the invoice number alone; flatten failures offer Retry or searchable export
and never silently lower the resolution. History re-exports the original
as issued by default and warns when a re-render's layout fingerprint
differs. The render client gains a FIFO assemble lane that previews cannot
supersede. A Diagnostics panel runs fixtures in the real worker and
compares against goldens generated from Node.
2026-10-04 06:11:23 +05:30
xavierk 7162dac6e7 Add PDF archive store, raw-body IPC and export file helpers
The searchable PDF can be archived content-addressed under the local data
dir with its sha256 and layout fingerprint; a different PDF for an archived
invoice is refused and reads verify the hash. Export writes go through a
raw-body command that remembers the last folder; reveal-in-folder and open
fall back to xdg-open / explorer. Adds a Windows-safe export file name
builder. Migration M4 adds the archive columns and last export dir.
2026-10-04 05:54:26 +05:30
xavierk cedf4839bd Replace the HTML preview with a worker-rendered PDF preview
Rendering moves into a module Web Worker behind a latest-wins RenderClient
with a watchdog and a handshake-based main-thread fallback. The preview
shows the real PDF through pdf.js at 2x with zoom, page indicator, audit
issues popover and a focus modal. A4/Letter and margin controls are frozen
into each invoice at issue and can be saved as the default (migration M3).
react-pdf and pdf.js are lazy-loaded; the main chunk shrinks from 1.8 MB
to 0.5 MB. The HTML twin and its styles are removed.
2026-10-04 05:47:19 +05:30
xavierk a014a907e4 Rebuild the Classic template on the engine with A4/Letter and a template harness
Classic now renders through buildRenderModel, computeFrame, the template
and renderCore, with S.No and HSN/SAC columns, GST slots only through
checked Slot components, taxable value, reverse-charge line, cancelled tag,
continuation band and Page n of N. Letter absorbs its height deficit in
elastic gaps. A harness runs 15 fixtures on A4 and Letter plus an item
sweep and asserts zero audit errors, repeated headers, no split blocks,
rupee extraction and deterministic output. The legacy InvoiceDocument is
removed.
2026-10-04 05:31:14 +05:30
xavierk 8d4f8e666b Add pagination blocks, renderCore, layout audit and react-pdf quirk tests
TableGuard, BottomSpacer, FirstPageHeader and PageChrome implement the
verified pagination rules; renderCore returns bytes, a normalised layout
tree, audit issues and a layout fingerprint. auditLayout detects overlaps,
ink overflow, truncation, header-only pages and split blocks. Quirk tests
pin the react-pdf behaviours the design depends on, and a child-process
test pins the page-level absolute Svg hang.
2026-10-04 05:05:51 +05:30
xavierk 40aa3e05d9 Add typography roles, text primitives, fontkit measurer and layout lint
Txt always sets an absolute line height; RenderTxt never does. Row pins
every cell and stacks when the fill column would be too narrow. IdText and
FitText shrink then chunk or truncate. The fontkit measurer matches react-pdf
text widths exactly. A TypeScript-AST lint test enforces the layout rules
(no lineHeight outside Txt, no non-literal fixed/wrap/minPresenceAhead,
flexGrow needs flexBasis, no stray absolute positioning).
2026-10-04 04:48:19 +05:30
xavierk 92ba3d9590 Add bundled TTF fonts, page geometry, column allocator and render model
Static TTF fonts (IBM Plex Sans/Mono, Noto Devanagari/Kannada) with a
fontkit-generated manifest, a verifier and OFL licences. Every family
registers a normal and italic source so fallback stacks cannot throw.
Adds A4/Letter geometry with margin presets, an exact column allocator,
deterministic Indian-grouping money and date formatting, text sanitising
and a pure buildRenderModel.
2026-10-04 04:38:03 +05:30
xavierk fbed18a0bd Add invoice drafts, read-only issued state and keyboard shortcuts
Views stay mounted so switching tabs no longer loses a half-typed invoice.
The form autosaves a draft after 1 s, drafts can be resumed or deleted,
and after issuing the form is read-only with Export again, Duplicate and
New invoice actions. A failed export keeps the issued invoice and offers a
retry instead of issuing again. Adds Ctrl+N/S/Enter/L and a shortcut sheet;
F5 and Ctrl+R are blocked in production builds.
2026-10-04 04:21:47 +05:30
xavierk 5597fb791d Add issue/cancel lifecycle, GST derivation and integer-paise totals
issue_invoice allocates the number, freezes vendor and bank details and
stores server-computed totals in one transaction. Issued invoices are
cancelled, not deleted. Tax heads derive from supplier state and place of
supply (a disagreeing choice is rejected); unregistered suppliers issue a
plain Invoice. Adds GSTIN checksum validation, canonical India Compliance
state list, server-side drafts, series validation, relative and
magic-byte-checked asset paths, and GST settings. PDF re-exports use the
frozen vendor snapshot.
2026-10-04 04:15:58 +05:30
xavierk d6ec6b2a17 Add versioned DB migrations, startup error dialog and native basics
rusqlite_migration with a VACUUM INTO backup before upgrading; M2 adds GST
registration, snapshot, cancellation and draft columns and backfills
snapshots for existing invoices. Startup failures now show a native dialog
instead of exiting silently. Adds single-instance and window-state plugins,
minimum window 960x600, embedBootstrapper, DMABUF workaround on Linux and
xbps dependency updates.
2026-10-04 04:04:34 +05:30
179 changed files with 17018 additions and 1707 deletions
+1 -1
View File
@@ -12,11 +12,11 @@
"@carbon/react": "^1.117.0",
"@carbon/styles": "^1.116.0",
"@ibm/plex": "^6.4.1",
"@napi-rs/canvas": "*",
"@react-pdf/renderer": "^4.9.0",
"@tauri-apps/api": "^2.12.1",
"@tauri-apps/plugin-dialog": "^2.8.1",
"@tauri-apps/plugin-opener": "^2.7.0",
"fontkit": "2.0.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-is": "^19.3.0"
+5 -1
View File
@@ -13,7 +13,10 @@
"tauri": "tauri",
"app:dev": "tauri dev",
"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": {
"@carbon/icons-react": "^11.89.0",
@@ -24,6 +27,7 @@
"@tauri-apps/api": "^2.12.1",
"@tauri-apps/plugin-dialog": "^2.8.1",
"@tauri-apps/plugin-opener": "^2.7.0",
"fontkit": "2.0.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-is": "^19.3.0"
+1 -1
View File
@@ -71,7 +71,7 @@ mkdir -p "$REPO"
-l "proprietary" \
-H "https://git.bongbetic.com/xavierk/Voiced" \
-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" \
--compression zstd \
"$STAGE"
+2 -2
View File
@@ -20,8 +20,8 @@ version=0.1.0
revision=1
archs="x86_64*"
hostmakedepends="rust nodejs pkg-config"
makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel openssl-devel"
depends="libwebkit2gtk41"
makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel"
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
short_desc="Offline invoicing app by Bongbetic"
maintainer="Xavier Karma <xavier@bongbetic.com>"
license="custom:proprietary"
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -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.
+93
View File
@@ -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.
+232
View File
@@ -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
}
]
}
]
}
+94
View File
@@ -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.
+94
View File
@@ -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.
+29
View File
@@ -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`,
);
+79
View File
@@ -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,
};
+76
View File
@@ -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`);
+55
View File
@@ -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 }));
+48 -1
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "adler2"
@@ -2887,6 +2887,16 @@ dependencies = [
"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]]
name = "rustc-hash"
version = "2.1.3"
@@ -3658,6 +3668,37 @@ dependencies = [
"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]]
name = "tauri-runtime"
version = "2.12.1"
@@ -4195,13 +4236,19 @@ version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"percent-encoding",
"rusqlite",
"rusqlite_migration",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-window-state",
"tempfile",
"thiserror 2.0.21",
"uuid",
]
+12 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
description = "Voiced — offline invoicing desktop app by Bongbetic"
authors = ["Bongbetic"]
edition = "2021"
rust-version = "1.77"
rust-version = "1.95"
# The `_lib` suffix keeps the library name distinct from the binary name on Windows.
[lib]
@@ -21,10 +21,21 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
rusqlite_migration = "2.6"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
thiserror = "2"
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]
# Production builds embed the frontend and serve it over the custom asset
+20 -1
View File
@@ -6,6 +6,25 @@
"permissions": [
"core: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/**" }
]
}
]
}
+289
View File
@@ -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"));
}
}
+183 -64
View File
@@ -1,8 +1,10 @@
use crate::AppState;
use base64::{engine::general_purpose::STANDARD, Engine};
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use tauri::State;
const FORMAT_ERROR: &str = "Use a PNG or JPEG image";
fn safe_kind(kind: &str) -> String {
kind.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
@@ -10,47 +12,88 @@ fn safe_kind(kind: &str) -> String {
.to_lowercase()
}
fn sanitize_name(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
.collect()
}
fn extension_of(path: &str) -> String {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.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",
/// Identify an image by its magic bytes. react-pdf only renders PNG and JPEG and
/// silently drops everything else, so nothing else is accepted.
/// Returns (extension, MIME type).
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"))
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Ok(("jpg", "image/jpeg"))
} else {
Err(FORMAT_ERROR.to_string())
}
}
fn assets_dir(state: &State<AppState>) -> Result<PathBuf, String> {
let dir = state.data_dir.join("assets");
fn assets_dir(data_dir: &Path) -> Result<PathBuf, String> {
let dir = data_dir.join("assets");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn write_asset(state: &State<AppState>, kind: &str, ext: &str, bytes: &[u8]) -> Result<String, String> {
let dir = assets_dir(state)?;
let file = format!(
"{}-{}.{}",
safe_kind(kind),
uuid::Uuid::new_v4().simple(),
ext
);
let path = dir.join(file);
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
Ok(path.to_string_lossy().to_string())
/// Resolve a stored asset path (relative to the data dir, or a legacy absolute one)
/// to a real file inside `<data_dir>/assets`. `..`, symlink escapes and anything
/// outside the assets directory are rejected.
pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
let raw = Path::new(path);
if path.trim().is_empty() {
return Err("Asset path is empty".into());
}
if raw.components().any(|c| matches!(c, Component::ParentDir)) {
return Err("Asset path must not contain '..'".into());
}
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.
@@ -61,56 +104,132 @@ pub fn import_asset(
kind: String,
) -> Result<String, String> {
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
let ext = extension_of(&source_path);
write_asset(&state, &kind, &ext, &bytes)
write_asset(&state.data_dir, &kind, &bytes)
}
/// 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]
pub fn save_asset_bytes(
state: State<AppState>,
kind: String,
file_name: String,
#[allow(unused_variables)] file_name: String,
data_base64: String,
) -> Result<String, String> {
let bytes = STANDARD
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?;
let ext = extension_of(&sanitize_name(&file_name));
write_asset(&state, &kind, &ext, &bytes)
write_asset(&state.data_dir, &kind, &bytes)
}
/// Read a stored asset back as a data URI so it can be embedded in the PDF.
#[tauri::command]
pub fn read_asset_data_uri(path: String) -> Result<String, String> {
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
let ext = extension_of(&path);
Ok(format!(
"data:{};base64,{}",
mime_for(&ext),
STANDARD.encode(bytes)
))
pub fn read_asset_data_uri(state: State<AppState>, path: String) -> Result<String, String> {
asset_data_uri(&state.data_dir, &path)
}
#[tauri::command]
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
let dir = assets_dir(&state)?;
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(())
remove_asset_file(&state.data_dir, &path)
}
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path.
#[tauri::command]
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> {
let bytes = STANDARD
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?;
if let Some(parent) = Path::new(&path).parent() {
std::fs::create_dir_all(parent).ok();
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F'];
#[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())
}
+211
View File
@@ -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()]);
}
}
+785 -64
View File
@@ -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::models::{Invoice, InvoiceInput, InvoiceItem, InvoiceSummary};
use crate::gst::{self, TaxType};
use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
};
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;
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,
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> {
Ok(Invoice {
@@ -38,13 +48,23 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
status: row.get(24)?,
created_at: row.get(25)?,
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(),
})
}
fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<InvoiceItem>> {
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",
)?;
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)?,
amount: row.get(6)?,
sort_order: row.get(7)?,
hsn_sac: row.get(8)?,
})
})?;
rows.collect()
@@ -90,31 +111,224 @@ pub fn peek_next_invoice_number(state: State<AppState>) -> Result<String, String
}
}
#[tauri::command]
pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Invoice, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
let tx = conn.transaction().map_err(|e| e.to_string())?;
fn paise_to_rupees(paise: i64) -> f64 {
paise as f64 / 100.0
}
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(
"SELECT id, prefix, padding, next_number FROM invoice_series
WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
[],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
},
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.optional()
.map_err(|e| e.to_string())?
.map_err(db)?
.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);
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.
let client_id = match input.client_id {
@@ -126,65 +340,78 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
params![
input.client_name,
input.client_address,
input.client_gstin,
input.place_of_supply_state_code,
client_gstin,
stored_pos,
input.po_number,
chrono::Utc::now().to_rfc3339()
now
],
)
.map_err(|e| e.to_string())?;
.map_err(db)?;
Some(tx.last_insert_rowid())
}
None => None,
};
let now = chrono::Utc::now().to_rfc3339();
tx.execute(
r#"INSERT INTO invoices
(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,
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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21, ?22, ?23, 'issued', ?24, ?24)"#,
params![
number,
series_id,
input.invoice_date,
input.due_date,
client_id,
input.client_name,
input.client_address,
input.client_gstin,
input.po_number,
input.place_of_supply_state_code,
input.subtotal,
input.discount,
input.tax_type,
input.tax_rate,
input.cgst_amount,
input.sgst_amount,
input.igst_amount,
input.total,
input.amount_in_words,
input.bank_account_id,
input.bank_snapshot,
input.signature_path,
input.notes,
now,
],
bank_account_id, bank_snapshot, signature_path, notes, status, doc_type,
reverse_charge, vendor_snapshot, render_prefs, snapshot_origin, created_at, updated_at)
VALUES (:number, :series_id, :invoice_date, :due_date, :client_id, :client_name,
:client_address, :client_gstin, :po_number, :pos, :subtotal, :discount,
:tax_type, :tax_rate, :cgst, :sgst, :igst, :total, :words, :bank_id,
:bank_snapshot, :signature_path, :notes, 'issued', :doc_type,
:reverse_charge, :vendor_snapshot, :render_prefs, 'issued', :now, :now)"#,
named_params! {
":number": number,
":series_id": series_id,
":invoice_date": input.invoice_date.trim(),
":due_date": input.due_date.trim(),
":client_id": client_id,
":client_name": input.client_name,
":client_address": input.client_address,
":client_gstin": client_gstin,
":po_number": input.po_number,
":pos": stored_pos,
":subtotal": paise_to_rupees(totals.subtotal),
":discount": paise_to_rupees(totals.discount),
":tax_type": derived.as_str(),
":tax_rate": tax_rate,
":cgst": paise_to_rupees(totals.cgst),
":sgst": paise_to_rupees(totals.sgst),
":igst": paise_to_rupees(totals.igst),
":total": paise_to_rupees(totals.total),
":words": words,
":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 mut stmt = tx
.prepare(
"INSERT INTO invoice_items
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac)
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() {
stmt.execute(params![
invoice_id,
@@ -193,10 +420,11 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
item.rate,
item.unit,
item.quantity,
item.amount,
paise_to_rupees(gst::line_amount_paise(item)),
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",
params![series_id],
)
.map_err(|e| e.to_string())?;
.map_err(db)?;
tx.commit().map_err(|e| e.to_string())?;
if let Some(draft_id) = input.draft_id {
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
.map_err(db)?;
}
fetch_invoice(&conn, invoice_id).map_err(|e| e.to_string())
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())?;
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())
}
#[tauri::command]
pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result<Invoice, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
cancel_invoice_impl(&mut conn, id, &reason)
}
#[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())
}
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]
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())?;
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())?;
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());
}
}
+3
View File
@@ -1,5 +1,8 @@
pub mod archive;
pub mod assets;
pub mod clients;
pub mod files;
pub mod invoice;
pub mod raw;
pub mod series;
pub mod settings;
+92
View File
@@ -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);
}
}
+129 -21
View File
@@ -1,7 +1,7 @@
use crate::db::format_number;
use crate::models::InvoiceSeries;
use crate::AppState;
use rusqlite::{params, Row};
use rusqlite::{params, Connection, Row};
use tauri::State;
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)
}
/// 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.
/// 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]
pub fn start_new_series(
state: State<AppState>,
prefix: String,
padding: i64,
) -> Result<InvoiceSeries, String> {
let prefix = prefix.trim().to_string();
if prefix.is_empty() {
return Err("Series prefix cannot be empty".into());
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
start_new_series_impl(&mut conn, &prefix, padding)
}
#[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())?;
conn.execute("UPDATE invoice_series SET is_active = 0", [])
.map_err(|e| e.to_string())?;
conn.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 = conn.last_insert_rowid();
conn.query_row(
&format!("{} WHERE id = ?1", SERIES_SELECT),
params![id],
map_series,
)
.map_err(|e| e.to_string())
#[test]
fn prefix_charset_and_length_rules() {
assert!(validate_series_format("INV/2026-", 3).is_ok());
assert!(validate_series_format("INV", 1).is_ok());
assert!(validate_series_format("", 3).is_err());
assert!(validate_series_format("-AP", 3).is_err());
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;",
)
.unwrap();
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
let active: i64 = c
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(active, 1);
}
}
+127 -10
View File
@@ -1,13 +1,14 @@
use crate::models::{BankAccount, Settings};
use crate::AppState;
use rusqlite::{params, Row};
use rusqlite::{params, Connection, Row};
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,
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 {
vendor_name: row.get(0)?,
vendor_address: row.get(1)?,
@@ -25,6 +26,12 @@ fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
currency: row.get(13)?,
onboarded: row.get::<_, i64>(14)? != 0,
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,
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
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"#,
params![
settings.vendor_name,
@@ -67,6 +75,11 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
settings.currency,
if settings.onboarded { 1 } else { 0 },
settings.theme,
settings.gst_registration,
settings.default_hsn_sac,
settings.signatory_name,
settings.signatory_designation,
settings.render_prefs,
],
)
.map_err(|e| e.to_string())?;
@@ -74,7 +87,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
get_settings(state)
}
fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
pub(crate) fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
Ok(BankAccount {
id: Some(row.get(0)?),
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())
}
/// 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]
pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
delete_bank_impl(&mut conn, id)
}
#[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
View File
@@ -1,7 +1,29 @@
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#"
CREATE TABLE IF NOT EXISTS app_settings (
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);
"#;
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
let conn = Connection::open(path)?;
/// Version 2: GST registration, invoice snapshots/cancellation fields, HSN/SAC and drafts.
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, "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)?;
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.
/// 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<()> {
let settings_count: i64 =
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,
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
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![
"Test Vendor",
"1 Test Road, Bangalore - 560001",
@@ -169,7 +315,476 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
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".
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
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");
}
}
}
+506
View File
@@ -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
View File
@@ -1,34 +1,107 @@
mod commands;
mod db;
mod gst;
mod models;
mod selftest;
use rusqlite::Connection;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
pub struct AppState {
pub db: Mutex<Connection>,
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)]
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_dialog::init())
.setup(|app| {
let data_dir = app
.path()
.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,
});
let state = init_state(app)?;
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -44,16 +117,32 @@ pub fn run() {
commands::clients::save_client,
commands::clients::delete_client,
commands::invoice::peek_next_invoice_number,
commands::invoice::create_invoice,
commands::invoice::issue_invoice,
commands::invoice::cancel_invoice,
commands::invoice::list_invoices,
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::save_asset_bytes,
commands::assets::read_asset_data_uri,
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!())
.expect("error while running the Voiced application");
.run(tauri::generate_context!());
if let Err(e) = result {
eprintln!("error while running the Voiced application: {e}");
std::process::exit(1);
}
}
+40 -13
View File
@@ -19,6 +19,16 @@ pub struct Settings {
pub currency: String,
pub onboarded: bool,
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)]
@@ -92,6 +102,8 @@ pub struct InvoiceItem {
pub amount: f64,
#[serde(default)]
pub sort_order: i64,
#[serde(default)]
pub hsn_sac: String,
}
fn default_mode() -> String {
@@ -102,6 +114,8 @@ fn default_unit() -> 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)]
#[serde(rename_all = "camelCase")]
pub struct InvoiceInput {
@@ -121,34 +135,25 @@ pub struct InvoiceInput {
#[serde(default)]
pub place_of_supply_state_code: String,
#[serde(default)]
pub subtotal: f64,
#[serde(default)]
pub discount: f64,
/// The tax treatment the user chose; it must match the derived one.
#[serde(default = "default_tax_type")]
pub tax_type: String,
#[serde(default)]
pub tax_rate: f64,
#[serde(default)]
pub cgst_amount: f64,
#[serde(default)]
pub sgst_amount: f64,
#[serde(default)]
pub igst_amount: f64,
#[serde(default)]
pub total: f64,
#[serde(default)]
pub amount_in_words: String,
pub reverse_charge: bool,
#[serde(default)]
pub bank_account_id: Option<i64>,
#[serde(default)]
pub bank_snapshot: String,
#[serde(default)]
pub signature_path: Option<String>,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub save_client: bool,
#[serde(default)]
pub draft_id: Option<i64>,
#[serde(default)]
pub items: Vec<InvoiceItem>,
}
@@ -183,9 +188,22 @@ pub struct Invoice {
pub bank_snapshot: String,
pub signature_path: Option<String>,
pub notes: String,
/// draft | issued | cancelled
pub status: String,
pub created_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>,
}
@@ -201,3 +219,12 @@ pub struct InvoiceSummary {
pub status: 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>,
}
+46
View File
@@ -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}");
}
}
+3 -3
View File
@@ -15,8 +15,8 @@
"title": "Voiced",
"width": 1360,
"height": 900,
"minWidth": 1024,
"minHeight": 720,
"minWidth": 960,
"minHeight": 600,
"resizable": true,
"center": true
}
@@ -41,7 +41,7 @@
],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
"type": "embedBootstrapper"
},
"nsis": {
"installMode": "currentUser"
+13 -3
View File
@@ -88,9 +88,19 @@ export default function App() {
return (
<Theme theme={theme}>
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
{view === "new" && <NewInvoice settings={settings} onSettingsChange={refreshSettings} />}
{view === "invoices" && <InvoiceHistory settings={settings} />}
{view === "series" && <SeriesSettings />}
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
<NewInvoice
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} />}
</AppShell>
</Theme>
+115
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+84
View File
@@ -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}
</>
);
}
+64
View File
@@ -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>
);
}
-192
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+341
View File
@@ -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>
);
}
+94
View File
@@ -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],
);
}
+74
View File
@@ -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;
}
+62
View File
@@ -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
View File
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import type {
BankAccount,
Client,
DraftSummary,
Invoice,
InvoiceInput,
InvoiceSeries,
@@ -9,6 +10,13 @@ import type {
Settings,
} from "./types";
export interface ArchiveStatus {
archived: boolean;
sha256?: string;
fingerprint?: string;
archivedAt?: string;
}
export const api = {
getSettings: () => invoke<Settings>("get_settings"),
saveSettings: (settings: Settings) => invoke<Settings>("save_settings", { settings }),
@@ -27,10 +35,18 @@ export const api = {
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
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"),
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) =>
invoke<string>("import_asset", { sourcePath, kind }),
@@ -38,6 +54,19 @@ export const api = {
invoke<string>("save_asset_bytes", { kind, fileName, dataBase64 }),
readAssetDataUri: (path: string) => invoke<string>("read_asset_data_uri", { 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 }),
};
+57
View File
@@ -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(() => {});
}
}
+72
View File
@@ -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: "" });
});
});
+92
View File
@@ -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),
};
}
+25
View File
@@ -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,
};
}
+223
View File
@@ -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" });
});
});
+219
View File
@@ -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);
}
}
+27
View File
@@ -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)",
};
+131
View File
@@ -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$/);
});
});
+71
View File
@@ -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`;
}
+58
View File
@@ -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
View File
@@ -1,12 +1,23 @@
import { round2 } from "./format";
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. */
export function computeLineAmount(item: InvoiceItem): number {
if (item.mode === "rate") {
return round2((item.rate || 0) * (item.quantity || 0));
}
return round2(item.amount || 0);
return linePaise(item) / 100;
}
export interface Totals {
@@ -26,29 +37,30 @@ export function computeTotals(
taxType: TaxType,
taxRate: number,
): Totals {
const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0));
const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal));
const taxable = round2(subtotal - discountValue);
const subtotal = items.reduce((sum, item) => sum + linePaise(item), 0);
const discountPaise = Math.min(Math.max(toPaise(discount), 0), Math.max(subtotal, 0));
const taxable = subtotal - discountPaise;
const rateBp = Math.round((Number.isFinite(taxRate) ? taxRate : 0) * 100);
let cgst = 0;
let sgst = 0;
let igst = 0;
if (taxType === "cgst_sgst") {
cgst = round2((taxable * (taxRate / 2)) / 100);
sgst = round2((taxable * (taxRate / 2)) / 100);
cgst = divRound(taxable * rateBp, 20_000);
sgst = cgst;
} 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 {
subtotal,
discount: discountValue,
taxable,
cgst,
sgst,
igst,
taxTotal,
total: round2(taxable + taxTotal),
subtotal: subtotal / 100,
discount: discountPaise / 100,
taxable: taxable / 100,
cgst: cgst / 100,
sgst: sgst / 100,
igst: igst / 100,
taxTotal: taxTotal / 100,
total: (taxable + taxTotal) / 100,
};
}
+60 -84
View File
@@ -1,96 +1,72 @@
import { pdf } from "@react-pdf/renderer";
import { save } from "@tauri-apps/plugin-dialog";
import { registerPdfFonts } from "../pdf/fonts";
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
import pkg from "../../package.json";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
import { vendorFromSnapshot } from "../pdf/model/vendor";
import { Superseded } from "../pdf/render/client";
import type { RenderResult } from "../pdf/render/jobs";
import { api } from "./api";
import { imageSizeFromDataUri } from "./imageSize";
import { getRenderClient, nextRenderJobId } from "./renderClient";
import type { BankAccount, Invoice, Settings } from "./types";
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
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);
});
}
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
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,
settings: Settings,
bank: BankAccount | null,
): Promise<InvoicePdfProps> {
const logoDataUri = settings.logoPath ? await api.readAssetDataUri(settings.logoPath) : null;
const signatureDataUri = invoice.signaturePath
? await api.readAssetDataUri(invoice.signaturePath)
: null;
logoUri: string | null,
signatureDataUri: string | null,
): RenderModel {
return buildRenderModel({
invoice,
vendor: settings,
bank,
logo: logoUri ? { dataUri: logoUri, ...(imageSizeFromDataUri(logoUri) ?? UNKNOWN_LOGO_SIZE) } : null,
signatureDataUri,
appVersion: pkg.version,
});
}
let resolvedBank = bank;
if (!resolvedBank && invoice.bankSnapshot) {
/** 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,
settings: Settings,
bank: BankAccount | null,
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
): Promise<RenderResult> {
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 {
resolvedBank = JSON.parse(invoice.bankSnapshot) as BankAccount;
} catch {
resolvedBank = null;
return await getRenderClient().render({ id: nextRenderJobId(), model, prefs });
} catch (err) {
if (!(err instanceof Superseded) || attempt >= 4) throw err;
}
}
return {
vendor: settings,
bank: resolvedBank,
logoDataUri,
signatureDataUri,
number: invoice.number,
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(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
): Promise<string | null> {
const props = await buildPdfProps(invoice, settings, bank);
return exportPdfFromProps(props, invoice.number);
}
+50
View File
@@ -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";
+98
View File
@@ -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,
};
}
+12
View File
@@ -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;
+87
View File
@@ -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");
});
});
+289
View File
@@ -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");
}
+57
View File
@@ -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,
};
}
+152
View File
@@ -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" };
});
}
+33
View File
@@ -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);
});
});
+51
View File
@@ -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;
}
+25
View File
@@ -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
View File
@@ -19,6 +19,14 @@ export interface Settings {
currency: string;
onboarded: boolean;
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 {
@@ -61,9 +69,41 @@ export interface InvoiceItem {
quantity: number;
amount: 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 {
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;
dueDate: string;
clientId: number | null;
@@ -85,18 +125,30 @@ export interface InvoiceInput {
bankSnapshot: string;
signaturePath: string | null;
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[];
}
export interface Invoice extends Omit<InvoiceInput, "saveClient"> {
export interface DraftSummary {
id: number;
number: string;
seriesId: number | null;
status: string;
createdAt: string;
updatedAt: string;
items: InvoiceItem[];
clientName: string | null;
total: number | null;
}
export interface InvoiceSummary {
@@ -106,7 +158,7 @@ export interface InvoiceSummary {
dueDate: string;
clientName: string;
total: number;
status: string;
status: InvoiceStatus;
createdAt: string;
}
@@ -118,10 +170,12 @@ export const EMPTY_ITEM: InvoiceItem = {
quantity: 1,
amount: 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 }[] = [
{ code: "01", name: "Jammu & Kashmir" },
{ code: "01", name: "Jammu and Kashmir" },
{ code: "02", name: "Himachal Pradesh" },
{ code: "03", name: "Punjab" },
{ code: "04", name: "Chandigarh" },
@@ -145,18 +199,20 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "22", name: "Chhattisgarh" },
{ code: "23", name: "Madhya Pradesh" },
{ 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: "29", name: "Karnataka" },
{ code: "30", name: "Goa" },
{ code: "31", name: "Lakshadweep" },
{ code: "31", name: "Lakshadweep Islands" },
{ code: "32", name: "Kerala" },
{ code: "33", name: "Tamil Nadu" },
{ code: "34", name: "Puducherry" },
{ code: "35", name: "Andaman & Nicobar Islands" },
{ code: "35", name: "Andaman and Nicobar Islands" },
{ code: "36", name: "Telangana" },
{ code: "37", name: "Andhra Pradesh" },
{ code: "38", name: "Ladakh" },
{ code: "96", name: "Other Countries" },
{ code: "97", name: "Other Territory" },
];
export const UNIT_LABELS: Record<LineUnit, string> = {
+27
View File
@@ -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();
});
});
+44
View File
@@ -22,6 +22,50 @@ export function gstinError(value: string): string | undefined {
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 {
if (!value.trim()) return undefined;
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
+3
View File
@@ -2,9 +2,12 @@ import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/carbon.scss";
import { maybeRunSelfTest } from "./selftestBoot";
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
void maybeRunSelfTest();
-291
View File
@@ -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");
});
});
-450
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+37
View File
@@ -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 },
}),
),
),
);
}
+15
View File
@@ -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;
}
+13
View File
@@ -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 })} />;
}
+52
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+187
View File
@@ -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>
);
}
+154
View File
@@ -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>
)}
</>
);
}
+35
View File
@@ -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 })} />;
}
+105
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+19
View File
@@ -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 })} />;
}
+25
View File
@@ -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 }} />;
}
+74
View File
@@ -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>;
}
+314
View File
@@ -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