20 Commits
Author SHA1 Message Date
xavierkandClaude Sonnet 5.5 8122129a19 Add Reset all data to Settings > Data and release 1.2.0
Windows build / build (push) Waiting to run
CI / security (pull_request) Successful in 40s
CI / lint (pull_request) Successful in 2m50s
CI / e2e (pull_request) Successful in 3m3s
CI / ai-review (pull_request) Failing after 10m2s
Reset all data stages a wipe behind a typed RESET confirmation and applies it
on the next start, before the database is opened. It deletes the database,
assets, PDF archive, imported fonts and every backup, then the setup wizard
shows again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:29:52 +05:30
xavierk 93a4ece886 Run cargo fmt on the unit code and drop a quantity box's invalid mark when its line is removed
Windows build / build (push) Waiting to run
CI / security (pull_request) Successful in 58s
CI / lint (pull_request) Successful in 2m51s
CI / e2e (pull_request) Successful in 3m34s
CI / ai-review (pull_request) Failing after 10m2s
The new CI runs cargo fmt --check. QuantityInput also reported its validity only when it changed, so removing a line
whose quantity box held bad text left that box counted as invalid and blocked issuing; it now reports valid when it
unmounts, and no longer writes a ref during render.

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:47:43 +05:30
xavierk d1976499a2 Merge origin/main (CI quality gates) into the 1.1.0 release
# Conflicts:
#	package-lock.json
#	src/views/NewInvoice.tsx
2026-10-06 09:43:47 +05:30
xavierk 0faf3d63ee Release 1.1.0
Version bump, the README line about units, and two new manual checks in the release checklist (export then New invoice
through the real save dialog, and units).

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:35:26 +05:30
xavierk 3aa0d33a01 Add units of measure and fractional quantities to rate lines
Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.

- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
  characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
  allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
  same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
  priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
  custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
  Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
  and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
  push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:35:20 +05:30
xavierk cd067ad584 Fix the blank window after an export
Every successful export left the window blank and unresponsive, so the app had to be killed. The "Saved to ..." notice
(and the export-failed notice) held buttons inside a Carbon InlineNotification. Carbon throws "component should have no
interactive child nodes" in an effect, and an uncaught throw makes React unmount the whole tree. Found by driving the
real debug binary through WebKitWebDriver: the page body was empty and the error listener showed that throw.

- The buttons now sit beside the notice, and the failed-export notice uses ActionableNotification's own Retry action.
- ErrorBoundary around the app (reload screen instead of a blank window) and around each export notice.
- A lint test that fails if a Carbon notification wraps an interactive element.
- The real-webview self-test mounts the saved and failed notices and fails on an uncaught React error, and issues and
  exports a second invoice after the first.

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:35:10 +05:30
xavierk 66a39b2291 Fix clipped text in the invoice form
The saved-client box showed "Timber Media Pv" for "Timber Media Pvt Ltd": the form sat on a Carbon Grid nested in a
Column, whose tracks came out much narrower than the page grid, so the combo box was about 230 px wide at the default
window size and the date, GSTIN and PO fields were just as tight. The fields now sit on a plain 12-column CSS grid
(.voiced-fields), the client fields use the full width, place of supply gets its own row, and the line type select is
wide enough for "Rate x quantity".

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
2026-10-06 09:35:02 +05:30
xavierk 2083d2ff6a Merge pull request 'ci: add quality gates (semgrep, lint, e2e smoke, advisory PR-Agent)' (#1) from ci/quality-gates into main
CI / security (push) Successful in 37s
CI / lint (push) Successful in 2m48s
CI / e2e (push) Successful in 2m33s
CI / ai-review (push) Skipped
2026-10-05 14:27:40 +00:00
soubarna aa83505997 fix(ci): drop --entrypoint override on the ai-review job container
CI / security (pull_request) Successful in 39s
CI / lint (pull_request) Successful in 2m51s
CI / e2e (pull_request) Successful in 3m22s
CI / ai-review (pull_request) Successful in 1s
The runner starts job containers with entrypoint /bin/sleep 3600 and copies the workspace in. An empty --entrypoint override made the pr-agent container exit at once (RWLayer is unexpectedly nil). The image already has pr-agent on PATH.
2026-10-05 17:38:49 +05:30
soubarna 65f3615661 chore: semgrep triage
Pass a constant format string to console.log in compare.mjs (same output).
Annotate the mutable action tags in the Windows fallback workflow with
nosemgrep; workflow behaviour is unchanged.
2026-10-05 17:29:03 +05:30
soubarna 5b18db3a77 fix: eslint findings
Remove dead code and unused imports, drop useless assignments, and tune
rules in eslint.config.js (underscore-prefixed unused names, intentional BOM/NBSP,
React Compiler rules from eslint-plugin-react-hooks 7 as warnings).
2026-10-05 17:29:03 +05:30
soubarna 9ba00045c9 ci: add quality-gate workflow, ESLint and Playwright smoke test 2026-10-05 17:29:03 +05:30
soubarna b6cf46b883 style(src-tauri): cargo fmt
Pure mechanical rustfmt reformat (default config, no rustfmt.toml).
2026-10-05 17:19:13 +05:30
xavierk e1031b3ecc Remove the remaining references to the source templates from the README and release notes
Windows build / build (push) Canceled after 0s
2026-10-05 12:37:42 +05:30
xavierk 4960e26d8c Add the Apache-2.0 licence, NOTICE and contributing guide 2026-10-05 12:21:37 +05:30
xavierk 3cb0ac9236 Refresh the README for 1.0.2: setup wizard, install steps, Windows status
Windows build / build (push) Canceled after 0s
2026-10-05 12:07:36 +05:30
xavierk 9bdb142090 Release 1.0.2 2026-10-05 10:19:05 +05:30
xavierk c78bf1a851 Replace the sample data with synthetic fixtures and regenerate goldens and thumbnails
The template harness, tests and thumbnails now use a neutral vendor, bank account and
a generated sample wordmark (scripts/logo/make-sample-logo.mjs). Layout goldens and the
14 picker thumbnails are regenerated. Fixture strings keep their original lengths where
layout tests depend on them.
2026-10-05 10:18:53 +05:30
xavierk c3e2e05e89 Start the setup wizard blank and add a Run setup again button
The first-run seed no longer inserts the original vendor's name, address, contact
details, PAN or bank account; the wizard now starts empty and requires a name and
state. Settings > Data gets a Run setup again button that clears the onboarded flag.
Re-running the wizard keeps the active invoice series unless the prefix or padding
changes. The sample logo moves out of public/ so it no longer ships in the app.
2026-10-05 09:39:02 +05:30
xavierk e786580d42 Release 1.0.1: rename Create bank account button to Add bank account 2026-10-05 08:38:41 +05:30
133 changed files with 8198 additions and 1402 deletions
+107
View File
@@ -0,0 +1,107 @@
name: CI
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
# Static analysis. Also the only job that runs on the weekly schedule, so newly published
# Semgrep registry rules are applied to main even when nothing is pushed.
security:
runs-on: bongbetic-ci
timeout-minutes: 15
container:
image: docker.io/semgrep/semgrep:1.178.0
steps:
- name: Checkout
run: |
git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \
&& git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \
&& git checkout -q FETCH_HEAD
- name: Semgrep
run: semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
lint:
if: gitea.event_name != 'schedule'
runs-on: bongbetic-ci
timeout-minutes: 20
steps:
- name: Checkout
run: |
git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \
&& git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \
&& git checkout -q FETCH_HEAD
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npx tsc --noEmit
- name: ESLint
run: npx eslint .
- name: Rust format check
# rustfmt only. clippy is deliberately not run: it needs the webkit2gtk/gtk system libraries
# and a full compile of the Tauri crate, which is too heavy for the shared CI host.
working-directory: src-tauri
run: |
base=https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu
curl --proto '=https' --tlsv1.2 -sSfO "$base/rustup-init" -O "$base/rustup-init.sha256"
sha256sum -c rustup-init.sha256
chmod +x rustup-init
./rustup-init -y --no-modify-path --profile minimal --default-toolchain 1.99.0 -c rustfmt
rm -f rustup-init rustup-init.sha256
"$HOME/.cargo/bin/cargo" fmt --check
- name: Duplicate code (jscpd)
run: npx --yes jscpd@4.3.0
e2e:
if: gitea.event_name != 'schedule'
runs-on: bongbetic-ci
timeout-minutes: 25
container:
# Keep this tag in step with the @playwright/test version in package.json.
image: mcr.microsoft.com/playwright:v1.63.0-noble
env:
CI: "true"
steps:
- name: Checkout
run: |
git init -q . && git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git" \
&& git -c http.extraheader="Authorization: token ${{ gitea.token }}" fetch -q --depth=1 origin "${{ gitea.sha }}" \
&& git checkout -q FETCH_HEAD
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Playwright smoke tests
run: npx playwright test
# Advisory only: never blocks a merge.
ai-review:
if: gitea.event_name == 'pull_request'
runs-on: bongbetic-ci
timeout-minutes: 10
continue-on-error: true
container:
image: docker.io/pragent/pr-agent:0.47.0
env:
config__git_provider: gitea
gitea__url: https://git.bongbetic.com
gitea__personal_access_token: ${{ secrets.PR_AGENT_GITEA_TOKEN }}
openrouter__key: ${{ secrets.OPENROUTER_API_KEY }}
config__model: ${{ vars.PR_AGENT_MODEL || 'openrouter/anthropic/claude-sonnet-5' }}
# PR-Agent needs this for OpenRouter models it has no built-in context window for.
config__custom_model_max_tokens: "200000"
steps:
- name: PR-Agent review
run: |
if [ -z "$gitea__personal_access_token" ] || [ -z "$openrouter__key" ]; then
echo "::notice::PR-Agent secrets not configured; skipping AI review."
exit 0
fi
pr-agent --pr_url="${{ gitea.event.pull_request.html_url }}" review
+4 -4
View File
@@ -13,13 +13,13 @@ jobs:
build:
runs-on: windows
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@stable # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
- name: Install Node
uses: actions/setup-node@v4
uses: actions/setup-node@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
with:
node-version: 20
@@ -30,7 +30,7 @@ jobs:
run: npm run app:build
- name: Upload installer
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4 # nosemgrep: github-actions-mutable-action-tag -- fallback workflow, behavior unchanged
with:
name: voiced-windows
path: src-tauri/target/release/bundle/nsis/*.exe
+4
View File
@@ -31,3 +31,7 @@ xwin/
CLAUDE.md
handoff.md
.commandcode/
# Playwright
test-results/
playwright-report/
+21
View File
@@ -0,0 +1,21 @@
{
"threshold": 4.5,
"reporters": ["console"],
"absolute": false,
"gitignore": true,
"path": ["."],
"ignore": [
"**/node_modules/**",
"**/package-lock.json",
"**/Cargo.lock",
"**/dist/**",
"**/target/**",
"public/**",
"docs/**",
"src-tauri/gen/**",
"src-tauri/icons/**",
"**/*.{woff,woff2,ttf,otf,png,jpg,jpeg,gif,ico,icns,svg,pdf}",
"**/playwright-report/**",
"**/test-results/**"
]
}
+50
View File
@@ -0,0 +1,50 @@
# CI
Workflow: `.gitea/workflows/ci.yml`, runner label `bongbetic-ci`. It runs on pull requests, pushes to `main` and manual dispatch. A weekly schedule (Monday 03:00 UTC) runs the `security` job only.
## Jobs
| Job | What it runs | Blocks merge? |
| --- | --- | --- |
| `security` | Semgrep (`p/default`, `p/owasp-top-ten`), fails on any finding | Yes |
| `lint` | `npm ci`, `tsc --noEmit`, ESLint, `cargo fmt --check` (src-tauri), jscpd (threshold 4.5%) | Yes |
| `e2e` | `npm run build`, then the Playwright smoke test (chromium) against `vite preview` | Yes |
| `ai-review` | PR-Agent review comment, pull requests only | No (advisory, `continue-on-error`) |
There is no deploy job: Voiced ships release assets, not a hosted app.
Semgrep registry rules are fetched at run time, so a new rule can fail a previously green `main`. The weekly schedule surfaces that early. Triage with a fix or a narrow `// nosemgrep: <rule-id> -- reason`.
Rust clippy is deferred. It needs the webkit2gtk/gtk system libraries and a full compile of the Tauri crate, which is too heavy for the shared CI host (jobs are limited to 3 GB RAM / 2 CPU). Only `cargo fmt --check` runs, with a pinned minimal rustup toolchain (rustfmt only).
## Run locally
```sh
# Semgrep (same image and rules as CI)
podman run --rm -v "$PWD:/src:ro,Z" -w /src docker.io/semgrep/semgrep:1.178.0 \
semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
npm run lint # ESLint
npx tsc --noEmit # typecheck
(cd src-tauri && cargo fmt --check)
npx jscpd@4.3.0 # duplicate code, reads .jscpd.json
npx playwright install chromium # once
npm run build && npm run e2e
```
Keep the `mcr.microsoft.com/playwright` image tag in the `e2e` job in step with the `@playwright/test` version in `package.json`.
## PR-Agent (advisory)
`ai-review` posts a review through PR-Agent using OpenRouter. It never pushes code and is skipped (with a notice) when the secrets are empty.
Repository secrets and variables:
- `OPENROUTER_API_KEY` (secret): OpenRouter API key.
- `PR_AGENT_GITEA_TOKEN` (secret): Gitea personal access token of the account that posts the review.
- `PR_AGENT_MODEL` (variable, optional): defaults to `openrouter/anthropic/claude-sonnet-5`.
## Rollback
Revert the workflow PR. If branch protection requires these checks (`security`, `lint`, `e2e`), relax it first, otherwise PRs will wait forever for checks that no longer run.
+21
View File
@@ -0,0 +1,21 @@
# Contributing to Voiced
Contributions are welcome: bug reports, fixes, templates, translations and documentation.
1. Open an issue first for anything larger than a small fix, so the work is not duplicated.
2. Branch from `main`. Keep a change focused on one thing.
3. Run the gates from `docs/RELEASE.md` that your change touches: `npm run build`, `npm test`, `npm run fonts:verify`
and `cargo test --manifest-path src-tauri/Cargo.toml`. Changes to the PDF engine or a template also need the
real-webview self-test (see the README, "Tests and gates").
4. Do not add personal data, real GSTINs, bank details or client names to code, tests, fixtures or screenshots. Use
synthetic values.
5. Open a pull request describing what changed and why.
By submitting a contribution you agree that it is licensed under the Apache License 2.0 (see `LICENSE`), the same
licence as the project, as described in section 5 of that licence.
## Using Voiced in your own project
You may fork, modify and redistribute Voiced under the Apache License 2.0. You must keep `LICENSE` and `NOTICE`,
keep the copyright notices, and mark files you changed. Please also credit the original: "Based on Voiced by
Bongbetic".
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+11
View File
@@ -0,0 +1,11 @@
Voiced
Copyright 2026 Bongbetic
This product includes software developed by Bongbetic (https://git.bongbetic.com/xavierk/Voiced).
If you redistribute Voiced, or a work derived from it (including a fork), the Apache License 2.0 requires you to keep
this NOTICE file, the LICENSE file and the copyright notices in the source files, and to state any changes you made.
Please also keep the credit "Based on Voiced by Bongbetic" in your README and in your application's About or
documentation.
Bundled fonts in public/fonts/ are covered by their own SIL Open Font License 1.1 texts (OFL.txt in each folder).
+54 -15
View File
@@ -1,6 +1,6 @@
# Voiced
An offline-first GST invoicing desktop app for **Test Vendor**, built by **Bongbetic**.
An offline-first GST invoicing desktop app built by **Bongbetic**.
Voiced turns a few details into a clean, print-ready PDF invoice. It keeps a running invoice-number series in a
local database, remembers your vendor, client and bank details, and works without an account or a network
@@ -8,16 +8,23 @@ connection. The only optional network feature is the ERPNext push, which you con
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
Current release: **1.2.0**. Installers and the Void package are attached to the release on the project's Gitea
page (see "Installing").
## Features
- **First-run setup.** A four-step wizard (business, branding, bank, numbering) starts blank: no vendor, bank or tax
details are pre-filled, and a business name and state are required. **Settings, Data, Run setup again** reopens it
with your current details; invoices, clients and backups are kept, and numbering carries on unless you change the
series prefix or digits.
- **Invoices.** Automatic numbering in a persistent series (for example `INV/2026-001`) with a one-click new series,
drafts, issue and cancel. An issued invoice is immutable: it stores a snapshot of the vendor, client, GST figures
and layout. Line items are a fixed amount or rate x quantity (per second, minute, hour, session or unit), with
item presets.
and layout. Line items are a fixed amount or rate x quantity in a unit of measure (time, distance, area, weight,
volume, a count, or a custom unit; a time typed as `4:30` counts as 4.5 hours), with item presets.
- **GST.** All money and tax maths is done in integer paise. CGST+SGST, IGST or UTGST follow from the supplier's
state and the place of supply, and a mismatch is rejected. An unregistered vendor gets a plain "Invoice" with no
tax. GSTIN, PAN, IFSC, email and phone are validated; amounts are written in Indian words.
- **Templates.** 14 templates: Voiced Standard (Classic) and 13 designs measured from reference originals (Linea,
- **Templates.** 14 templates: Voiced Standard (Classic) and 13 further designs (Linea,
Monogram, Serenity, Crimson Grid, Tangerine Ledger, Slate Band, Teal Swoosh, Purple Pop, Citrus Split, Monolith,
Cobalt Stripe, Marble, Highlighter). Your logo fills the template's logo slot; a few templates offer an opt-in
logo position. Specs are in `docs/templates/`.
@@ -31,6 +38,7 @@ Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS inst
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
balance is cash plus TDS against the invoice total.
- **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list.
- **Reset all data.** Settings, Data wipes everything and returns to first-run setup after a typed `RESET`.
- **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts.
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
(see `docs/voiced-invoice-v1.md`).
@@ -66,14 +74,28 @@ npm run app:dev # tauri dev, served from localhost:1420 (does not exercis
| `npm run build` | `tsc --noEmit` and `vite build` (the type-check gate) |
| `npm test` | `vitest run`, about 7 minutes; run on a quiet machine because load starves the sweep tests |
| `npm run fonts:verify` | Checks the bundled fonts against `public/fonts/manifest.json` (`fonts:manifest` regenerates it) |
| `npm run logo:measure` | Regenerates `src/pdf/templates/slots.generated.ts` from the reference PDFs; must be idempotent |
| `npm run thumbnails` | Regenerates `public/templates/*.png` |
| `npm run templates:compare` | reference vs Voiced side-by-side PNGs under `/tmp` |
| `npm run logo:measure` | Regenerates `src/pdf/templates/slots.generated.ts` from the reference template PDFs (not in the repo); must be idempotent |
| `npm run thumbnails` | Regenerates `public/templates/*.png` from the synthetic sample invoice (neutral vendor and bank, generated wordmark) |
| `node scripts/logo/make-sample-logo.mjs` | Regenerates the synthetic sample logo in `src/pdf/testing/fixtures/` |
| `npm run templates:compare` | Reference vs Voiced side-by-side PNGs under `/tmp` (needs the reference PDFs, not in the repo) |
| `cargo test --manifest-path src-tauri/Cargo.toml` | Rust unit tests |
| `npm run app:build` | Production build for the current platform (custom-protocol) |
The release procedure, including the manual first-run checklist, is in `docs/RELEASE.md`.
## Installing
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
`v1.2.0`).
- **Windows 10/11:** run `Voiced_1.2.0_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
SmartScreen may warn: choose "More info", then "Run anyway".
- **Void Linux:** install `voiced-1.2.0_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
On first launch the setup wizard asks for your business details, logo, bank account and invoice numbering. Nothing is
pre-filled. Uninstalling does not delete your data (see "Where data lives"); to start from scratch use
Settings, Data, Run setup again, or remove the data directory after taking a backup.
## Where data lives
The Tauri identifier is `com.bongbetic.voiced`. Voiced uses two directories, which differ only on Windows:
@@ -105,6 +127,16 @@ Settings, Data tab.
backup files on storage you trust, do not email or upload them casually, and rotate the ERPNext API key if a
backup is lost.
## Reset all data
Settings, Data, Reset all data. This permanently deletes the database (invoices, clients, payments, business details
and settings), `assets/`, the PDF `archive/`, imported `fonts/` and the whole `backups/` folder, including automatic
backups and the `pre-restore-*` safety copies, then starts Voiced as if it were new (the setup wizard shows again).
You must type `RESET` to enable the button. The wipe is staged and applied on the next start, before the database is
opened, so the "Delete everything and restart" button restarts the app for you; if a file cannot be deleted the
wipe is retried on the next start. Backup zips you saved elsewhere are not touched. Take a backup first if you may
want the data back.
## Fonts and licences
Eleven families are bundled as static TTF files in `public/fonts/<family>/`, each with its `OFL.txt`: DM Sans,
@@ -184,7 +216,8 @@ Hosts without an `nsis` package (for example Void Linux):
# from https://github.com/tauri-apps/nsis-tauri-utils/releases
```
The installer has not been run on a real Windows machine.
The installer has been installed and run once in a Windows 11 virtual machine; it has not been tried on physical
hardware.
## Installing on Void Linux
@@ -215,7 +248,7 @@ cd void-packages && ./xbps-src pkg voiced
```
`INSTALL` and `REMOVE` must sit next to the template; xbps-src packs them. The source is a tagged archive of the
private repository (see the comments at the top of the template; `checksum=SKIP` until a tarball is published).
repository (see the comments at the top of the template; `checksum=SKIP` until a tarball is published).
xbps-src builds in a network-less chroot, so vendor the dependencies first: run `cargo vendor` in `src-tauri/` (with a
`.cargo/config.toml` pointing at `vendor/`) and populate the npm cache, and include the results in the source
archive. The template runs `npm ci` (not `--omit=optional`: that removes
@@ -241,6 +274,7 @@ src/ React + Carbon frontend
fonts/ Font registration, roles, glyph priming
render/ renderCore, Web Worker, latest-wins client
flatten.ts assemble.ts Rasterise at 300 DPI and rebuild an image-only PDF
testing/ Goldens (layout fingerprints), self-test fixtures, synthetic sample logo
src-tauri/src/ Rust backend
db.rs Schema as numbered migrations (copies the DB aside before migrating)
gst.rs Integer-paise money and GST maths
@@ -250,7 +284,7 @@ src-tauri/src/ Rust backend
integrations/erpnext/ ERPNext client, mapping, discovery, push
selftest.rs Unattended real-webview self-test
public/fonts/ Bundled TTF families, OFL texts, manifest.json (plus legacy IBM Plex web fonts)
public/templates/ Template thumbnails
public/templates/ Template thumbnails (rendered from synthetic sample data)
docs/ Template specs, voiced.invoice.v1, release checklist
scripts/ Font, logo, template, thumbnail and ERPNext end-to-end tooling
packaging/void/ xbps-src template, build-xbps.sh, desktop entry, INSTALL and REMOVE scripts
@@ -272,15 +306,20 @@ Settings, Diagnostics.
## Known limitations
- Windows and WebView2 have never been run on a real machine; the NSIS installer is cross-compiled only.
- The 13 reference-based templates are re-creations of reference designs. the reference design's content licence may restrict this, so
ship them only in this vendor's build until it is confirmed.
- Windows and WebView2 have only been exercised in a Windows 11 virtual machine, not on physical hardware; the NSIS
installer is cross-compiled and unsigned.
- The SAC code for the vendor's services and the signature rule still need confirmation from a chartered accountant.
- Now, Gotham and Open Sauce One are replaced by free look-alikes unless you import licensed files.
- ERPNext: only v15 is verified; HTTP is allowed only for local hosts; see "Not covered" above.
- Not implemented (deferred): copies and watermarks, PDF/A, custom margins, user background textures, SVG logo
import, CESS, composition and SEZ invoices.
## Ownership
## Licence and credit
© Bongbetic. All rights reserved. This is a private application.
Voiced is released under the [Apache License 2.0](LICENSE). Copyright 2026 Bongbetic.
You are free to use, modify, fork and redistribute it. If you do, you must keep the `LICENSE` and `NOTICE` files and
the copyright notices, and state your changes; please also credit the original: **Based on Voiced by Bongbetic**.
Bundled fonts keep their own SIL Open Font License texts (see "Fonts and licences").
Contributions are welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). Contributions are licensed under the same terms.
+7 -5
View File
@@ -71,6 +71,11 @@ Linux and Windows. Windows has never been run on real hardware, so treat it as u
- **Template picker.** Open all 14 templates; switch A4 and Letter and the margin presets; check the logo slots.
- **Export.** Issue an invoice; export searchable and flattened; change settings and re-export; "Original as issued"
must be byte-identical to the archived PDF. On Windows, check file names with `/`, `:` and reserved names.
- **Export, then New invoice.** Issue and export in both formats through the real save dialog, press "New invoice",
issue and export a second one. The window must stay usable after every export (1.1.0 fixed a blank window caused
by buttons inside a Carbon notification; the dialog itself can only be checked by hand).
- **Units.** Add a rate line in hours typed as `4:30` (amount counts 4.5), one in km and one with a custom unit;
check the quantity and rate columns on a few templates and A4/Letter.
- **Fonts.** Settings, Fonts: import a TTF for Now, Gotham or Open Sauce One (if a licensed file is at hand), confirm
a template that uses it changes, remove it again.
- **Clients.** Create a client with structured address and GST category; use it on an invoice.
@@ -83,9 +88,6 @@ Linux and Windows. Windows has never been run on real hardware, so treat it as u
- **Integrations.** Test connection and push an invoice to a test ERPNext (README, "ERPNext integration").
- **Single instance.** Launch a second copy: the first window must come to the front.
## 6. Reference licence
## 6. Open items
The 13 reference-based templates are re-creations of reference designs. the reference design's content licence may restrict that. Until it
is confirmed in writing, distribute builds containing those templates only to this vendor. If the answer is no,
remove the templates from `src/pdf/templates/registry.ts` and `catalog.ts` (Classic stays) and rebuild. Also still
open: a chartered accountant's sign-off on the SAC code and the signature rule.
A chartered accountant's sign-off on the SAC code and the signature rule is still open.
+1 -1
View File
@@ -74,7 +74,7 @@ Everything can be built with View backgroundColor and borders. Svg is only neede
## Mapping to Voiced fields
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. public/brand/sample-wordmark-logo.png), hide the text lockup.
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. src/pdf/testing/fixtures/sample-wordmark-logo.png), hide the text lockup.
- Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned).
- 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'.
- Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'.
+4
View File
@@ -33,6 +33,10 @@ Notes on the fields:
- **Items.** `taxable_value` of a line is the line amount before the invoice-level discount. `totals.taxable_value` is
`subtotal - discount`, the base the tax is charged on. A fixed-amount line has `quantity "1"` and `unit null`.
- **Units.** On a rate line `unit` is a built-in id (`unit`, `piece`, `set`, `session`, `second`, `minute`, `hour`, `day`,
`week`, `month`, `km`, `m`, `sqft`, `sqm`, `kg`, `litre`) or the custom text typed on the invoice (at most 12 characters:
letters, digits, spaces and `. / -`). `quantity` may be fractional; a time typed as `4:20` hours is stored as the exact
repeating decimal.
- **UTGST.** When the supplier is in a union territory without a legislature (state codes 04, 26, 31, 35, 38) the second
head is reported under `utgst` and `sgst` is zero.
- **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured
+69
View File
@@ -0,0 +1,69 @@
import { expect, test, type Page } from "@playwright/test";
// The app talks to its Rust backend through Tauri's `invoke`, which does not exist in a plain
// browser. These smoke tests cover the web build in both situations: without the backend (the app
// must fail gracefully) and with a minimal stubbed backend (the app shell must render).
const SETTINGS = {
vendorName: "Smoke Test Studio",
vendorAddress: "1 Test Street",
vendorEmail: "smoke@example.com",
vendorPhone: "",
vendorPan: "",
vendorGstin: "",
vendorStateCode: "",
logoPath: null,
signaturePath: null,
defaultBankId: null,
defaultTaxRate: 0,
defaultTaxType: "none",
paymentTermsDays: 15,
currency: "INR",
onboarded: true,
theme: "g10",
gstRegistration: "unregistered",
defaultHsnSac: "",
signatoryName: "",
signatoryDesignation: "",
renderPrefs: "",
logoOriginalPath: null,
logoKnockoutPath: null,
logoMeta: "",
logoIncludesName: "auto",
};
function collectPageErrors(page: Page): string[] {
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
return errors;
}
test("web build loads without a backend and fails gracefully", async ({ page }) => {
const errors = collectPageErrors(page);
await page.goto("/");
await expect(page).toHaveTitle("Voiced");
await expect(page.getByText("Could not start Voiced")).toBeVisible();
expect(errors).toEqual([]);
});
test("app shell renders with a stubbed backend", async ({ page }) => {
const errors = collectPageErrors(page);
await page.addInitScript((settings) => {
const w = window as unknown as { __TAURI_INTERNALS__: unknown };
w.__TAURI_INTERNALS__ = {
transformCallback: () => 0,
invoke: async (cmd: string) => {
if (cmd === "get_settings") return settings;
if (cmd.startsWith("list_")) return [];
return null;
},
};
}, SETTINGS);
await page.goto("/");
const nav = page.getByRole("navigation", { name: "Voiced navigation" });
await expect(nav).toBeVisible();
for (const label of ["Clients", "Settings"]) {
await expect(nav.getByText(label, { exact: true })).toBeVisible();
}
expect(errors).toEqual([]);
});
+50
View File
@@ -0,0 +1,50 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config(
{
ignores: ["node_modules/", "dist/", "src-tauri/", "public/", "docs/", "playwright-report/", "test-results/"],
},
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
{
rules: {
// Leading underscore marks an intentionally unused binding (omitting a key via rest, unused
// override parameters).
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_", ignoreRestSiblings: true },
],
// The BOM and NBSP are written literally on purpose in the CSV exporters and the text sanitiser.
"no-irregular-whitespace": ["error", { skipStrings: true, skipTemplates: true, skipRegExps: true }],
},
},
{
// The React Compiler rules shipped in eslint-plugin-react-hooks 7 flag long-standing patterns
// here (load data on mount, "latest value" refs written during render). They are legitimate
// design feedback but not bugs, and rewriting them blind risks behaviour changes in an
// effect-heavy app, so they stay visible as warnings until each site is reworked.
files: ["src/**/*.{ts,tsx}"],
rules: {
"react-hooks/set-state-in-effect": "warn",
"react-hooks/refs": "warn",
"react-hooks/immutability": "warn",
},
},
{
// Test helper that reads react-pdf's untyped internal layout data.
files: ["src/pdf/testing/**/*.ts"],
rules: { "@typescript-eslint/no-explicit-any": "warn" },
},
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: { globals: globals.browser },
},
{
files: ["scripts/**/*.{js,mjs}", "e2e/**/*.ts", "*.config.{js,ts}"],
languageOptions: { globals: globals.node },
},
);
+1324 -4
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -1,7 +1,8 @@
{
"name": "voiced",
"private": true,
"version": "1.0.0",
"version": "1.2.0",
"license": "Apache-2.0",
"description": "Voiced — offline invoicing desktop app by Bongbetic",
"type": "module",
"author": "Bongbetic",
@@ -22,7 +23,9 @@
"decor:script-word": "node scripts/decor/script-word.mjs",
"decor:icons": "node scripts/decor/icons.mjs",
"thumbnails": "node scripts/thumbnails.mjs",
"templates:compare": "node scripts/templates/compare.mjs"
"templates:compare": "node scripts/templates/compare.mjs",
"lint": "eslint .",
"e2e": "playwright test"
},
"dependencies": {
"@carbon/icons-react": "^11.89.0",
@@ -39,15 +42,21 @@
"react-is": "^19.3.0"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@playwright/test": "1.63.0",
"@tauri-apps/cli": "^2.12.1",
"@types/node": "^26.6.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
"clipper-lib": "^6.4.2",
"eslint": "10.12.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.13.0",
"pdfjs-dist": "6.4.299",
"sass": "^1.105.1",
"typescript": "^5.9.3",
"typescript-eslint": "8.71.0",
"vite": "^7.3.6",
"vitest": "^5.0.3"
},
+1 -1
View File
@@ -89,7 +89,7 @@ mkdir -p "$REPO"
-n "$PKGVER" \
-s "Offline invoicing app by Bongbetic" \
-S "Voiced is an offline-first invoice generator with automatic numbering, GST support, optional signatures and print-ready PDF export." \
-l "proprietary" \
-l "Apache-2.0" \
-H "https://git.bongbetic.com/xavierk/Voiced" \
-m "Xavier Karma <xavier@bongbetic.com>" \
-D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
+2 -2
View File
@@ -28,7 +28,7 @@
# pure Rust and need no extra system libraries.
#
pkgname=voiced
version=1.0.0
version=1.2.0
revision=1
archs="x86_64*"
hostmakedepends="rust nodejs pkg-config"
@@ -36,7 +36,7 @@ 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"
license="Apache-2.0"
homepage="https://git.bongbetic.com/xavierk/Voiced"
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
checksum=SKIP
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from "@playwright/test";
const PORT = 4173;
export default defineConfig({
testDir: "./e2e",
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: "list",
use: {
baseURL: `http://localhost:${PORT}`,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
// Serves the production build (run `npm run build` first).
webServer: {
command: `npx vite preview --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

-1
View File
@@ -52,7 +52,6 @@ function wordCommands(font) {
function rotate(cmds) {
const pts = cmds.flatMap((c) => c.args);
const xs = pts.filter((_, i) => i % 2 === 0);
const ys = pts.filter((_, i) => i % 2 === 1);
const inkLen = Math.max(...xs) - Math.min(...xs);
const s = INK_LENGTH / inkLen;
const map = (x, y) => [-y * s, -x * s];
+1 -1
View File
@@ -116,7 +116,7 @@ async function main() {
const { computeLogoPlacement, slotFor } = await loadPlacement();
mkdirSync(OUT_DIR, { recursive: true });
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "public/brand/sample-wordmark-logo.png")));
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png")));
const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
const syn = syntheticLogos(createCanvas);
+20
View File
@@ -0,0 +1,20 @@
// Renders the synthetic wordmark used as the sample logo in tests, thumbnails and scripts
// (src/pdf/testing/fixtures/sample-wordmark-logo.png). Run: node scripts/logo/make-sample-logo.mjs
import { writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GlobalFonts, createCanvas } from "@napi-rs/canvas";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const W = 2172;
const H = 724;
const size = Number(process.argv[2] ?? 300);
GlobalFonts.registerFromPath(path.join(ROOT, process.argv[3] ?? "public/fonts/montserrat/Montserrat-ExtraBold.ttf"), "SampleMark");
const canvas = createCanvas(W, H);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#1f3a6e";
ctx.font = `${size}px SampleMark`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(process.argv[4] ?? "STUDIO", W / 2, H / 2);
writeFileSync(path.join(ROOT, "src/pdf/testing/fixtures/sample-wordmark-logo.png"), canvas.toBuffer("image/png"));
+1 -1
View File
@@ -107,7 +107,7 @@ async function main() {
invoice: { clientName: TOKEN, clientAddress: LONG, notes: `Note: ${TOKEN} ${LONG}` },
});
writeFileSync(path.join(OUT, `${id}-A4-longtext.png`), (await rasterize(long.result.bytes, 1, SCALE)).toBuffer("image/png"));
console.log(`${id}: pages ${withLogo.result.pages}/${sixty.result.pages}/${letter.result.pages}/${long.result.pages}; audit issues`, [withLogo, noLogo, sixty, letter, long].map((x) => x.result.issues.length).join("/"));
console.log("%s: pages %s/%s/%s/%s; audit issues %s", id, withLogo.result.pages, sixty.result.pages, letter.result.pages, long.result.pages, [withLogo, noLogo, sixty, letter, long].map((x) => x.result.issues.length).join("/"));
for (const x of [withLogo, noLogo, sixty, letter, long]) for (const i of x.result.issues) console.log(" ", i.severity, i.code, `p${i.page}`, i.message.slice(0, 160));
}
}
+1 -1
View File
@@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voiced"
version = "1.0.0"
version = "1.2.0"
dependencies = [
"base64 0.22.1",
"chrono",
+2 -1
View File
@@ -1,6 +1,7 @@
[package]
name = "voiced"
version = "1.0.0"
version = "1.2.0"
license = "Apache-2.0"
description = "Voiced — offline invoicing desktop app by Bongbetic"
authors = ["Bongbetic"]
edition = "2021"
+69 -21
View File
@@ -30,7 +30,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
/// 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'));
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());
}
@@ -72,7 +75,9 @@ pub fn archive_pdf_impl(
.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})"));
return Err(format!(
"Only an issued invoice can be archived (this one is {status})"
));
}
let sha = sha256_hex(bytes);
@@ -89,7 +94,9 @@ pub fn archive_pdf_impl(
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);
let present = std::fs::metadata(&path)
.map(|m| m.len() == bytes.len() as u64)
.unwrap_or(false);
if !present {
write_atomic(&path, bytes)?;
}
@@ -107,7 +114,8 @@ pub fn archive_pdf_impl(
}
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 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 {
@@ -124,7 +132,11 @@ pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result
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))
.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())
@@ -140,12 +152,20 @@ pub fn archive_status_impl(conn: &Connection, id: i64) -> Result<ArchiveStatus,
.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 })
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> {
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())?;
@@ -159,7 +179,10 @@ pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result
}
#[tauri::command]
pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result<ArchiveStatus, String> {
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)
}
@@ -195,7 +218,10 @@ mod tests {
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);
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);
@@ -205,7 +231,10 @@ mod tests {
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!(
archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(),
first_at
);
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
}
@@ -221,19 +250,27 @@ mod tests {
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();
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);
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"));
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"))
@@ -264,15 +301,21 @@ mod tests {
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"));
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"));
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"));
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);
@@ -282,8 +325,13 @@ mod tests {
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"));
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"));
}
}
+28 -6
View File
@@ -42,7 +42,11 @@ pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
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 full = if raw.is_absolute() {
raw.to_path_buf()
} else {
data_dir.join(raw)
};
let root = data_dir
.join("assets")
.canonicalize()
@@ -73,7 +77,12 @@ pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String
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);
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}"))
}
@@ -105,7 +114,11 @@ fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
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) };
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(());
@@ -163,7 +176,10 @@ mod tests {
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_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());
@@ -173,7 +189,10 @@ mod tests {
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!(
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();
@@ -210,7 +229,10 @@ mod tests {
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);
assert_eq!(
relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(),
stored
);
}
#[test]
File diff suppressed because it is too large Load Diff
+32 -9
View File
@@ -4,9 +4,16 @@ use crate::AppState;
use rusqlite::{params, Connection, Row};
use tauri::State;
const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"];
const CATEGORIES: &[&str] = &[
"registered_regular",
"unregistered",
"composition",
"sez",
"overseas",
];
const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
const CLIENT_COLS: &str =
"c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
c.payment_terms_days,
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
@@ -33,7 +40,13 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String {
pub fn compose_address(
line1: &str,
line2: &str,
city: &str,
state_code: &str,
pincode: &str,
) -> String {
let state_name = gst::STATES
.iter()
.find(|(c, _)| *c == state_code)
@@ -246,7 +259,10 @@ mod tests {
c.po_number = "PO-9".into();
let saved = save_client_impl(&conn, c).unwrap();
assert!(saved.id.is_some());
assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
assert_eq!(
saved.address,
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
);
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
let listed = list_clients_impl(&conn).unwrap();
assert_eq!(listed.len(), 1);
@@ -282,7 +298,10 @@ mod tests {
edit.name = "B".into();
edit.gst_category = "sez".into();
let again = save_client_impl(&conn, edit).unwrap();
assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez"));
assert_eq!(
(again.name.as_str(), again.gst_category.as_str()),
("B", "sez")
);
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
let mut ghost = blank("Ghost");
ghost.id = Some(999);
@@ -292,7 +311,9 @@ mod tests {
#[test]
fn validation_rejects_bad_input() {
let conn = open_in_memory().unwrap();
assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name"));
assert!(save_client_impl(&conn, blank(" "))
.unwrap_err()
.contains("name"));
let mut c = blank("X");
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
@@ -342,9 +363,11 @@ mod tests {
delete_client_impl(&conn, id).unwrap();
assert!(list_clients_impl(&conn).unwrap().is_empty());
let (name, addr): (String, String) = conn
.query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.query_row(
"SELECT client_name, client_address FROM invoices WHERE number='T-1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
}
+50 -14
View File
@@ -5,8 +5,12 @@
//! never held across a network call.
use crate::integrations::erpnext::client::ErpClient;
use crate::integrations::erpnext::config::{self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView};
use crate::integrations::erpnext::discovery::{self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts};
use crate::integrations::erpnext::config::{
self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView,
};
use crate::integrations::erpnext::discovery::{
self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts,
};
use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult};
use crate::integrations::SyncStatus;
use crate::AppState;
@@ -18,12 +22,18 @@ pub fn get_config_impl(conn: &Connection) -> Result<ErpnextConfigView, String> {
}
/// Merges the form with the stored row (blank secret keeps the stored one) without saving.
pub fn resolve_config(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfig, String> {
pub fn resolve_config(
conn: &Connection,
input: ErpnextConfigInput,
) -> Result<ErpnextConfig, String> {
let stored = config::load(conn)?;
input.resolve(&stored)
}
pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
pub fn save_config_impl(
conn: &Connection,
input: ErpnextConfigInput,
) -> Result<ErpnextConfigView, String> {
let cfg = resolve_config(conn, input)?;
config::save(conn, &cfg)?;
Ok(cfg.view())
@@ -32,7 +42,11 @@ pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<
/// What the connection test needs from the local database.
pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
let registration: String = conn
.query_row("SELECT gst_registration FROM app_settings WHERE id = 1", [], |r| r.get(0))
.query_row(
"SELECT gst_registration FROM app_settings WHERE id = 1",
[],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT number FROM invoices ORDER BY id")
@@ -54,7 +68,11 @@ pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
)
.ok()
.map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next));
Ok(LocalFacts { vendor_registered: registration != "unregistered", invalid_numbers, next_number })
Ok(LocalFacts {
vendor_registered: registration != "unregistered",
invalid_numbers,
next_number,
})
}
#[tauri::command]
@@ -65,7 +83,10 @@ pub fn erpnext_get_config(state: State<AppState>) -> Result<ErpnextConfigView, S
/// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it.
#[tauri::command]
pub fn erpnext_save_config(state: State<AppState>, config: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
pub fn erpnext_save_config(
state: State<AppState>,
config: ErpnextConfigInput,
) -> Result<ErpnextConfigView, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
save_config_impl(&conn, config)
}
@@ -105,7 +126,10 @@ fn saved_client(state: &AppState) -> Result<ErpClient, String> {
config::load(&conn)?
};
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
return Err("ERPNext is not set up yet: enter the address, API key and API secret in Settings.".into());
return Err(
"ERPNext is not set up yet: enter the address, API key and API secret in Settings."
.into(),
);
}
Ok(ErpClient::from_config(&cfg)?)
}
@@ -134,7 +158,10 @@ pub async fn erpnext_push_invoices(
}
#[tauri::command]
pub async fn erpnext_push_payment(state: State<'_, AppState>, payment_id: i64) -> Result<PaymentPushResult, String> {
pub async fn erpnext_push_payment(
state: State<'_, AppState>,
payment_id: i64,
) -> Result<PaymentPushResult, String> {
let http = saved_client(&state)?;
Ok(push::push_payment(&state.db, &http, payment_id).await)
}
@@ -165,15 +192,22 @@ mod tests {
use serde_json::json;
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
let mut base = json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" });
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let mut base =
json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" });
base.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
serde_json::from_value(base).unwrap()
}
#[test]
fn saved_config_is_returned_without_the_secret() {
let conn = crate::db::open_in_memory().unwrap();
let view = save_config_impl(&conn, input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true }))).unwrap();
let view = save_config_impl(
&conn,
input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true })),
)
.unwrap();
assert!(view.api_secret_set);
let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap();
assert!(!shown.contains("super-secret-value"));
@@ -190,7 +224,8 @@ mod tests {
#[test]
fn test_values_are_resolved_without_saving() {
let conn = crate::db::open_in_memory().unwrap();
let resolved = resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap();
let resolved =
resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap();
assert_eq!(resolved.api_secret.expose(), "typed-not-saved");
assert!(!get_config_impl(&conn).unwrap().api_secret_set);
}
@@ -203,7 +238,8 @@ mod tests {
assert!(facts.invalid_numbers.is_empty());
assert!(facts.next_number.is_some());
conn.execute("UPDATE app_settings SET gst_registration = 'regular'", []).unwrap();
conn.execute("UPDATE app_settings SET gst_registration = 'regular'", [])
.unwrap();
conn.execute(
"INSERT INTO invoices (number, invoice_date, created_at, updated_at)
VALUES ('INV/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')",
+47 -14
View File
@@ -1,6 +1,8 @@
//! 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 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};
@@ -25,7 +27,9 @@ pub fn write_export_file_impl(
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())?;
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)?;
@@ -38,8 +42,12 @@ pub fn write_export_file_impl(
}
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())
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> {
@@ -71,7 +79,11 @@ fn reveal_fallback_command(path: &Path) -> Command {
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
let folder = if path.is_dir() {
path
} else {
path.parent().unwrap_or(path)
};
let mut cmd = Command::new("xdg-open");
cmd.arg(folder);
cmd
@@ -90,7 +102,9 @@ fn open_fallback_command(path: &Path) -> Command {
}
fn spawn_detached(mut cmd: Command) -> Result<(), String> {
let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?;
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 || {
@@ -100,7 +114,10 @@ fn spawn_detached(mut cmd: Command) -> Result<(), String> {
}
#[tauri::command]
pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
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())?;
@@ -125,7 +142,11 @@ pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), Strin
#[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() {
if app
.opener()
.open_path(path.to_string_lossy(), None::<&str>)
.is_ok()
{
return Ok(());
}
spawn_detached(open_fallback_command(&path))
@@ -141,8 +162,11 @@ mod tests {
}
fn encode(path: &Path) -> String {
percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC)
.to_string()
percent_encoding::utf8_percent_encode(
&path.to_string_lossy(),
percent_encoding::NON_ALPHANUMERIC,
)
.to_string()
}
#[test]
@@ -152,8 +176,12 @@ mod tests {
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();
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!(
@@ -170,7 +198,10 @@ mod tests {
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(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(), "");
@@ -191,7 +222,9 @@ mod tests {
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(&missing.to_string_lossy())
.unwrap_err()
.contains("does not exist"));
assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
}
+368 -90
View File
@@ -9,7 +9,9 @@
//! (`fvar`, `CFF2`: only the default instance would embed), files without outlines, and fonts whose OS/2 `fsType`
//! forbids embedding. The sfnt is parsed by hand (no font crate is available offline); only the tables needed
//! to describe the font are read.
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers};
use super::raw::{
decode_header_path, header_map, raw_body, required_header, write_atomic, Headers,
};
use crate::AppState;
use flate2::read::ZlibDecoder;
use rusqlite::{params, Connection, OptionalExtension};
@@ -119,7 +121,10 @@ impl<'a> Sfnt<'a> {
let off = be32(data, rec + 8).ok_or("The font file is truncated")? as usize;
let len = be32(data, rec + 12).ok_or("The font file is truncated")? as usize;
if off.checked_add(len).map_or(true, |end| end > data.len()) {
return Err(format!("Table '{}' lies outside the file", String::from_utf8_lossy(&tag)));
return Err(format!(
"Table '{}' lies outside the file",
String::from_utf8_lossy(&tag)
));
}
tables.push((tag, off, len));
}
@@ -135,7 +140,10 @@ impl<'a> Sfnt<'a> {
}
fn utf16be(b: &[u8]) -> String {
let units: Vec<u16> = b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let units: Vec<u16> = b
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&units)
}
@@ -201,7 +209,11 @@ impl<'a> Cmap<'a> {
best = Some((rank, offset, format));
}
}
best.map(|(_, offset, format)| Self { table, offset, format })
best.map(|(_, offset, format)| Self {
table,
offset,
format,
})
}
fn has(&self, cp: u32) -> bool {
@@ -283,7 +295,9 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
let comp = be32(data, rec + 8).ok_or_else(bad)? as usize;
let orig = be32(data, rec + 12).ok_or_else(bad)? as usize;
let checksum = be32(data, rec + 16).ok_or_else(bad)?;
let stored = data.get(off..off.checked_add(comp).ok_or_else(bad)?).ok_or_else(bad)?;
let stored = data
.get(off..off.checked_add(comp).ok_or_else(bad)?)
.ok_or_else(bad)?;
total += (orig + 3) & !3;
if orig > MAX_FONT_BYTES || total > MAX_FONT_BYTES {
return Err("The font expands to more than the 16 MB limit".into());
@@ -293,7 +307,10 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
} else if comp < orig {
let mut out = Vec::with_capacity(orig);
// One byte over the stated size detects a table that lies about it, without trusting it for allocation.
ZlibDecoder::new(stored).take(orig as u64 + 1).read_to_end(&mut out).map_err(|_| bad())?;
ZlibDecoder::new(stored)
.take(orig as u64 + 1)
.read_to_end(&mut out)
.map_err(|_| bad())?;
if out.len() != orig {
return Err(bad());
}
@@ -301,7 +318,11 @@ fn woff_to_sfnt(data: &[u8]) -> Result<Vec<u8>, String> {
} else {
return Err(bad());
};
entries.push(Entry { tag, checksum, bytes });
entries.push(Entry {
tag,
checksum,
bytes,
});
}
entries.sort_by_key(|e| e.tag);
@@ -372,12 +393,18 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
}
let cff = font.table(b"CFF ").is_some();
if !cff && (font.table(b"glyf").is_none() || font.table(b"loca").is_none()) {
return Err("The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded.".into());
return Err(
"The font has no glyph outlines (no glyf or CFF table), so it cannot be embedded."
.into(),
);
}
let head = font.table(b"head").ok_or("The font has no head table")?;
let cmap_table = font.table(b"cmap").ok_or("The font has no cmap table")?;
let name = font.table(b"name").ok_or("The font has no name table")?;
let num_glyphs = font.table(b"maxp").and_then(|m| be16(m, 4)).ok_or("The font has no usable maxp table")?;
let num_glyphs = font
.table(b"maxp")
.and_then(|m| be16(m, 4))
.ok_or("The font has no usable maxp table")?;
let os2 = font.table(b"OS/2");
let fs_type = os2.and_then(|t| be16(t, 8)).unwrap_or(0);
@@ -413,7 +440,11 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
let fs_selection = os2.and_then(|t| be16(t, 62)).unwrap_or(0);
let mac_style = be16(head, 44).unwrap_or(0);
// fsSelection bit 0 = italic, bit 9 = oblique; head.macStyle bit 1 = italic.
let italic = if os2.map_or(false, |t| t.len() >= 64) { fs_selection & 0x0201 != 0 } else { mac_style & 0x2 != 0 };
let italic = if os2.map_or(false, |t| t.len() >= 64) {
fs_selection & 0x0201 != 0
} else {
mac_style & 0x2 != 0
};
let cmap = Cmap::new(cmap_table).ok_or("The font has no usable Unicode character map")?;
let has_rupee = cmap.has(0x20B9);
@@ -428,9 +459,14 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
warnings.push("The font has no rupee sign (\u{20B9}); amounts fall back to IBM Plex Sans for that glyph.".to_string());
}
let family = name_string(name, 16).or_else(|| name_string(name, 1)).unwrap_or_default();
let subfamily = name_string(name, 17).or_else(|| name_string(name, 2)).unwrap_or_default();
let full_name = name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string());
let family = name_string(name, 16)
.or_else(|| name_string(name, 1))
.unwrap_or_default();
let subfamily = name_string(name, 17)
.or_else(|| name_string(name, 2))
.unwrap_or_default();
let full_name =
name_string(name, 4).unwrap_or_else(|| format!("{family} {subfamily}").trim().to_string());
let postscript_name = name_string(name, 6).unwrap_or_default();
if family.is_empty() && full_name.is_empty() {
return Err("The font has no readable name".into());
@@ -460,7 +496,10 @@ pub fn inspect_bytes(input: &[u8]) -> Result<(Vec<u8>, FontInspection), String>
// storage
fn font_path(local_dir: &Path, sha256: &str, format: &str) -> Result<PathBuf, String> {
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
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 font hash is malformed".to_string());
}
@@ -493,19 +532,31 @@ fn map_row(r: &rusqlite::Row) -> rusqlite::Result<UserFont> {
}
fn get_font(conn: &Connection, id: i64) -> Result<Option<UserFont>, String> {
conn.query_row(&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"), params![id], map_row)
.optional()
.map_err(|e| e.to_string())
conn.query_row(
&format!("SELECT {COLS} FROM user_fonts WHERE id = ?1"),
params![id],
map_row,
)
.optional()
.map_err(|e| e.to_string())
}
pub fn list_user_fonts_impl(conn: &Connection, include_hidden: bool) -> Result<Vec<UserFont>, String> {
pub fn list_user_fonts_impl(
conn: &Connection,
include_hidden: bool,
) -> Result<Vec<UserFont>, String> {
let sql = format!(
"SELECT {COLS} FROM user_fonts {} ORDER BY face, weight, style, id",
if include_hidden { "" } else { "WHERE hidden = 0" }
if include_hidden {
""
} else {
"WHERE hidden = 0"
}
);
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let rows = stmt.query_map([], map_row).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
}
/// Whether any invoice's frozen render prefs name this font file.
@@ -531,7 +582,11 @@ pub struct ImportRequest<'a> {
fn clean_file_name(raw: &str) -> String {
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
let cleaned: String = base.chars().filter(|c| !c.is_control()).take(120).collect();
if cleaned.trim().is_empty() { "font".to_string() } else { cleaned }
if cleaned.trim().is_empty() {
"font".to_string()
} else {
cleaned
}
}
pub fn import_font_impl(
@@ -541,7 +596,10 @@ pub fn import_font_impl(
req: &ImportRequest,
) -> Result<UserFont, String> {
if !FACES.contains(&req.face) {
return Err(format!("Fonts can be imported for {} only", FACES.join(", ")));
return Err(format!(
"Fonts can be imported for {} only",
FACES.join(", ")
));
}
if !req.licence_acknowledged {
return Err("Confirm that you hold a licence for this font before importing it".into());
@@ -567,7 +625,9 @@ pub fn import_font_impl(
let path = font_path(local_dir, &info.sha256, &info.format)?;
std::fs::create_dir_all(path.parent().expect("font path has a parent"))
.map_err(|e| format!("Could not create the fonts folder: {e}"))?;
let present = std::fs::metadata(&path).map(|m| m.len() == sfnt.len() as u64).unwrap_or(false);
let present = std::fs::metadata(&path)
.map(|m| m.len() == sfnt.len() as u64)
.unwrap_or(false);
if !present {
write_atomic(&path, &sfnt)?;
}
@@ -628,12 +688,21 @@ pub fn import_font_impl(
fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf>, String> {
let font = get_font(conn, id)?.ok_or_else(|| "Font not found".to_string())?;
if referenced_by_invoice(conn, &font.sha256)? {
conn.execute("UPDATE user_fonts SET hidden = 1 WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
conn.execute(
"UPDATE user_fonts SET hidden = 1 WHERE id = ?1",
params![id],
)
.map_err(|e| e.to_string())?;
return Ok(None);
}
conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
conn.execute("DELETE FROM user_fonts WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
let shared: i64 = conn
.query_row("SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1", params![font.sha256], |r| r.get(0))
.query_row(
"SELECT COUNT(*) FROM user_fonts WHERE sha256 = ?1",
params![font.sha256],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
if shared > 0 {
return Ok(None);
@@ -641,11 +710,19 @@ fn retire(conn: &Connection, local_dir: &Path, id: i64) -> Result<Option<PathBuf
font_path(local_dir, &font.sha256, &font.format).map(Some)
}
pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) -> Result<RemoveOutcome, String> {
pub fn remove_user_font_impl(
conn: &mut Connection,
local_dir: &Path,
id: i64,
) -> Result<RemoveOutcome, String> {
let tx = conn.transaction().map_err(|e| e.to_string())?;
let file = retire(&tx, local_dir, id)?;
let still_there: bool = tx
.query_row("SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)", params![id], |r| r.get::<_, i64>(0))
.query_row(
"SELECT EXISTS(SELECT 1 FROM user_fonts WHERE id = ?1)",
params![id],
|r| r.get::<_, i64>(0),
)
.map_err(|e| e.to_string())?
!= 0;
tx.commit().map_err(|e| e.to_string())?;
@@ -653,13 +730,24 @@ pub fn remove_user_font_impl(conn: &mut Connection, local_dir: &Path, id: i64) -
// Best effort, as in import: the row is already gone.
let _ = std::fs::remove_file(path);
}
Ok(RemoveOutcome { deleted: !still_there, hidden: still_there })
Ok(RemoveOutcome {
deleted: !still_there,
hidden: still_there,
})
}
/// The stored sfnt for a font file, hidden or not (an issued invoice must still be able to render with it).
pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -> Result<Vec<u8>, String> {
pub fn user_font_bytes_impl(
conn: &Connection,
local_dir: &Path,
sha256: &str,
) -> Result<Vec<u8>, String> {
let format: Option<String> = conn
.query_row("SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1", params![sha256], |r| r.get(0))
.query_row(
"SELECT format FROM user_fonts WHERE sha256 = ?1 LIMIT 1",
params![sha256],
|r| r.get(0),
)
.optional()
.map_err(|e| e.to_string())?;
let format = format.ok_or_else(|| "This font is not in the font library".to_string())?;
@@ -677,20 +765,43 @@ pub fn user_font_bytes_impl(conn: &Connection, local_dir: &Path, sha256: &str) -
Ok(bytes)
}
fn parse_import_headers<'a>(headers: &'a Headers, file_name: &'a str) -> Result<ImportRequest<'a>, String> {
fn parse_import_headers<'a>(
headers: &'a Headers,
file_name: &'a str,
) -> Result<ImportRequest<'a>, String> {
let face = required_header(headers, "x-face")?;
let weight = match headers.get("x-weight").map(|v| v.trim()).filter(|v| !v.is_empty()) {
let weight = match headers
.get("x-weight")
.map(|v| v.trim())
.filter(|v| !v.is_empty())
{
None => None,
Some(v) => Some(v.parse::<u16>().map_err(|_| "x-weight must be a number".to_string())?),
Some(v) => Some(
v.parse::<u16>()
.map_err(|_| "x-weight must be a number".to_string())?,
),
};
let italic = match headers.get("x-style").map(|v| v.trim()).filter(|v| !v.is_empty()) {
let italic = match headers
.get("x-style")
.map(|v| v.trim())
.filter(|v| !v.is_empty())
{
None => None,
Some("normal") => Some(false),
Some("italic") => Some(true),
Some(_) => return Err("x-style must be normal or italic".into()),
};
let licence_acknowledged = headers.get("x-licence-ack").map(|v| v.trim() == "true").unwrap_or(false);
Ok(ImportRequest { face, file_name, weight, italic, licence_acknowledged })
let licence_acknowledged = headers
.get("x-licence-ack")
.map(|v| v.trim() == "true")
.unwrap_or(false);
Ok(ImportRequest {
face,
file_name,
weight,
italic,
licence_acknowledged,
})
}
// The commands are async so parsing, hashing and file I/O of a multi-MB font stay off the main thread.
@@ -701,10 +812,18 @@ pub async fn inspect_font(request: Request<'_>) -> Result<FontInspection, String
}
#[tauri::command]
pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Result<UserFont, String> {
pub async fn import_font(
request: Request<'_>,
state: State<'_, AppState>,
) -> Result<UserFont, String> {
let bytes = raw_body(&request)?;
let headers = header_map(&request);
let file_name = decode_header_path(headers.get("x-file-name").map(String::as_str).unwrap_or("font"))?;
let file_name = decode_header_path(
headers
.get("x-file-name")
.map(String::as_str)
.unwrap_or("font"),
)?;
let face = decode_header_path(required_header(&headers, "x-face")?)?;
let mut headers = headers;
headers.insert("x-face".to_string(), face);
@@ -714,19 +833,28 @@ pub async fn import_font(request: Request<'_>, state: State<'_, AppState>) -> Re
}
#[tauri::command]
pub async fn list_user_fonts(include_hidden: Option<bool>, state: State<'_, AppState>) -> Result<Vec<UserFont>, String> {
pub async fn list_user_fonts(
include_hidden: Option<bool>,
state: State<'_, AppState>,
) -> Result<Vec<UserFont>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_user_fonts_impl(&conn, include_hidden.unwrap_or(false))
}
#[tauri::command]
pub async fn get_user_font_bytes(sha256: String, state: State<'_, AppState>) -> Result<Response, String> {
pub async fn get_user_font_bytes(
sha256: String,
state: State<'_, AppState>,
) -> Result<Response, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
user_font_bytes_impl(&conn, &state.local_data_dir, &sha256).map(Response::new)
}
#[tauri::command]
pub async fn remove_user_font(id: i64, state: State<'_, AppState>) -> Result<RemoveOutcome, String> {
pub async fn remove_user_font(
id: i64,
state: State<'_, AppState>,
) -> Result<RemoveOutcome, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
remove_user_font_impl(&mut conn, &state.local_data_dir, id)
}
@@ -740,7 +868,9 @@ mod tests {
use tempfile::tempdir;
fn jost(name: &str) -> Vec<u8> {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/jost").join(name);
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../public/fonts/jost")
.join(name);
std::fs::read(path).unwrap()
}
fn regular() -> Vec<u8> {
@@ -781,7 +911,11 @@ mod tests {
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
enc.write_all(raw).unwrap();
let z = enc.finish().unwrap();
blobs.push(if z.len() < raw.len() { (*tag, z, raw.len()) } else { (*tag, raw.to_vec(), raw.len()) });
blobs.push(if z.len() < raw.len() {
(*tag, z, raw.len())
} else {
(*tag, raw.to_vec(), raw.len())
});
}
let mut out = vec![0u8; 44 + 20 * n];
out[0..4].copy_from_slice(b"wOFF");
@@ -803,7 +937,13 @@ mod tests {
}
fn request<'a>(face: &'a str, ack: bool) -> ImportRequest<'a> {
ImportRequest { face, file_name: "C:\\fonts\\Jost-Regular.ttf", weight: None, italic: None, licence_acknowledged: ack }
ImportRequest {
face,
file_name: "C:\\fonts\\Jost-Regular.ttf",
weight: None,
italic: None,
licence_acknowledged: ack,
}
}
#[test]
@@ -821,9 +961,17 @@ mod tests {
assert_eq!(info.sha256, sha256_hex(&regular()));
assert_eq!(info.warnings.len(), 1, "{:?}", info.warnings);
assert!(info.warnings[0].contains("rupee"));
let poppins = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/fonts/poppins/Poppins-Regular.ttf")).unwrap();
let poppins = std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../public/fonts/poppins/Poppins-Regular.ttf"),
)
.unwrap();
let (_, with_rupee) = inspect_bytes(&poppins).unwrap();
assert!(with_rupee.has_rupee && with_rupee.warnings.is_empty(), "{:?}", with_rupee.warnings);
assert!(
with_rupee.has_rupee && with_rupee.warnings.is_empty(),
"{:?}",
with_rupee.warnings
);
assert_eq!(inspect_bytes(&bold()).unwrap().1.weight, 700);
}
@@ -835,7 +983,9 @@ mod tests {
let mut ttc = regular();
ttc[0..4].copy_from_slice(b"ttcf");
assert!(inspect_bytes(&ttc).unwrap_err().contains("collections"));
assert!(inspect_bytes(b"<!doctype html><html></html>").unwrap_err().contains("not a TrueType"));
assert!(inspect_bytes(b"<!doctype html><html></html>")
.unwrap_err()
.contains("not a TrueType"));
assert!(inspect_bytes(b"short").is_err());
let mut big = regular();
big.resize(MAX_FONT_BYTES + 1, 0);
@@ -856,7 +1006,9 @@ mod tests {
fn fs_type_rules() {
let mut restricted = regular();
set_fs_type(&mut restricted, FS_RESTRICTED);
assert!(inspect_bytes(&restricted).unwrap_err().contains("restricted"));
assert!(inspect_bytes(&restricted)
.unwrap_err()
.contains("restricted"));
// A more permissive bit alongside wins (least restrictive).
let mut both = regular();
@@ -865,11 +1017,21 @@ mod tests {
let mut preview = regular();
set_fs_type(&mut preview, FS_PREVIEW_PRINT);
assert!(inspect_bytes(&preview).unwrap().1.warnings.iter().any(|w| w.contains("preview and print")));
assert!(inspect_bytes(&preview)
.unwrap()
.1
.warnings
.iter()
.any(|w| w.contains("preview and print")));
let mut no_subset = regular();
set_fs_type(&mut no_subset, FS_NO_SUBSETTING);
assert!(inspect_bytes(&no_subset).unwrap().1.warnings.iter().any(|w| w.contains("subset")));
assert!(inspect_bytes(&no_subset)
.unwrap()
.1
.warnings
.iter()
.any(|w| w.contains("subset")));
let mut bitmap = regular();
set_fs_type(&mut bitmap, FS_BITMAP_ONLY);
@@ -880,13 +1042,18 @@ mod tests {
fn refuses_a_font_without_outlines() {
let mut font = regular();
rename_table(&mut font, b"glyf", b"XXXX");
assert!(inspect_bytes(&font).unwrap_err().contains("no glyph outlines"));
assert!(inspect_bytes(&font)
.unwrap_err()
.contains("no glyph outlines"));
}
#[test]
fn unpacks_woff_to_the_same_font() {
let woff = to_woff(&regular());
assert!(woff.len() < regular().len(), "the fixture should actually compress");
assert!(
woff.len() < regular().len(),
"the fixture should actually compress"
);
let (sfnt, info) = inspect_bytes(&woff).unwrap();
assert_eq!(info.source_format, "woff");
assert_eq!(info.family, "Jost");
@@ -897,7 +1064,12 @@ mod tests {
let unpacked = Sfnt::parse(&sfnt).unwrap();
assert_eq!(original.tables.len(), unpacked.tables.len());
for (tag, _, _) in &original.tables {
assert_eq!(original.table(tag), unpacked.table(tag), "table {}", String::from_utf8_lossy(tag));
assert_eq!(
original.table(tag),
unpacked.table(tag),
"table {}",
String::from_utf8_lossy(tag)
);
}
let mut damaged = woff.clone();
let len = damaged.len();
@@ -909,17 +1081,28 @@ mod tests {
fn import_stores_content_addressed_and_round_trips() {
let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
let row =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
assert_eq!(row.face, "Gotham");
assert_eq!((row.weight, row.style.as_str(), row.format.as_str()), (400, "normal", "ttf"));
assert_eq!(row.file_name, "Jost-Regular.ttf", "only the base name is kept");
assert_eq!(
(row.weight, row.style.as_str(), row.format.as_str()),
(400, "normal", "ttf")
);
assert_eq!(
row.file_name, "Jost-Regular.ttf",
"only the base name is kept"
);
assert!(!row.hidden && row.licence_ack_at.len() > 10);
let path = dir.path().join("fonts").join(format!("{}.ttf", row.sha256));
assert_eq!(std::fs::read(&path).unwrap(), regular());
assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular());
assert_eq!(
user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(),
regular()
);
// Importing the same file again is a no-op.
let again = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
let again =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
assert_eq!(again.id, row.id);
assert_eq!(list_user_fonts_impl(&conn, false).unwrap().len(), 1);
@@ -933,18 +1116,32 @@ mod tests {
fn import_overrides_and_guards() {
let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
assert!(import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", false))
.unwrap_err()
.contains("licence"));
assert!(import_font_impl(&mut conn, dir.path(), &regular(), &request("Montserrat", true))
.unwrap_err()
.contains("Now, Gotham, Open Sauce One"));
assert!(!dir.path().join("fonts").exists(), "a refused import writes nothing");
assert!(
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", false))
.unwrap_err()
.contains("licence")
);
assert!(import_font_impl(
&mut conn,
dir.path(),
&regular(),
&request("Montserrat", true)
)
.unwrap_err()
.contains("Now, Gotham, Open Sauce One"));
assert!(
!dir.path().join("fonts").exists(),
"a refused import writes nothing"
);
let mut woff2 = regular();
woff2[0..4].copy_from_slice(b"wOF2");
assert!(import_font_impl(&mut conn, dir.path(), &woff2, &request("Now", true)).is_err());
let req = ImportRequest { weight: Some(300), italic: Some(true), ..request("Open Sauce One", true) };
let req = ImportRequest {
weight: Some(300),
italic: Some(true),
..request("Open Sauce One", true)
};
let row = import_font_impl(&mut conn, dir.path(), &regular(), &req).unwrap();
assert_eq!((row.weight, row.style.as_str()), (300, "italic"));
// The table itself refuses other faces.
@@ -962,15 +1159,23 @@ mod tests {
fn replacing_a_slot_removes_the_old_unreferenced_font() {
let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
let first = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
let first =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
// Same slot, different file (the bold file declared as 400).
let req = ImportRequest { weight: Some(400), ..request("Gotham", true) };
let req = ImportRequest {
weight: Some(400),
..request("Gotham", true)
};
let second = import_font_impl(&mut conn, dir.path(), &bold(), &req).unwrap();
assert_ne!(first.sha256, second.sha256);
let rows = list_user_fonts_impl(&conn, true).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].sha256, second.sha256);
assert!(!dir.path().join("fonts").join(format!("{}.ttf", first.sha256)).exists());
assert!(!dir
.path()
.join("fonts")
.join(format!("{}.ttf", first.sha256))
.exists());
}
fn issue_invoice_with_prefs(conn: &Connection, prefs: &str) {
@@ -986,11 +1191,22 @@ mod tests {
fn remove_deletes_when_unreferenced() {
let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap();
let row =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap();
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap();
assert_eq!(out, RemoveOutcome { deleted: true, hidden: false });
assert_eq!(
out,
RemoveOutcome {
deleted: true,
hidden: false
}
);
assert!(list_user_fonts_impl(&conn, true).unwrap().is_empty());
assert!(!dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists());
assert!(!dir
.path()
.join("fonts")
.join(format!("{}.ttf", row.sha256))
.exists());
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).is_err());
assert!(remove_user_font_impl(&mut conn, dir.path(), row.id).is_err());
}
@@ -999,20 +1215,42 @@ mod tests {
fn remove_hides_a_font_an_issued_invoice_references() {
let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
let row =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
issue_invoice_with_prefs(
&conn,
&format!(r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#, row.sha256),
&format!(
r#"{{"version":1,"templateId":"purple-pop","fonts":{{"Gotham":["{}"]}}}}"#,
row.sha256
),
);
let out = remove_user_font_impl(&mut conn, dir.path(), row.id).unwrap();
assert_eq!(out, RemoveOutcome { deleted: false, hidden: true });
assert_eq!(
out,
RemoveOutcome {
deleted: false,
hidden: true
}
);
// Gone from the visible list, but kept and still served so the invoice can re-render.
assert!(list_user_fonts_impl(&conn, false).unwrap().is_empty());
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 1);
assert_eq!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(), regular());
assert_eq!(
user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap(),
regular()
);
// The slot is free again: importing a new font for it works, and the hidden one stays.
let fresh = import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap();
let fresh = import_font_impl(
&mut conn,
dir.path(),
&bold(),
&ImportRequest {
weight: Some(400),
..request("Gotham", true)
},
)
.unwrap();
assert_ne!(fresh.sha256, row.sha256);
assert_eq!(list_user_fonts_impl(&conn, true).unwrap().len(), 2);
}
@@ -1021,10 +1259,27 @@ mod tests {
fn replacing_a_referenced_font_hides_it_instead_of_deleting() {
let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
issue_invoice_with_prefs(&conn, &format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256));
import_font_impl(&mut conn, dir.path(), &bold(), &ImportRequest { weight: Some(400), ..request("Gotham", true) }).unwrap();
assert!(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)).exists());
let row =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
issue_invoice_with_prefs(
&conn,
&format!(r#"{{"fonts":{{"Gotham":["{}"]}}}}"#, row.sha256),
);
import_font_impl(
&mut conn,
dir.path(),
&bold(),
&ImportRequest {
weight: Some(400),
..request("Gotham", true)
},
)
.unwrap();
assert!(dir
.path()
.join("fonts")
.join(format!("{}.ttf", row.sha256))
.exists());
let all = list_user_fonts_impl(&conn, true).unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all.iter().filter(|f| f.hidden).count(), 1);
@@ -1034,22 +1289,38 @@ mod tests {
fn a_file_shared_by_two_faces_survives_removing_one() {
let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
let a = import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
let a =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Gotham", true)).unwrap();
let b = import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap();
assert_eq!(a.sha256, b.sha256);
remove_user_font_impl(&mut conn, dir.path(), a.id).unwrap();
assert!(dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists());
assert!(dir
.path()
.join("fonts")
.join(format!("{}.ttf", a.sha256))
.exists());
remove_user_font_impl(&mut conn, dir.path(), b.id).unwrap();
assert!(!dir.path().join("fonts").join(format!("{}.ttf", a.sha256)).exists());
assert!(!dir
.path()
.join("fonts")
.join(format!("{}.ttf", a.sha256))
.exists());
}
#[test]
fn bytes_are_verified_and_the_hash_shape_checked() {
let dir = tempdir().unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
let row = import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap();
std::fs::write(dir.path().join("fonts").join(format!("{}.ttf", row.sha256)), b"tampered").unwrap();
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256).unwrap_err().contains("corrupted"));
let row =
import_font_impl(&mut conn, dir.path(), &regular(), &request("Now", true)).unwrap();
std::fs::write(
dir.path().join("fonts").join(format!("{}.ttf", row.sha256)),
b"tampered",
)
.unwrap();
assert!(user_font_bytes_impl(&conn, dir.path(), &row.sha256)
.unwrap_err()
.contains("corrupted"));
assert!(font_path(dir.path(), "../../etc/passwd", "ttf").is_err());
}
@@ -1061,11 +1332,18 @@ mod tests {
h.insert("x-weight".into(), "700".into());
h.insert("x-style".into(), "italic".into());
let req = parse_import_headers(&h, "f.ttf").unwrap();
assert_eq!((req.weight, req.italic, req.licence_acknowledged), (Some(700), Some(true), true));
assert_eq!(
(req.weight, req.italic, req.licence_acknowledged),
(Some(700), Some(true), true)
);
h.insert("x-style".into(), "oblique".into());
assert!(parse_import_headers(&h, "f.ttf").is_err());
h.remove("x-licence-ack");
h.insert("x-style".into(), "".into());
assert!(!parse_import_headers(&h, "f.ttf").unwrap().licence_acknowledged);
assert!(
!parse_import_headers(&h, "f.ttf")
.unwrap()
.licence_acknowledged
);
}
}
+240 -59
View File
@@ -1,12 +1,14 @@
use super::assets::relative_asset_path;
use super::payments::{derive_summary, summary_for, today_ist};
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::gst::{self, TaxType};
use super::payments::{derive_summary, summary_for, today_ist};
use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
Settings,
};
use crate::units;
use crate::AppState;
use chrono::NaiveDate;
use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
@@ -130,6 +132,15 @@ fn parse_date(label: &str, value: &str) -> Result<(), String> {
/// overflow integer paise arithmetic.
const MAX_LINE_RUPEES: f64 = 1e11;
/// The unit written to the database. A fixed-amount line has no unit of its own, so whatever the form held is not kept.
fn stored_unit(item: &InvoiceItem) -> String {
if item.mode == "rate" {
units::normalize(&item.unit)
} else {
"unit".to_string()
}
}
fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
if items.is_empty() {
return Err("Add at least one line item".into());
@@ -140,11 +151,14 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
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"));
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"));
}
units::validate(&item.unit).map_err(|e| format!("Line {n}: {e}"))?;
let amount = gst::line_amount_paise(item);
if amount <= 0 {
return Err(format!("Line {n}: amount must be greater than zero"));
@@ -271,8 +285,9 @@ pub fn issue_invoice_impl(
.map_err(db)?
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
validate_series_format(&prefix, padding)
.map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new 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!(
@@ -296,10 +311,16 @@ pub fn issue_invoice_impl(
};
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"));
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);
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 {}",
@@ -309,7 +330,11 @@ pub fn issue_invoice_impl(
if pos.is_empty() { &settings.vendor_state_code } else { &pos },
));
}
let tax_rate = if derived == TaxType::None { 0.0 } else { input.tax_rate };
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());
}
@@ -330,9 +355,9 @@ pub fn issue_invoice_impl(
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}"))?,
),
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())?;
@@ -427,7 +452,7 @@ pub fn issue_invoice_impl(
item.description,
item.mode,
item.rate,
item.unit,
stored_unit(item),
item.quantity,
paise_to_rupees(gst::line_amount_paise(item)),
i as i64,
@@ -445,8 +470,11 @@ pub fn issue_invoice_impl(
.map_err(db)?;
if let Some(draft_id) = input.draft_id {
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
.map_err(db)?;
tx.execute(
"DELETE FROM invoice_drafts WHERE id = ?1",
params![draft_id],
)
.map_err(db)?;
}
tx.commit().map_err(db)?;
@@ -464,9 +492,17 @@ pub fn issue_invoice(
}
/// 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> {
pub fn cancel_invoice_impl(
conn: &mut Connection,
id: i64,
reason: &str,
) -> Result<Invoice, String> {
let payments: i64 = conn
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
.query_row(
"SELECT COUNT(*) FROM payments WHERE invoice_id = ?1",
params![id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
if payments > 0 {
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
@@ -482,7 +518,11 @@ pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Resu
.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))
.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 {
@@ -525,7 +565,14 @@ pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, Stri
let status: String = row.get(6)?;
let due_date: String = row.get(3)?;
let total_paise = gst::rupees_to_paise(total);
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
let payment = derive_summary(
&status,
total_paise,
row.get(9)?,
row.get(10)?,
&due_date,
&today,
);
Ok(InvoiceSummary {
id: row.get(0)?,
number: row.get(1)?,
@@ -566,7 +613,8 @@ pub struct InvoiceLedgerRow {
}
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> =
std::collections::HashMap::new();
{
let mut stmt = conn
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
@@ -666,11 +714,17 @@ pub fn save_draft_impl(
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")
.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)?))
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
@@ -682,7 +736,10 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
DraftSummary {
id,
updated_at,
client_name: json.get("clientName").and_then(|v| v.as_str()).map(String::from),
client_name: json
.get("clientName")
.and_then(|v| v.as_str())
.map(String::from),
total: json.get("total").and_then(|v| v.as_f64()),
}
})
@@ -691,7 +748,11 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
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))
.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())?;
@@ -765,19 +826,28 @@ mod tests {
}
fn next_number(conn: &Connection) -> i64 {
conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
.unwrap()
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()
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0))
.unwrap()
}
#[test]
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
let mut conn = registered();
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
conn.execute(
"UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1",
params![first.id],
)
.unwrap();
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
params![first.id],
@@ -791,7 +861,10 @@ mod tests {
assert_eq!(row.doc_type, "tax_invoice");
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
assert_eq!(row.taxable_paise, 731_000);
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
assert_eq!(
(row.cgst_paise, row.sgst_paise, row.igst_paise),
(65_790, 65_790, 0)
);
assert!(!row.reverse_charge);
}
@@ -854,6 +927,43 @@ mod tests {
assert_eq!(inv.items[1].hsn_sac, "");
}
#[test]
fn lines_can_use_any_unit_and_fractional_quantities() {
let mut conn = registered();
let inv = issue(
&mut conn,
input(json!({
"items": [
{"description": "Travel", "mode": "rate", "rate": 18.0, "quantity": 12.75, "unit": "km"},
{"description": "Studio", "mode": "rate", "rate": 1200.0, "quantity": 4.0 + 1.0 / 3.0, "unit": "HOUR"},
{"description": "Parcels", "mode": "rate", "rate": 99.5, "quantity": 3.0, "unit": " bundle "},
{"description": "Fee", "mode": "fixed", "amount": 10.0, "unit": "not a unit;"},
],
})),
)
.unwrap();
let units: Vec<&str> = inv.items.iter().map(|i| i.unit.as_str()).collect();
assert_eq!(units, ["km", "hour", "bundle", "unit"]);
let amounts: Vec<f64> = inv.items.iter().map(|i| i.amount).collect();
// 18 x 12.75 = 229.50; 1200 x 4h20m = 5200.00 (one rounding, on the exact quantity); 99.50 x 3 = 298.50.
assert_eq!(amounts, [229.5, 5200.0, 298.5, 10.0]);
}
#[test]
fn a_rate_line_with_a_bad_unit_is_refused_and_the_counter_stays() {
let mut conn = registered();
for bad in ["", " ", "thirteen char", "x;y", "\u{20b9}"] {
let err = issue(
&mut conn,
input(json!({"items": [{"description": "Work", "mode": "rate", "rate": 10.0, "quantity": 1.0, "unit": bad}]})),
)
.unwrap_err();
assert!(err.starts_with("Line 1: "), "{bad:?}: {err}");
}
assert_eq!(next_number(&conn), 1);
assert_eq!(invoice_count(&conn), 0);
}
#[test]
fn tax_type_mismatch_is_rejected_and_the_counter_stays() {
let mut conn = registered();
@@ -862,7 +972,10 @@ mod tests {
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
)
.unwrap_err();
assert!(err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), "{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);
@@ -872,7 +985,11 @@ mod tests {
#[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();
conn.execute(
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'",
[],
)
.unwrap();
let inv = issue(
&mut conn,
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
@@ -929,7 +1046,8 @@ mod tests {
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();
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);
}
@@ -937,10 +1055,12 @@ mod tests {
#[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 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");
assert_eq!(bank["bankName"], "Test Bank");
let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
assert!(err.contains("bank account"), "{err}");
}
@@ -956,15 +1076,22 @@ mod tests {
params![format!("INV/{year}-001")],
)
.unwrap();
conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", [])
.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();
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));
}
@@ -979,10 +1106,19 @@ mod tests {
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!({"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!({"placeOfSupplyStateCode": "99"}),
"not a valid GST state code",
),
(json!({"taxRate": 150.0}), "between 0 and 100"),
(json!({"signaturePath": "../voiced.db"}), "Signature image"),
];
@@ -1001,31 +1137,58 @@ mod tests {
#[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 = '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!(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();
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();
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(
@@ -1033,7 +1196,9 @@ mod tests {
[],
)
.unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16"));
assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("longer than 16"));
assert_eq!(invoice_count(&conn), 0);
}
@@ -1061,7 +1226,10 @@ mod tests {
&json!({"theme": "plain"}),
)
.unwrap();
assert_eq!(inv.signature_path.as_deref(), Some("assets/signature-1.png"));
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();
@@ -1082,8 +1250,14 @@ mod tests {
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, 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.
@@ -1095,10 +1269,16 @@ mod tests {
#[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 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();
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());
@@ -1111,7 +1291,8 @@ mod tests {
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();
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a])
.unwrap();
assert!(get_draft_impl(&conn, a).is_err());
}
}
+68 -18
View File
@@ -21,14 +21,22 @@ pub struct LogoAsset {
}
fn sha8(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().take(4).map(|b| format!("{b:02x}")).collect()
Sha256::digest(bytes)
.iter()
.take(4)
.map(|b| format!("{b:02x}"))
.collect()
}
/// Derive the print and knockout images from `bytes` and store them content-addressed in the asset
/// store. `existing_original` is the already stored file the bytes came from (legacy logos); without
/// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at
/// older logo files.
fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>) -> Result<LogoAsset, String> {
fn store_logo(
data_dir: &Path,
bytes: &[u8],
existing_original: Option<String>,
) -> Result<LogoAsset, String> {
let derived = process_logo(bytes).map_err(|e| e.to_string())?;
let id = sha8(bytes);
let original_path = match existing_original {
@@ -38,17 +46,28 @@ fn store_logo(data_dir: &Path, bytes: &[u8], existing_original: Option<String>)
write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)?
}
};
let print_path = write_named_asset(data_dir, &format!("logo-{id}-print.png"), &derived.print_png)?;
let print_path = write_named_asset(
data_dir,
&format!("logo-{id}-print.png"),
&derived.print_png,
)?;
let knockout_path = derived
.knockout_png
.as_deref()
.map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png))
.transpose()?;
Ok(LogoAsset { original_path, print_path, knockout_path, metrics: derived.metrics })
Ok(LogoAsset {
original_path,
print_path,
knockout_path,
metrics: derived.metrics,
})
}
pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result<LogoAsset, String> {
let len = std::fs::metadata(source_path).map_err(|e| e.to_string())?.len();
let len = std::fs::metadata(source_path)
.map_err(|e| e.to_string())?
.len();
if len > MAX_INPUT_BYTES as u64 {
return Err(crate::logo::LogoError::TooLarge.to_string());
}
@@ -61,7 +80,9 @@ pub fn import_logo_from_base64(data_dir: &Path, data_base64: &str) -> Result<Log
if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 {
return Err(crate::logo::LogoError::TooLarge.to_string());
}
let bytes = STANDARD.decode(data_base64.as_bytes()).map_err(|e| e.to_string())?;
let bytes = STANDARD
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?;
store_logo(data_dir, &bytes, None)
}
@@ -74,9 +95,11 @@ fn metrics_json(metrics: &LogoMetrics) -> Result<String, String> {
/// original. Returns whether anything changed.
pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bool, String> {
let (logo_path, meta): (Option<String>, String) = conn
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.query_row(
"SELECT logo_path, logo_meta FROM app_settings WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.map_err(|e| e.to_string())?;
let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else {
return Ok(false);
@@ -86,11 +109,20 @@ pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result<bo
}
let file = resolve_asset(data_dir, &logo_path)?;
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
let asset = store_logo(data_dir, &bytes, Some(relative_asset_path(data_dir, &logo_path)?))?;
let asset = store_logo(
data_dir,
&bytes,
Some(relative_asset_path(data_dir, &logo_path)?),
)?;
conn.execute(
"UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3,
logo_meta = ?4 WHERE id = 1",
params![asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics)?],
params![
asset.print_path,
asset.original_path,
asset.knockout_path,
metrics_json(&asset.metrics)?
],
)
.map_err(|e| e.to_string())?;
Ok(true)
@@ -161,7 +193,9 @@ mod tests {
}
}
let mut out = Vec::new();
DynamicImage::ImageRgba8(img).write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
DynamicImage::ImageRgba8(img)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
@@ -180,16 +214,30 @@ mod tests {
let bytes = padded_png();
let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap();
let id = sha8(&bytes);
assert_eq!(asset.original_path, format!("assets/logo-{id}-original.png"));
assert_eq!(
asset.original_path,
format!("assets/logo-{id}-original.png")
);
assert_eq!(asset.print_path, format!("assets/logo-{id}-print.png"));
assert_eq!(asset.knockout_path.as_deref(), Some(format!("assets/logo-{id}-knockout.png").as_str()));
assert_eq!(
asset.knockout_path.as_deref(),
Some(format!("assets/logo-{id}-knockout.png").as_str())
);
assert_eq!((asset.metrics.width, asset.metrics.height), (100, 20));
assert_eq!(std::fs::read(dir.path().join(&asset.original_path)).unwrap(), bytes);
assert_eq!(
std::fs::read(dir.path().join(&asset.original_path)).unwrap(),
bytes
);
for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] {
assert!(resolve_asset(dir.path(), rel).is_ok());
}
// The same bytes land on the same names.
assert_eq!(import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap().print_path, asset.print_path);
assert_eq!(
import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes))
.unwrap()
.print_path,
asset.print_path
);
let json = serde_json::to_value(&asset).unwrap();
assert_eq!(json["metrics"]["kind"], "wordmark");
assert_eq!(json["metrics"]["opaqueBackground"], false);
@@ -220,7 +268,8 @@ mod tests {
let dir = tempdir().unwrap();
let conn = crate::db::open_in_memory().unwrap();
let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).unwrap();
conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy]).unwrap();
conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy])
.unwrap();
assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap());
let (logo, original, knockout, meta) = logo_columns(&conn);
@@ -243,7 +292,8 @@ mod tests {
let dir = tempdir().unwrap();
let conn = crate::db::open_in_memory().unwrap();
assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap());
conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", []).unwrap();
conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", [])
.unwrap();
assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err());
// Left untouched so a later start can retry.
assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png"));
+1
View File
@@ -10,5 +10,6 @@ pub mod logo;
pub mod payments;
pub mod presets;
pub mod raw;
pub mod reset;
pub mod series;
pub mod settings;
+84 -20
View File
@@ -53,7 +53,11 @@ pub struct PaymentInput {
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
pub fn today_ist() -> String {
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
Utc::now().with_timezone(&ist).date_naive().format("%Y-%m-%d").to_string()
Utc::now()
.with_timezone(&ist)
.date_naive()
.format("%Y-%m-%d")
.to_string()
}
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
@@ -112,7 +116,14 @@ pub fn summary_for(
params![invoice_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok(derive_summary(status, rupees_to_paise(total), paid, tds, due_date, &today_ist()))
Ok(derive_summary(
status,
rupees_to_paise(total),
paid,
tds,
due_date,
&today_ist(),
))
}
fn rupees(paise: i64) -> String {
@@ -130,7 +141,9 @@ fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
reference: r.get(6)?,
notes: r.get(7)?,
created_at: r.get(8)?,
erpnext_payment_entry: r.get::<_, Option<String>>(9)?.filter(|e| !e.trim().is_empty()),
erpnext_payment_entry: r
.get::<_, Option<String>>(9)?
.filter(|e| !e.trim().is_empty()),
})
}
@@ -165,7 +178,9 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result
.map_err(db)?
.ok_or_else(|| "Invoice not found".to_string())?;
if status != "issued" {
return Err(format!("Payments can only be recorded on an issued invoice (this one is {status})"));
return Err(format!(
"Payments can only be recorded on an issued invoice (this one is {status})"
));
}
let total_paise = rupees_to_paise(total);
let settled: i64 = tx
@@ -203,7 +218,11 @@ pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result
.map_err(db)?;
let id = tx.last_insert_rowid();
let payment = tx
.query_row(&format!("SELECT {COLS} FROM payments WHERE id = ?1"), params![id], map_payment)
.query_row(
&format!("SELECT {COLS} FROM payments WHERE id = ?1"),
params![id],
map_payment,
)
.map_err(db)?;
tx.commit().map_err(db)?;
Ok(payment)
@@ -270,7 +289,9 @@ mod tests {
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
}))
.unwrap();
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap().id
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({}))
.unwrap()
.id
}
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
@@ -287,9 +308,11 @@ mod tests {
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
let (status, total, due): (String, f64, String) = conn
.query_row("SELECT status, total, due_date FROM invoices WHERE id = ?1", params![id], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.query_row(
"SELECT status, total, due_date FROM invoices WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
summary_for(conn, id, &status, total, &due).unwrap()
}
@@ -303,7 +326,10 @@ mod tests {
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.balance_paise), ("partially_paid", 40_000, 60_000));
assert_eq!(
(s.payment_status.as_str(), s.paid_paise, s.balance_paise),
("partially_paid", 40_000, 60_000)
);
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
let s = summary(&conn, id);
@@ -317,7 +343,15 @@ mod tests {
// 10% TDS: the client pays 900.00 in cash and deducts 100.00.
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.tds_paise, s.balance_paise), ("paid", 90_000, 10_000, 0));
assert_eq!(
(
s.payment_status.as_str(),
s.paid_paise,
s.tds_paise,
s.balance_paise
),
("paid", 90_000, 10_000, 0)
);
// TDS alone is a valid payment too.
let id2 = issued(&mut conn, 500.0, "2999-01-01");
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
@@ -329,13 +363,18 @@ mod tests {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
assert!(err.contains("more than the outstanding balance of ₹1000.00"), "{err}");
assert!(
err.contains("more than the outstanding balance of ₹1000.00"),
"{err}"
);
// Cash plus TDS is what counts.
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap();
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
assert!(err.contains("₹500.00"), "{err}");
let n: i64 = conn.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0)).unwrap();
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
}
@@ -374,7 +413,11 @@ mod tests {
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![id2]).unwrap();
conn.execute(
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
params![id2],
)
.unwrap();
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
}
@@ -397,27 +440,38 @@ mod tests {
assert_eq!(summary(&conn, id).payment_status, "paid");
delete_payment_impl(&conn, full.id).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("unpaid", 100_000));
assert_eq!(
(s.payment_status.as_str(), s.balance_paise),
("unpaid", 100_000)
);
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
assert!(delete_payment_impl(&conn, full.id).is_err());
}
#[test]
fn overdue_is_derived_from_due_date_and_balance() {
let d = |due: &str, paid: i64, tds: i64| derive_summary("issued", 100_000, paid, tds, due, "2026-05-10");
let d = |due: &str, paid: i64, tds: i64| {
derive_summary("issued", 100_000, paid, tds, due, "2026-05-10")
};
// Due today is not overdue yet; the day after is.
assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid");
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
// Overdue wins over partially paid; the paid amount is still reported.
let s = d("2026-05-09", 10_000, 0);
assert_eq!((s.payment_status.as_str(), s.paid_paise), ("overdue", 10_000));
assert_eq!(
(s.payment_status.as_str(), s.paid_paise),
("overdue", 10_000)
);
// A settled invoice is never overdue.
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
// No usable due date means never overdue.
assert_eq!(d("", 0, 0).payment_status, "unpaid");
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
// Cancelled invoices are not owed.
assert_eq!(derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise, 0);
assert_eq!(
derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise,
0
);
}
#[test]
@@ -429,7 +483,10 @@ mod tests {
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.paid_paise, s.balance_paise, s.payment_status.as_str()), (9999, 0, "paid"));
assert_eq!(
(s.paid_paise, s.balance_paise, s.payment_status.as_str()),
(9999, 0, "paid")
);
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
assert_eq!(inv.total_paise, 9999);
}
@@ -445,7 +502,14 @@ mod tests {
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
let ra = get(a);
assert_eq!((ra.payment.paid_paise, ra.payment.tds_paise, ra.payment.balance_paise), (40_000, 5_000, 55_000));
assert_eq!(
(
ra.payment.paid_paise,
ra.payment.tds_paise,
ra.payment.balance_paise
),
(40_000, 5_000, 55_000)
);
assert_eq!(ra.payment.payment_status, "overdue");
assert_eq!(ra.total_paise, 100_000);
assert_eq!(get(b).payment.payment_status, "paid");
+31 -10
View File
@@ -1,10 +1,10 @@
use crate::models::ItemPreset;
use crate::units;
use crate::AppState;
use rusqlite::{params, Connection, Row};
use tauri::State;
const MODES: &[&str] = &["fixed", "rate"];
const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"];
/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
const MAX_RATE_PAISE: i64 = 100_000_000_000;
@@ -45,9 +45,8 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
if !MODES.contains(&preset.mode.as_str()) {
return Err(format!("Unknown line type \"{}\"", preset.mode));
}
if !UNITS.contains(&preset.unit.as_str()) {
return Err(format!("Unknown unit \"{}\"", preset.unit));
}
units::validate(&preset.unit)?;
let unit = units::normalize(&preset.unit);
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
return Err("Amount is out of range".into());
}
@@ -58,7 +57,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
.execute(
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
WHERE id=?7",
params![description, hsn, preset.mode, preset.unit, preset.rate_paise, preset.sort_order, id],
params![description, hsn, preset.mode, unit, preset.rate_paise, preset.sort_order, id],
)
.map_err(|e| e.to_string())?;
if n == 0 {
@@ -74,7 +73,7 @@ pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPre
description,
hsn,
preset.mode,
preset.unit,
unit,
preset.rate_paise,
preset.sort_order,
chrono::Utc::now().to_rfc3339()
@@ -136,7 +135,10 @@ mod tests {
let saved = save_preset_impl(&conn, p).unwrap();
assert_eq!(saved.description, "Voice-over session");
assert_eq!(saved.hsn_sac, "998397");
assert_eq!((saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise), ("rate", "hour", 150_050));
assert_eq!(
(saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise),
("rate", "hour", 150_050)
);
let mut edit = saved.clone();
edit.rate_paise = 200_000;
@@ -156,6 +158,23 @@ mod tests {
delete_preset_impl(&conn, 12345).unwrap();
}
#[test]
fn units_beyond_the_original_five_are_stored_as_given() {
let conn = open_in_memory().unwrap();
for (typed, stored) in [
("km", "km"),
("SQFT", "sqft"),
(" bundle ", "bundle"),
("sq.ft/day", "sq.ft/day"),
] {
let mut p = preset(typed);
p.mode = "rate".into();
p.unit = typed.into();
p.rate_paise = 5_000;
assert_eq!(save_preset_impl(&conn, p).unwrap().unit, stored, "{typed}");
}
}
#[test]
fn validation_rejects_bad_presets() {
let conn = open_in_memory().unwrap();
@@ -166,9 +185,11 @@ mod tests {
let mut p = preset("X");
p.mode = "weird".into();
assert!(save_preset_impl(&conn, p).is_err());
let mut p = preset("X");
p.unit = "decade".into();
assert!(save_preset_impl(&conn, p).is_err());
for bad in ["", " ", "far too long a unit", "bad;unit", "₹"] {
let mut p = preset("X");
p.unit = bad.into();
assert!(save_preset_impl(&conn, p).is_err(), "{bad:?}");
}
let mut ghost = preset("Ghost");
ghost.id = Some(77);
assert!(save_preset_impl(&conn, ghost).is_err());
+231
View File
@@ -0,0 +1,231 @@
//! Reset all data: wipe everything the app owns, then start blank (the first-run wizard shows again).
//!
//! The running app holds `voiced.db` open (WAL) and serves files out of `assets/`, `archive/` and `fonts/`,
//! so deleting them live is unsafe. Like a restore, a reset has two phases:
//!
//! 1. `reset_all_data` (`stage_wipe_impl`) writes `<data>/pending_wipe.json` and touches nothing else.
//! 2. `apply_pending_wipe` runs in `init_state` BEFORE a staged restore is applied and before the database is
//! opened. It deletes exactly the names in `OWNED_DB_FILES` and `OWNED_DIRS`, never anything else in the
//! data directories (on Linux both roots are the same folder and may hold unrelated files), and removes the
//! marker last, so a failed wipe is retried on the next start. The frontend restarts the app after staging.
//!
//! This also deletes the backups (`<data>/backups`, including pre-restore safety copies and pre-migration
//! copies); that is the point of a reset.
use super::backup::{DataDirs, DB_ENTRY, LAST_RESTORE_NAME, MARKER_NAME, ROOTS, STAGING_NAME};
use super::raw::write_atomic;
use crate::AppState;
use std::fs;
use std::path::Path;
use tauri::State;
const WIPE_MARKER_NAME: &str = "pending_wipe.json";
/// The database and its SQLite sidecars, in the data dir.
const OWNED_DB_FILES: [&str; 4] = [
DB_ENTRY,
"voiced.db-wal",
"voiced.db-shm",
"voiced.db-journal",
];
/// Marker files of the restore mechanism, in the data dir.
const OWNED_MARKERS: [&str; 2] = [MARKER_NAME, LAST_RESTORE_NAME];
#[derive(Debug, PartialEq, Eq)]
pub enum WipeOutcome {
NothingPending,
Wiped,
/// Something could not be deleted; the marker is kept so the next start retries.
Failed {
message: String,
},
}
fn marker(dirs: &DataDirs) -> std::path::PathBuf {
dirs.data.join(WIPE_MARKER_NAME)
}
/// Remove a file or directory tree if it exists. A symlink is unlinked, never followed.
fn remove_if_exists(path: &Path) -> Result<(), String> {
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(format!("Could not delete {}: {e}", path.display())),
};
let result = if meta.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
result.map_err(|e| format!("Could not delete {}: {e}", path.display()))
}
/// Record that the next start must wipe everything. The live data is not touched.
pub fn stage_wipe_impl(dirs: &DataDirs) -> Result<(), String> {
write_atomic(&marker(dirs), b"{}")
}
/// Call after the data directories exist and before anything else (restore, database) uses them.
pub fn apply_pending_wipe(dirs: &DataDirs) -> WipeOutcome {
if fs::symlink_metadata(marker(dirs)).is_err() {
return WipeOutcome::NothingPending;
}
let mut targets = Vec::new();
// Order matters: the database goes first so a half-finished wipe never leaves data with no settings row.
for name in OWNED_DB_FILES {
targets.push(dirs.data.join(name));
}
targets.push(dirs.backups());
for root in ROOTS {
targets.push(dirs.root_dir(root));
}
for name in OWNED_MARKERS {
targets.push(dirs.data.join(name));
}
targets.push(dirs.data.join(STAGING_NAME));
targets.push(dirs.local.join(STAGING_NAME));
let errors: Vec<String> = targets
.iter()
.filter_map(|p| remove_if_exists(p).err())
.collect();
if !errors.is_empty() {
return WipeOutcome::Failed {
message: errors.join("; "),
};
}
match remove_if_exists(&marker(dirs)) {
Ok(()) => WipeOutcome::Wiped,
Err(message) => WipeOutcome::Failed { message },
}
}
/// Phase one of a reset: stage the wipe. The app must be restarted to carry it out.
#[tauri::command]
pub fn reset_all_data(state: State<AppState>) -> Result<(), String> {
stage_wipe_impl(&DataDirs::new(&state.data_dir, &state.local_data_dir))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn populate(dirs: &DataDirs) {
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
fs::write(dirs.data.join(name), b"x").unwrap();
}
fs::create_dir_all(dirs.backups().join("auto")).unwrap();
fs::write(
dirs.backups().join("auto").join("voiced-auto-20260101.zip"),
b"x",
)
.unwrap();
for root in ROOTS {
fs::create_dir_all(dirs.root_dir(root)).unwrap();
fs::write(dirs.root_dir(root).join("f.bin"), b"x").unwrap();
}
for staging in [dirs.data.join(STAGING_NAME), dirs.local.join(STAGING_NAME)] {
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("manifest.json"), b"x").unwrap();
}
fs::write(dirs.data.join("unrelated.txt"), b"keep").unwrap();
fs::create_dir_all(dirs.data.join("other")).unwrap();
fs::write(dirs.data.join("other").join("keep.txt"), b"keep").unwrap();
}
fn assert_wiped(dirs: &DataDirs) {
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
assert!(!dirs.data.join(name).exists(), "{name}");
}
assert!(!dirs.backups().exists());
for root in ROOTS {
assert!(!dirs.root_dir(root).exists(), "{root}");
}
assert!(!dirs.data.join(STAGING_NAME).exists());
assert!(!dirs.local.join(STAGING_NAME).exists());
assert!(!marker(dirs).exists());
}
fn assert_unrelated_kept(dirs: &DataDirs) {
assert_eq!(fs::read(dirs.data.join("unrelated.txt")).unwrap(), b"keep");
assert!(dirs.data.join("other").join("keep.txt").exists());
}
#[test]
fn nothing_staged_means_nothing_is_deleted() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
assert!(dirs.db().exists());
assert!(dirs.backups().exists());
}
#[test]
fn staging_alone_does_not_touch_live_data() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert!(marker(&dirs).exists());
assert!(dirs.db().exists());
assert!(dirs.root_dir("archive").join("f.bin").exists());
}
#[test]
fn wipe_removes_owned_data_and_keeps_unrelated_files() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
assert_unrelated_kept(&dirs);
assert!(dirs.data.exists());
}
#[test]
fn wipe_handles_separate_data_and_local_roots() {
let tmp = tempdir().unwrap();
let data = tmp.path().join("roaming");
let local = tmp.path().join("local");
fs::create_dir_all(&data).unwrap();
fs::create_dir_all(&local).unwrap();
let dirs = DataDirs::new(&data, &local);
populate(&dirs);
fs::write(local.join("unrelated-local.txt"), b"keep").unwrap();
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
assert_unrelated_kept(&dirs);
assert!(local.join("unrelated-local.txt").exists());
}
#[test]
fn wipe_is_idempotent_when_things_are_missing() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
}
#[cfg(unix)]
#[test]
fn a_symlinked_root_is_unlinked_not_followed() {
let tmp = tempdir().unwrap();
let outside = tempdir().unwrap();
fs::write(outside.path().join("precious.txt"), b"keep").unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
std::os::unix::fs::symlink(outside.path(), dirs.root_dir("archive")).unwrap();
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert!(!dirs.root_dir("archive").exists());
assert!(outside.path().join("precious.txt").exists());
}
}
+20 -4
View File
@@ -154,7 +154,11 @@ mod tests {
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))
.query_row(
"SELECT COUNT(*) FROM invoice_series WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(active, 1);
}
@@ -163,12 +167,20 @@ mod tests {
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))
.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))
.query_row(
"SELECT id FROM invoice_series WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(before, after);
}
@@ -184,7 +196,11 @@ mod tests {
.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))
.query_row(
"SELECT COUNT(*) FROM invoice_series WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(active, 1);
}
+40 -12
View File
@@ -142,8 +142,14 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3,
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
params![
bank.label, bank.bank_name, bank.account_name, bank.account_no,
bank.branch, bank.ifsc, is_default, id
bank.label,
bank.bank_name,
bank.account_name,
bank.account_no,
bank.branch,
bank.ifsc,
is_default,
id
],
)
.map_err(|e| e.to_string())?;
@@ -155,8 +161,13 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
params![
bank.label, bank.bank_name, bank.account_name, bank.account_no,
bank.branch, bank.ifsc, is_default
bank.label,
bank.bank_name,
bank.account_name,
bank.account_no,
bank.branch,
bank.ifsc,
is_default
],
)
.map_err(|e| e.to_string())?;
@@ -208,11 +219,17 @@ pub fn delete_bank_impl(conn: &mut Connection, id: i64) -> Result<(), 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])
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())
}
@@ -238,12 +255,20 @@ mod tests {
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))
.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();
let flagged = stmt
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
(setting, flagged)
}
@@ -259,8 +284,11 @@ mod tests {
.unwrap()
};
assert_eq!(read(&conn).render_prefs, "");
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"])
.unwrap();
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();
+317 -86
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`.
pub(crate) const LATEST_VERSION: i64 = 11;
pub(crate) const LATEST_VERSION: i64 = 12;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
@@ -345,6 +345,27 @@ DROP TABLE erpnext_sync;
ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync;
"#;
/// Version 12: `item_presets.unit` no longer lists five units. Units of measure now cover time, distance, area, weight and
/// volume, and a preset may carry a custom unit, so the allow-list becomes a length check and src/units.rs does the rest.
/// SQLite cannot alter a CHECK constraint, so the table is rebuilt; every M7/M10 row is copied over with its id.
const M12: &str = r#"
CREATE TABLE item_presets_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT NOT NULL,
hsn_sac TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')),
unit TEXT NOT NULL DEFAULT 'unit' CHECK (length(trim(unit)) BETWEEN 1 AND 12),
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
erpnext_item_code TEXT
);
INSERT INTO item_presets_new (id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets;
DROP TABLE item_presets;
ALTER TABLE item_presets_new RENAME TO item_presets;
"#;
fn migrations() -> Migrations<'static> {
Migrations::new(vec![
M::up(SCHEMA),
@@ -358,6 +379,7 @@ fn migrations() -> Migrations<'static> {
M::up(M9),
M::up(M10),
M::up(M11),
M::up(M12),
])
}
@@ -436,8 +458,8 @@ fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> {
}
/// Insert the first-run defaults the very first time the app is opened.
/// 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).
/// The defaults are neutral: no vendor, bank or tax identity is pre-filled, so the setup wizard
/// starts blank. The vendor is an unregistered supplier (no tax) until the wizard says otherwise.
fn seed(conn: &Connection) -> rusqlite::Result<()> {
let settings_count: i64 =
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
@@ -447,38 +469,8 @@ 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, 'none', 30, 'INR', 0, 'g10')"#,
params![
"Test Vendor",
"1 Test Road, Bangalore - 560001",
"vendor@example.com",
"9000000000",
"ABCDE1234F",
"",
],
)?;
}
let bank_count: i64 =
conn.query_row("SELECT COUNT(*) FROM bank_accounts", [], |r| r.get(0))?;
if bank_count == 0 {
conn.execute(
r#"INSERT INTO bank_accounts
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)"#,
params![
"Primary",
"State Bank of India",
"Test Vendor",
"000000000000",
"000001",
"TEST0000001",
],
)?;
let bank_id = conn.last_insert_rowid();
conn.execute(
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
params![bank_id],
VALUES (1, '', '', '', '', '', '', '', 18, 'none', 30, 'INR', 0, 'g10')"#,
[],
)?;
}
@@ -497,6 +489,28 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
Ok(())
}
/// Sample vendor and bank for command-logic unit tests (production seeds none). Fake values only.
#[cfg(test)]
pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
r#"UPDATE app_settings SET vendor_name = 'Test Vendor', vendor_address = '1 Test Road, Bangalore - 560001',
vendor_email = 'vendor@example.com', vendor_phone = '9000000000', vendor_pan = 'ABCDE1234F',
vendor_state_code = '29' WHERE id = 1"#,
[],
)?;
conn.execute(
r#"INSERT INTO bank_accounts (label, bank_name, account_name, account_no, branch, ifsc, is_default)
VALUES ('Primary', 'Test Bank', 'Test Vendor', '000000000000', '000001', 'TEST0000001', 1)"#,
[],
)?;
let bank_id = conn.last_insert_rowid();
conn.execute(
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
params![bank_id],
)?;
Ok(())
}
/// A migrated, seeded in-memory database for unit tests of the command logic.
#[cfg(test)]
pub fn open_in_memory() -> Result<Connection, DbError> {
@@ -504,6 +518,7 @@ pub fn open_in_memory() -> Result<Connection, DbError> {
conn.pragma_update(None, "foreign_keys", "ON")?;
migrations().to_latest(&mut conn)?;
seed(&conn)?;
seed_test_fixture(&conn)?;
Ok(conn)
}
@@ -552,8 +567,9 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
}
let snapshots: Vec<(i64, String)> = {
let mut stmt =
tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?;
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<_>>()?
};
@@ -584,7 +600,12 @@ pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::R
/// 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)
format!(
"{}{:0width$}",
prefix,
number,
width = padding.max(1) as usize
)
}
#[cfg(test)]
@@ -692,7 +713,8 @@ 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()
conn.pragma_query_value(None, "user_version", |r| r.get(0))
.unwrap()
}
fn backups_in(dir: &Path) -> Vec<PathBuf> {
@@ -726,7 +748,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.unwrap();
assert_eq!(tax_type, "none");
assert_eq!(gst_reg, "unregistered");
assert_eq!(name, "Test Vendor");
assert_eq!(name, "");
assert!(backups_in(&backups).is_empty());
}
@@ -734,7 +756,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
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))
.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).
@@ -755,14 +781,26 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
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))
.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();
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))
.query_row(
"SELECT render_prefs FROM app_settings WHERE id = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(stored, json);
}
@@ -789,15 +827,21 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.to_latest(&mut old)
.unwrap();
seed(&old).unwrap();
old.execute("UPDATE app_settings SET logo_path = 'assets/logo-old.png'", []).unwrap();
old.execute(
"UPDATE app_settings SET logo_path = 'assets/logo-old.png'",
[],
)
.unwrap();
assert_eq!(user_version(&old), 4);
}
let conn = open(&path, &dir.path().join("backups")).unwrap();
assert_eq!(user_version(&conn), LATEST_VERSION);
let (logo, meta): (String, String) = conn
.query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.query_row(
"SELECT logo_path, logo_meta FROM app_settings WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", ""));
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
@@ -818,7 +862,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
)
.unwrap();
let (cat, terms): (String, Option<i64>) = conn
.query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?)))
.query_row(
"SELECT gst_category, payment_terms_days FROM clients",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!((cat.as_str(), terms), ("unregistered", None));
// The category CHECK rejects unknown values.
@@ -830,7 +878,14 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
#[test]
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
let mut conn = Connection::open_in_memory().unwrap();
let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)];
let all = vec![
M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
];
Migrations::new(all).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 6);
conn.execute(
@@ -856,7 +911,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(rows[0].1, "Line one\nLine two");
assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1"));
assert_eq!(
(rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()),
("29ABCDE1234F1Z5", "29", "PO-1")
);
assert_eq!(rows[0].5, "registered_regular");
assert_eq!(rows[0].6, "");
assert_eq!(rows[1].5, "unregistered");
@@ -874,25 +932,43 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
)
.unwrap();
assert_eq!((naming.as_str(), submit, attach, auto_push, create_customers), ("mirror", 0, 1, 0, 1));
assert_eq!(
(naming.as_str(), submit, attach, auto_push, create_customers),
("mirror", 0, 1, 0, 1)
);
assert_eq!(secret, "");
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
let rows: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0)).unwrap();
let rows: i64 = conn
.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0))
.unwrap();
assert_eq!(rows, 1);
// Single row only.
assert!(conn.execute("INSERT INTO erpnext_config (id) VALUES (2)", []).is_err());
assert!(conn
.execute("INSERT INTO erpnext_config (id) VALUES (2)", [])
.is_err());
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
.unwrap();
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients").unwrap();
conn.prepare("SELECT erpnext_payment_entry FROM payments").unwrap();
conn.prepare("SELECT erpnext_item_code FROM item_presets").unwrap();
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients")
.unwrap();
conn.prepare("SELECT erpnext_payment_entry FROM payments")
.unwrap();
conn.prepare("SELECT erpnext_item_code FROM item_presets")
.unwrap();
}
#[test]
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
let mut conn = Connection::open_in_memory().unwrap();
let v9 = vec![
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9),
M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
M::up(M7),
M::up(M8),
M::up(M9),
];
Migrations::new(v9).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 9);
@@ -912,37 +988,70 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), LATEST_VERSION);
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) = conn
.query_row("SELECT name, gstin, erpnext_customer, erpnext_address FROM clients", [], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) =
conn.query_row(
"SELECT name, gstin, erpnext_customer, erpnext_address FROM clients",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!((name.as_str(), gstin.as_str(), customer, address), ("Acme", "29ABCDE1234F1Z5", None, None));
assert_eq!(
(name.as_str(), gstin.as_str(), customer, address),
("Acme", "29ABCDE1234F1Z5", None, None)
);
let (desc, rate, code): (String, i64, Option<String>) = conn
.query_row("SELECT description, rate_paise, erpnext_item_code FROM item_presets", [], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.query_row(
"SELECT description, rate_paise, erpnext_item_code FROM item_presets",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
let (paid, entry): (i64, Option<String>) = conn
.query_row("SELECT amount_paise, erpnext_payment_entry FROM payments", [], |r| Ok((r.get(0)?, r.get(1)?)))
.query_row(
"SELECT amount_paise, erpnext_payment_entry FROM payments",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!((paid, entry), (12345, None));
let number: String = conn.query_row("SELECT number FROM invoices", [], |r| r.get(0)).unwrap();
let number: String = conn
.query_row("SELECT number FROM invoices", [], |r| r.get(0))
.unwrap();
assert_eq!(number, "INV/2026-001");
// The config row exists with defaults, and a sync row can reference the invoice.
let enabled: i64 = conn.query_row("SELECT COUNT(*) FROM erpnext_config WHERE id = 1", [], |r| r.get(0)).unwrap();
let enabled: i64 = conn
.query_row(
"SELECT COUNT(*) FROM erpnext_config WHERE id = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(enabled, 1);
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')", []).is_err());
assert!(conn
.execute(
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')",
[]
)
.is_err());
}
#[test]
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
let mut conn = Connection::open_in_memory().unwrap();
let v10 = vec![
M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), M::up(M10),
M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
M::up(M7),
M::up(M8),
M::up(M9),
M::up(M10),
];
Migrations::new(v10).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 10);
@@ -957,10 +1066,12 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
[],
)
.unwrap();
assert!(conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).is_err());
assert!(conn
.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
.is_err());
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 11);
assert_eq!(user_version(&conn), LATEST_VERSION);
let row: (String, i64, String, String, String, Option<String>) = conn
.query_row(
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
@@ -970,12 +1081,107 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.unwrap();
assert_eq!(
row,
("INV/2026-001".into(), 1, "synced".into(), "abc".into(), String::new(), Some("2026-04-02T00:00:00Z".into()))
(
"INV/2026-001".into(),
1,
"synced".into(),
"abc".into(),
String::new(),
Some("2026-04-02T00:00:00Z".into())
)
);
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", []).unwrap();
assert!(conn.execute("UPDATE erpnext_sync SET status = 'bogus'", []).is_err());
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
.unwrap();
assert!(conn
.execute("UPDATE erpnext_sync SET status = 'bogus'", [])
.is_err());
// The unique key still guards one row per invoice.
assert!(conn.execute("INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')", []).is_err());
assert!(conn
.execute(
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')",
[]
)
.is_err());
}
#[test]
fn m12_rebuilds_item_presets_keeping_rows_and_allowing_new_units() {
let mut conn = Connection::open_in_memory().unwrap();
let v11 = vec![
M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
M::up(M7),
M::up(M8),
M::up(M9),
M::up(M10),
M::up(M11),
];
Migrations::new(v11).to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 11);
conn.execute(
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
VALUES ('Studio', '998397', 'rate', 'hour', 150050, 3, 'then', 'ITEM-1'), ('Fee', '', 'fixed', 'unit', 500000, 0, 'then', NULL)",
[],
)
.unwrap();
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'km', 'now')", []).is_err());
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 12);
let rows: Vec<(i64, String, String, String, String, i64, i64, String, Option<String>)> = conn
.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets ORDER BY id")
.unwrap()
.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(
rows,
vec![
(
1,
"Studio".into(),
"998397".into(),
"rate".into(),
"hour".into(),
150050,
3,
"then".into(),
Some("ITEM-1".into())
),
(
2,
"Fee".into(),
"".into(),
"fixed".into(),
"unit".into(),
500000,
0,
"then".into(),
None
),
]
);
// New units and custom text are allowed; an empty or over-long unit is not; ids keep counting up.
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Travel', 'km', 'now')", []).unwrap();
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Parcel', 'bundle', 'now')", []).unwrap();
assert!(conn
.execute(
"INSERT INTO item_presets (description, unit, created_at) VALUES ('X', '', 'now')",
[]
)
.is_err());
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'thirteen char', 'now')", []).is_err());
let last: i64 = conn
.query_row("SELECT MAX(id) FROM item_presets", [], |r| r.get(0))
.unwrap();
assert_eq!(last, 4);
}
fn build_v1_database(path: &Path) {
@@ -1028,7 +1234,10 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
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}");
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
@@ -1049,7 +1258,13 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert_eq!(vendor, "Acme Studio");
// Invoice backfill.
let (origin, snapshot, doc_type, number, total): (String, Option<String>, String, String, f64) = conn
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",
[],
@@ -1117,7 +1332,9 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.collect();
names.sort();
assert_eq!(names.len(), MAX_BACKUPS);
assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104")));
assert!(names
.iter()
.all(|n| !n.contains("20260101") && !n.contains("20260104")));
assert!(dir.path().join("unrelated.db").exists());
}
@@ -1126,8 +1343,16 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
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 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",
@@ -1156,9 +1381,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
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)?))
})
.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);
@@ -1175,7 +1402,11 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
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))
.query_row(
"SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(bad, "not json");
}
+56 -13
View File
@@ -18,7 +18,7 @@ pub const STATES: &[(&str, &str)] = &[
("09", "Uttar Pradesh"),
("10", "Bihar"),
("11", "Sikkim"),
("12", "Sampleachal Pradesh"),
("12", "Arunachal Pradesh"),
("13", "Nagaland"),
("14", "Manipur"),
("15", "Mizoram"),
@@ -75,7 +75,11 @@ pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str
if registration == "unregistered" {
return TaxType::None;
}
let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state };
let pos = if pos_state.trim().is_empty() {
supplier_state
} else {
pos_state
};
if pos == supplier_state {
TaxType::CgstSgst
} else {
@@ -225,8 +229,26 @@ pub fn compute_totals(
}
const ONES: [&str; 20] = [
"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven",
"Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen",
"",
"One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
"Ten",
"Eleven",
"Twelve",
"Thirteen",
"Fourteen",
"Fifteen",
"Sixteen",
"Seventeen",
"Eighteen",
"Nineteen",
];
const TENS: [&str; 10] = [
@@ -353,9 +375,18 @@ mod tests {
#[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);
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]
@@ -384,17 +415,29 @@ mod tests {
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"));
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"));
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();
+224 -52
View File
@@ -49,7 +49,10 @@ impl Default for ClientOptions {
/// Plain http is only for a local development site; anything else must use https.
pub fn is_local_dev_host(host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
host == "localhost" || host == "127.0.0.1" || host.ends_with(".localhost") || host.ends_with(".test")
host == "localhost"
|| host == "127.0.0.1"
|| host.ends_with(".localhost")
|| host.ends_with(".test")
}
/// Normalises what the user typed into `scheme://host[:port][/prefix]` without a trailing slash.
@@ -63,10 +66,15 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
raw.to_string()
} else {
let host = raw.split(['/', ':']).next().unwrap_or("");
let scheme = if is_local_dev_host(host) { "http" } else { "https" };
let scheme = if is_local_dev_host(host) {
"http"
} else {
"https"
};
format!("{scheme}://{raw}")
};
let mut url = Url::parse(&with_scheme).map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
let mut url = Url::parse(&with_scheme)
.map_err(|e| ErpError::config(format!("The ERPNext address is not valid: {e}")))?;
let host = url
.host_str()
.ok_or_else(|| ErpError::config("The ERPNext address has no host name."))?
@@ -80,10 +88,16 @@ pub fn normalize_base_url(raw: &str) -> Result<String, ErpError> {
));
}
}
other => return Err(ErpError::config(format!("Unsupported address scheme \"{other}\"; use https://."))),
other => {
return Err(ErpError::config(format!(
"Unsupported address scheme \"{other}\"; use https://."
)))
}
}
if !url.username().is_empty() || url.password().is_some() {
return Err(ErpError::config("Do not put credentials in the address; use the API key and secret fields."));
return Err(ErpError::config(
"Do not put credentials in the address; use the API key and secret fields.",
));
}
url.set_query(None);
url.set_fragment(None);
@@ -114,7 +128,9 @@ pub struct ErpClient {
impl std::fmt::Debug for ErpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ErpClient").field("base", &self.base.as_str()).finish_non_exhaustive()
f.debug_struct("ErpClient")
.field("base", &self.base.as_str())
.finish_non_exhaustive()
}
}
@@ -127,12 +143,18 @@ impl ErpClient {
opts: ClientOptions,
) -> Result<Self, ErpError> {
ensure_crypto_provider();
let base = Url::parse(&normalize_base_url(base_url)?).map_err(|e| ErpError::config(e.to_string()))?;
let base = Url::parse(&normalize_base_url(base_url)?)
.map_err(|e| ErpError::config(e.to_string()))?;
if api_key.trim().is_empty() || !api_secret.is_set() {
return Err(ErpError::config("Enter the API key and API secret."));
}
let mut auth = HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
.map_err(|_| ErpError::config("The API key or secret contains characters that cannot be sent."))?;
let mut auth =
HeaderValue::from_str(&format!("token {}:{}", api_key.trim(), api_secret.expose()))
.map_err(|_| {
ErpError::config(
"The API key or secret contains characters that cannot be sent.",
)
})?;
auth.set_sensitive(true);
let mut headers = HeaderMap::new();
@@ -152,13 +174,18 @@ impl ErpClient {
let certs = reqwest::Certificate::from_pem_bundle(extra_ca_pem.trim().as_bytes())
.map_err(|_| ErpError::config("The extra CA certificate is not valid PEM."))?;
if certs.is_empty() {
return Err(ErpError::config("The extra CA certificate field holds no certificate."));
return Err(ErpError::config(
"The extra CA certificate field holds no certificate.",
));
}
builder = builder.tls_certs_merge(certs);
}
let http = builder
.build()
.map_err(|e| ErpError::config(format!("Could not set up the HTTP client: {}", error_chain(&e))))?;
let http = builder.build().map_err(|e| {
ErpError::config(format!(
"Could not set up the HTTP client: {}",
error_chain(&e)
))
})?;
Ok(ErpClient {
http,
base,
@@ -168,7 +195,13 @@ impl ErpClient {
}
pub fn from_config(cfg: &ErpnextConfig) -> Result<Self, ErpError> {
Self::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, &cfg.extra_ca_pem, ClientOptions::default())
Self::new(
&cfg.base_url,
&cfg.api_key,
&cfg.api_secret,
&cfg.extra_ca_pem,
ClientOptions::default(),
)
}
pub fn base_url(&self) -> &str {
@@ -184,22 +217,38 @@ impl ErpClient {
}
/// GET, retried on 429, 5xx and timeouts.
pub async fn get(&self, segments: &[&str], query: &[(&str, String)]) -> Result<Value, ErpError> {
self.send(Method::GET, segments, query, Payload::None, true).await
pub async fn get(
&self,
segments: &[&str],
query: &[(&str, String)],
) -> Result<Value, ErpError> {
self.send(Method::GET, segments, query, Payload::None, true)
.await
}
/// POST with a JSON body. 429 is always retried (the server refused before doing anything). 5xx and
/// timeouts are retried only when `idempotent` is true, because the server may have processed the
/// request: a mirrored-name Sales Invoice is idempotent (a repeat is a 409), a series-named one is not.
pub async fn post(&self, segments: &[&str], body: &Value, idempotent: bool) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent).await
pub async fn post(
&self,
segments: &[&str],
body: &Value,
idempotent: bool,
) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Json(body), idempotent)
.await
}
/// Multipart POST carrying one file part named `file` (the name Frappe's `upload_file` reads; the file
/// name travels in the part's Content-Disposition) plus plain text fields. Never retried after a 5xx or
/// timeout, because an upload that may have landed would attach the file twice.
pub async fn post_file(&self, segments: &[&str], upload: &Upload<'_>) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Upload(upload), false).await
pub async fn post_file(
&self,
segments: &[&str],
upload: &Upload<'_>,
) -> Result<Value, ErpError> {
self.send(Method::POST, segments, &[], Payload::Upload(upload), false)
.await
}
async fn send(
@@ -260,7 +309,8 @@ impl ErpClient {
if status.is_success() {
return self.parse_success(&text);
}
let retryable = status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
let retryable =
status.as_u16() == 429 || (status.is_server_error() && retry_ambiguous);
if retryable && can_retry {
self.backoff(attempt, retry_after).await;
attempt += 1;
@@ -287,7 +337,10 @@ impl ErpClient {
}
async fn backoff(&self, attempt: u32, retry_after: Option<Duration>) {
let exp = self.opts.backoff_base.saturating_mul(1u32 << attempt.min(10));
let exp = self
.opts
.backoff_base
.saturating_mul(1u32 << attempt.min(10));
let delay = retry_after.unwrap_or(exp).min(self.opts.max_backoff);
tokio::time::sleep(delay).await;
}
@@ -311,7 +364,10 @@ impl ErpClient {
fn map_transport(&self, e: &reqwest::Error) -> ErpError {
let chain = error_chain(e);
let err = if e.is_timeout() {
ErpError::new(ErrorKind::Timeout, "The ERPNext server did not answer in time.")
ErpError::new(
ErrorKind::Timeout,
"The ERPNext server did not answer in time.",
)
} else if e.is_connect() {
let lower = chain.to_ascii_lowercase();
let hint = if lower.contains("certificate") || lower.contains("unknownissuer") {
@@ -319,9 +375,15 @@ impl ErpClient {
} else {
""
};
ErpError::new(ErrorKind::Network, format!("Could not connect to the ERPNext server ({chain}).{hint}"))
ErpError::new(
ErrorKind::Network,
format!("Could not connect to the ERPNext server ({chain}).{hint}"),
)
} else {
ErpError::new(ErrorKind::Network, format!("The request to ERPNext failed ({chain})."))
ErpError::new(
ErrorKind::Network,
format!("The request to ERPNext failed ({chain})."),
)
};
self.scrub(err)
}
@@ -352,9 +414,18 @@ mod tests {
#[test]
fn https_is_accepted_and_normalised() {
assert_eq!(normalize_base_url(" https://Erp.Example.com/ ").unwrap(), "https://erp.example.com");
assert_eq!(normalize_base_url("erp.example.com").unwrap(), "https://erp.example.com");
assert_eq!(normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(), "https://erp.example.com:8443/sub");
assert_eq!(
normalize_base_url(" https://Erp.Example.com/ ").unwrap(),
"https://erp.example.com"
);
assert_eq!(
normalize_base_url("erp.example.com").unwrap(),
"https://erp.example.com"
);
assert_eq!(
normalize_base_url("https://erp.example.com:8443/sub/?a=1#x").unwrap(),
"https://erp.example.com:8443/sub"
);
}
#[test]
@@ -370,14 +441,26 @@ mod tests {
let url = normalize_base_url(ok).unwrap_or_else(|e| panic!("{ok}: {e}"));
assert!(url.starts_with("http://"), "{ok} -> {url}");
}
for bad in ["http://erp.example.com", "http://192.168.1.10:8000", "http://localhost.evil.com", "http://notlocalhost"] {
for bad in [
"http://erp.example.com",
"http://192.168.1.10:8000",
"http://localhost.evil.com",
"http://notlocalhost",
] {
assert!(normalize_base_url(bad).is_err(), "{bad} must be refused");
}
}
#[test]
fn other_schemes_credentials_and_blanks_are_refused() {
for bad in ["ftp://erp.example.com", "file:///etc/passwd", "https://user:pw@erp.example.com", "", " ", "https://"] {
for bad in [
"ftp://erp.example.com",
"file:///etc/passwd",
"https://user:pw@erp.example.com",
"",
" ",
"https://",
] {
assert!(normalize_base_url(bad).is_err(), "{bad:?} must be refused");
}
}
@@ -391,8 +474,14 @@ mod tests {
.unwrap();
assert_eq!(value["message"], "Sample@example.com");
let raw = mock.requests.lock().unwrap()[0].to_ascii_lowercase();
assert!(raw.starts_with("get /api/method/frappe.auth.get_logged_user "), "{raw}");
assert!(raw.contains("authorization: token thekey:thesecret"), "{raw}");
assert!(
raw.starts_with("get /api/method/frappe.auth.get_logged_user "),
"{raw}"
);
assert!(
raw.contains("authorization: token thekey:thesecret"),
"{raw}"
);
assert!(raw.contains("accept: application/json"), "{raw}");
}
@@ -400,11 +489,19 @@ mod tests {
async fn path_segments_are_percent_encoded_once() {
let mock = serve(vec![reply(200, json!({ "data": [] }))]).await;
client(&mock)
.get(&["api", "resource", "Sales Invoice", "INV/2026-001"], &[("limit_page_length", "0".into())])
.get(
&["api", "resource", "Sales Invoice", "INV/2026-001"],
&[("limit_page_length", "0".into())],
)
.await
.unwrap();
let raw = mock.requests.lock().unwrap()[0].clone();
assert!(raw.starts_with("GET /api/resource/Sales%20Invoice/AP%2F2026-001?limit_page_length=0 "), "{raw}");
assert!(
raw.starts_with(
"GET /api/resource/Sales%20Invoice/INV%2F2026-001?limit_page_length=0 "
),
"{raw}"
);
}
#[tokio::test]
@@ -412,15 +509,25 @@ mod tests {
let mut limited = reply(429, json!({ "message": "slow down" }));
limited.headers.push(("Retry-After", "0".into()));
let mock = serve(vec![limited, reply(200, json!({ "message": "ok" }))]).await;
let value = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap();
let value = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap();
assert_eq!(value["message"], "ok");
assert_eq!(mock.hits.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn persistent_500_exhausts_the_retries() {
let mock = serve(vec![reply(500, json!({ "exception": "frappe.exceptions.ValidationError: boom" }))]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
let mock = serve(vec![reply(
500,
json!({ "exception": "frappe.exceptions.ValidationError: boom" }),
)])
.await;
let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Server);
assert_eq!(err.message, "boom");
assert_eq!(mock.hits.load(Ordering::SeqCst), 4, "1 attempt + 3 retries");
@@ -428,9 +535,17 @@ mod tests {
#[tokio::test]
async fn validation_errors_are_not_retried() {
let mock = serve(vec![reply(417, json!({ "_error_message": "Rate is required" }))]).await;
let mock = serve(vec![reply(
417,
json!({ "_error_message": "Rate is required" }),
)])
.await;
let err = client(&mock)
.post(&["api", "resource", "Sales Invoice"], &json!({ "a": 1 }), true)
.post(
&["api", "resource", "Sales Invoice"],
&json!({ "a": 1 }),
true,
)
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Validation);
@@ -440,25 +555,42 @@ mod tests {
#[tokio::test]
async fn timeouts_are_retried_for_reads_but_not_for_ambiguous_writes() {
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
let slow = Reply {
delay_ms: 2_000,
..reply(200, json!({}))
};
let mock = serve(vec![slow]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Timeout);
assert_eq!(mock.hits.load(Ordering::SeqCst), 4);
let slow = Reply { delay_ms: 2_000, ..reply(200, json!({})) };
let slow = Reply {
delay_ms: 2_000,
..reply(200, json!({}))
};
let mock = serve(vec![slow]).await;
let err = client(&mock)
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Timeout);
assert_eq!(mock.hits.load(Ordering::SeqCst), 1, "a write that may have landed is not repeated");
assert_eq!(
mock.hits.load(Ordering::SeqCst),
1,
"a write that may have landed is not repeated"
);
}
#[tokio::test]
async fn non_idempotent_write_retries_429_but_not_500() {
let mock = serve(vec![reply(429, json!({})), reply(200, json!({ "data": { "name": "X" } }))]).await;
let mock = serve(vec![
reply(429, json!({})),
reply(200, json!({ "data": { "name": "X" } })),
])
.await;
let value = client(&mock)
.post(&["api", "resource", "Sales Invoice"], &json!({}), false)
.await
@@ -478,11 +610,20 @@ mod tests {
#[tokio::test]
async fn redirects_are_reported_not_followed() {
let mut moved = reply(301, json!({}));
moved.headers.push(("Location", "https://erp.example.com/".into()));
moved
.headers
.push(("Location", "https://erp.example.com/".into()));
let mock = serve(vec![moved]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Protocol);
assert!(err.message.contains("https://erp.example.com/"), "{}", err.message);
assert!(
err.message.contains("https://erp.example.com/"),
"{}",
err.message
);
assert_eq!(mock.hits.load(Ordering::SeqCst), 1);
}
@@ -491,7 +632,10 @@ mod tests {
let mut page = reply(200, json!({}));
page.body = "<html>login</html>".into();
let mock = serve(vec![page]).await;
let err = client(&mock).get(&["api", "method", "ping"], &[]).await.unwrap_err();
let err = client(&mock)
.get(&["api", "method", "ping"], &[])
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Protocol);
}
@@ -500,7 +644,14 @@ mod tests {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let c = ErpClient::new(&format!("http://127.0.0.1:{port}"), "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap();
let c = ErpClient::new(
&format!("http://127.0.0.1:{port}"),
"thekey",
&Secret::new("thesecret"),
"",
fast_opts(),
)
.unwrap();
let err = c.get(&["api", "method", "ping"], &[]).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::Network);
assert!(!err.to_string().contains("thesecret"));
@@ -509,9 +660,30 @@ mod tests {
#[test]
fn credentials_are_required_and_a_bad_ca_is_refused() {
assert!(ErpClient::new("https://erp.example.com", "", &Secret::new("s"), "", fast_opts()).is_err());
assert!(ErpClient::new("https://erp.example.com", "k", &Secret::default(), "", fast_opts()).is_err());
let err = ErpClient::new("https://erp.example.com", "k", &Secret::new("s"), "not a certificate", fast_opts()).unwrap_err();
assert!(ErpClient::new(
"https://erp.example.com",
"",
&Secret::new("s"),
"",
fast_opts()
)
.is_err());
assert!(ErpClient::new(
"https://erp.example.com",
"k",
&Secret::default(),
"",
fast_opts()
)
.is_err());
let err = ErpClient::new(
"https://erp.example.com",
"k",
&Secret::new("s"),
"not a certificate",
fast_opts(),
)
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Config);
}
}
+62 -28
View File
@@ -29,7 +29,11 @@ impl Secret {
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(if self.is_set() { "Secret([redacted])" } else { "Secret(unset)" })
f.write_str(if self.is_set() {
"Secret([redacted])"
} else {
"Secret(unset)"
})
}
}
@@ -61,18 +65,12 @@ impl NamingMode {
}
/// Voiced line unit to ERPNext UOM. Whole-number UOMs such as "Nos" reject fractional quantities,
/// which is why hours and minutes map to their own UOMs.
/// which is why hours, minutes, kilometres and the like map to their own UOMs (see `units::KNOWN`).
pub fn default_uom_map() -> BTreeMap<String, String> {
[
("second", "Second"),
("minute", "Minute"),
("hour", "Hour"),
("session", "Nos"),
("unit", "Nos"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
crate::units::KNOWN
.iter()
.map(|(id, uom)| (id.to_string(), uom.to_string()))
.collect()
}
/// Everything stored for the integration, secret included. Backend-only.
@@ -150,7 +148,9 @@ impl ErpnextConfig {
if let Some(u) = self.uom_map.get(unit).filter(|u| !u.trim().is_empty()) {
return u.trim().to_string();
}
default_uom_map().remove(unit).unwrap_or_else(|| "Nos".to_string())
default_uom_map()
.remove(unit)
.unwrap_or_else(|| "Nos".to_string())
}
pub fn view(&self) -> ErpnextConfigView {
@@ -182,7 +182,8 @@ impl ErpnextConfig {
attach_pdf: self.attach_pdf,
auto_push_on_issue: self.auto_push_on_issue,
create_missing_customers: self.create_missing_customers,
last_detect_result: serde_json::from_str(&self.last_detect_result).unwrap_or(Value::Null),
last_detect_result: serde_json::from_str(&self.last_detect_result)
.unwrap_or(Value::Null),
}
}
}
@@ -296,7 +297,11 @@ fn yes() -> bool {
}
fn origin_of(url: &str) -> String {
url.split('/').take(3).collect::<Vec<_>>().join("/").to_ascii_lowercase()
url.split('/')
.take(3)
.collect::<Vec<_>>()
.join("/")
.to_ascii_lowercase()
}
impl ErpnextConfigInput {
@@ -469,8 +474,11 @@ mod tests {
use super::*;
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
let mut base = serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" });
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let mut base =
serde_json::json!({ "baseUrl": "https://erp.example.com/", "apiKey": "key1" });
base.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
serde_json::from_value(base).unwrap()
}
@@ -480,7 +488,10 @@ mod tests {
#[test]
fn debug_never_prints_the_secret() {
let cfg = ErpnextConfig { api_secret: Secret::new("s3cr3t-value"), ..Default::default() };
let cfg = ErpnextConfig {
api_secret: Secret::new("s3cr3t-value"),
..Default::default()
};
assert!(!format!("{cfg:?}").contains("s3cr3t-value"));
assert!(!format!("{:?}", Secret::new("s3cr3t-value")).contains("s3cr3t-value"));
}
@@ -489,7 +500,12 @@ mod tests {
fn defaults_load_from_the_migration_row() {
let cfg = load(&db()).unwrap();
assert_eq!(cfg.naming_mode, NamingMode::Mirror);
assert!(!cfg.submit_on_push && cfg.attach_pdf && !cfg.auto_push_on_issue && cfg.create_missing_customers);
assert!(
!cfg.submit_on_push
&& cfg.attach_pdf
&& !cfg.auto_push_on_issue
&& cfg.create_missing_customers
);
assert_eq!(cfg.uom_for("hour"), "Hour");
assert_eq!(cfg.uom_for("session"), "Nos");
assert!(!cfg.api_secret.is_set());
@@ -519,34 +535,52 @@ mod tests {
#[test]
fn blank_secret_keeps_and_explicit_values_replace_or_clear() {
let conn = db();
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap();
let first = input(serde_json::json!({ "apiSecret": "one" }))
.resolve(&load(&conn).unwrap())
.unwrap();
save(&conn, &first).unwrap();
let keep = input(serde_json::json!({ "apiSecret": " " })).resolve(&load(&conn).unwrap()).unwrap();
let keep = input(serde_json::json!({ "apiSecret": " " }))
.resolve(&load(&conn).unwrap())
.unwrap();
assert_eq!(keep.api_secret.expose(), "one");
let replace = input(serde_json::json!({ "apiSecret": "two" })).resolve(&load(&conn).unwrap()).unwrap();
let replace = input(serde_json::json!({ "apiSecret": "two" }))
.resolve(&load(&conn).unwrap())
.unwrap();
assert_eq!(replace.api_secret.expose(), "two");
let clear = input(serde_json::json!({ "clearSecret": true })).resolve(&load(&conn).unwrap()).unwrap();
let clear = input(serde_json::json!({ "clearSecret": true }))
.resolve(&load(&conn).unwrap())
.unwrap();
assert!(!clear.api_secret.is_set());
}
#[test]
fn stored_secret_is_not_reused_for_another_server() {
let conn = db();
let first = input(serde_json::json!({ "apiSecret": "one" })).resolve(&load(&conn).unwrap()).unwrap();
let first = input(serde_json::json!({ "apiSecret": "one" }))
.resolve(&load(&conn).unwrap())
.unwrap();
save(&conn, &first).unwrap();
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" })).resolve(&load(&conn).unwrap());
let moved = input(serde_json::json!({ "baseUrl": "https://other.example.org" }))
.resolve(&load(&conn).unwrap());
assert!(moved.unwrap_err().contains("API secret"));
// The same server with a different path or case is still the same origin.
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" })).resolve(&load(&conn).unwrap());
let same = input(serde_json::json!({ "baseUrl": "HTTPS://ERP.example.com" }))
.resolve(&load(&conn).unwrap());
assert!(same.is_ok());
}
#[test]
fn invalid_address_or_naming_mode_is_rejected() {
let stored = ErpnextConfig::default();
assert!(input(serde_json::json!({ "baseUrl": "http://erp.example.com" })).resolve(&stored).is_err());
assert!(input(serde_json::json!({ "namingMode": "random" })).resolve(&stored).is_err());
assert!(
input(serde_json::json!({ "baseUrl": "http://erp.example.com" }))
.resolve(&stored)
.is_err()
);
assert!(input(serde_json::json!({ "namingMode": "random" }))
.resolve(&stored)
.is_err());
}
#[test]
+466 -103
View File
@@ -20,12 +20,19 @@ impl Version {
/// Parses `15.73.2`, `v16.0.0-dev` or `15.1`; anything after the numeric part is ignored.
pub fn parse(s: &str) -> Option<Version> {
let s = s.trim().trim_start_matches(['v', 'V']);
let numeric: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
let numeric: String = s
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
let mut parts = numeric.split('.').filter(|p| !p.is_empty());
let major = parts.next()?.parse().ok()?;
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
Some(Version { major, minor, patch })
Some(Version {
major,
minor,
patch,
})
}
}
@@ -59,7 +66,10 @@ pub fn parse_versions(value: &Value) -> ServerVersions {
let apps = value.get("message").unwrap_or(value);
let version_of = |app: &str| -> Option<String> {
let entry = apps.get(app)?;
let v = entry.get("version").and_then(Value::as_str).or_else(|| entry.as_str())?;
let v = entry
.get("version")
.and_then(Value::as_str)
.or_else(|| entry.as_str())?;
let v = v.trim();
(!v.is_empty()).then(|| v.to_string())
};
@@ -77,7 +87,9 @@ pub const IC_MAX_NUMBER_LEN: usize = 16;
/// India Compliance: `^[^\W_][A-Za-z0-9\-\/]{0,15}$`.
pub fn ic_number_ok(number: &str) -> bool {
let mut chars = number.chars();
let Some(first) = chars.next() else { return false };
let Some(first) = chars.next() else {
return false;
};
first.is_alphanumeric()
&& number.chars().count() <= IC_MAX_NUMBER_LEN
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/')
@@ -158,13 +170,22 @@ fn item(row: &Value, label_key: &str, detail: String) -> Option<OptionItem> {
let label = text(row, label_key);
Some(OptionItem {
name: name.to_string(),
label: if label.is_empty() { name.to_string() } else { label.to_string() },
label: if label.is_empty() {
name.to_string()
} else {
label.to_string()
},
detail,
})
}
fn join(parts: &[&str]) -> String {
parts.iter().filter(|p| !p.is_empty()).cloned().collect::<Vec<_>>().join(" · ")
parts
.iter()
.filter(|p| !p.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" · ")
}
/// Parses the `naming_series` options out of a `getdoctype` response: newline-separated, first line blank.
@@ -173,23 +194,41 @@ pub fn parse_naming_series(doctype_response: &Value) -> Vec<OptionItem> {
.get("docs")
.or_else(|| doctype_response.get("message").and_then(|m| m.get("docs")))
.and_then(Value::as_array);
let Some(doc) = docs.and_then(|d| d.first()) else { return Vec::new() };
let Some(fields) = doc.get("fields").and_then(Value::as_array) else { return Vec::new() };
let Some(field) = fields.iter().find(|f| text(f, "fieldname") == "naming_series") else {
let Some(doc) = docs.and_then(|d| d.first()) else {
return Vec::new();
};
let Some(fields) = doc.get("fields").and_then(Value::as_array) else {
return Vec::new();
};
let Some(field) = fields
.iter()
.find(|f| text(f, "fieldname") == "naming_series")
else {
return Vec::new();
};
text(field, "options")
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| OptionItem { name: l.to_string(), label: l.to_string(), detail: format!("e.g. {} digits", naming_series_expanded_len(l)) })
.map(|l| OptionItem {
name: l.to_string(),
label: l.to_string(),
detail: format!("e.g. {} digits", naming_series_expanded_len(l)),
})
.collect()
}
impl ErpClient {
pub async fn get_logged_user(&self) -> Result<String, ErpError> {
let v = self.get(&["api", "method", "frappe.auth.get_logged_user"], &[]).await?;
let user = v.get("message").and_then(Value::as_str).unwrap_or("").trim().to_string();
let v = self
.get(&["api", "method", "frappe.auth.get_logged_user"], &[])
.await?;
let user = v
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
if user.is_empty() || user.eq_ignore_ascii_case("guest") {
return Err(ErpError {
kind: ErrorKind::Auth,
@@ -202,7 +241,12 @@ impl ErpClient {
}
pub async fn get_versions(&self) -> Result<ServerVersions, ErpError> {
let v = self.get(&["api", "method", "frappe.utils.change_log.get_versions"], &[]).await?;
let v = self
.get(
&["api", "method", "frappe.utils.change_log.get_versions"],
&[],
)
.await?;
Ok(parse_versions(&v))
}
@@ -223,7 +267,10 @@ impl ErpClient {
query.push(("filters", filters.to_string()));
}
let v = self.get(&["api", "resource", doctype], &query).await?;
Ok(v.get("data").and_then(Value::as_array).cloned().unwrap_or_default())
Ok(v.get("data")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default())
}
pub async fn load_options(&self, company: &str) -> Result<ErpnextOptions, ErpError> {
@@ -234,52 +281,119 @@ impl ErpClient {
($list:literal, $target:expr, $call:expr) => {
match $call.await {
Ok(rows) => $target = rows,
Err(e) if matches!(e.kind, ErrorKind::Auth | ErrorKind::Network | ErrorKind::Timeout | ErrorKind::Config | ErrorKind::Protocol) => {
Err(e)
if matches!(
e.kind,
ErrorKind::Auth
| ErrorKind::Network
| ErrorKind::Timeout
| ErrorKind::Config
| ErrorKind::Protocol
) =>
{
return Err(e)
}
Err(e) => out.errors.push(OptionError { list: $list.to_string(), message: e.to_string() }),
Err(e) => out.errors.push(OptionError {
list: $list.to_string(),
message: e.to_string(),
}),
}
};
}
load!("companies", out.companies, async {
self.list_resource("Company", &["name", "company_name", "abbr", "country", "default_currency"], Value::Null, "name asc")
.await
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "company_name", join(&[text(r, "abbr"), text(r, "default_currency")])))
.collect()
})
self.list_resource(
"Company",
&[
"name",
"company_name",
"abbr",
"country",
"default_currency",
],
Value::Null,
"name asc",
)
.await
.map(|rows| {
rows.iter()
.filter_map(|r| {
item(
r,
"company_name",
join(&[text(r, "abbr"), text(r, "default_currency")]),
)
})
.collect()
})
});
load!("itemGroups", out.item_groups, async {
self.list_resource("Item Group", &["name"], Value::Null, "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
});
load!("uoms", out.uoms, async {
self.list_resource("UOM", &["name"], json!([["enabled", "=", 1]]), "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
});
load!("priceLists", out.price_lists, async {
self.list_resource("Price List", &["name"], json!([["enabled", "=", 1], ["selling", "=", 1]]), "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
self.list_resource(
"Price List",
&["name"],
json!([["enabled", "=", 1], ["selling", "=", 1]]),
"name asc",
)
.await
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
});
load!("customerGroups", out.customer_groups, async {
self.list_resource("Customer Group", &["name"], json!([["is_group", "=", 0]]), "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
self.list_resource(
"Customer Group",
&["name"],
json!([["is_group", "=", 0]]),
"name asc",
)
.await
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
});
load!("territories", out.territories, async {
self.list_resource("Territory", &["name"], json!([["is_group", "=", 0]]), "name asc")
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "name", String::new())).collect())
self.list_resource(
"Territory",
&["name"],
json!([["is_group", "=", 0]]),
"name asc",
)
.await
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "name", String::new()))
.collect()
})
});
load!("namingSeries", out.naming_series, async {
self.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Sales Invoice".to_string())])
.await
.map(|v| parse_naming_series(&v))
self.get(
&["api", "method", "frappe.desk.form.load.getdoctype"],
&[("doctype", "Sales Invoice".to_string())],
)
.await
.map(|v| parse_naming_series(&v))
});
if company.is_empty() {
@@ -291,11 +405,32 @@ impl ErpClient {
["Dynamic Link", "link_name", "=", company],
["disabled", "=", 0]
]);
let base = ["name", "address_title", "address_line1", "city", "state", "pincode"];
let with_gstin = ["name", "address_title", "address_line1", "city", "state", "pincode", "gstin"];
let base = [
"name",
"address_title",
"address_line1",
"city",
"state",
"pincode",
];
let with_gstin = [
"name",
"address_title",
"address_line1",
"city",
"state",
"pincode",
"gstin",
];
// `gstin` is a custom field added by India Compliance; a plain site rejects it ("Field not permitted").
let rows = match self.list_resource("Address", &with_gstin, filters.clone(), "name asc").await {
Err(e) if e.kind == ErrorKind::Validation => self.list_resource("Address", &base, filters, "name asc").await?,
let rows = match self
.list_resource("Address", &with_gstin, filters.clone(), "name asc")
.await
{
Err(e) if e.kind == ErrorKind::Validation => {
self.list_resource("Address", &base, filters, "name asc")
.await?
}
other => other?,
};
Ok::<_, ErpError>(
@@ -304,7 +439,13 @@ impl ErpClient {
item(
r,
"address_title",
join(&[text(r, "address_line1"), text(r, "city"), text(r, "state"), text(r, "pincode"), text(r, "gstin")]),
join(&[
text(r, "address_line1"),
text(r, "city"),
text(r, "state"),
text(r, "pincode"),
text(r, "gstin"),
]),
)
})
.collect(),
@@ -314,21 +455,39 @@ impl ErpClient {
self.list_resource(
"Account",
&["name", "account_name", "account_type"],
json!([["company", "=", company], ["root_type", "=", "Income"], ["is_group", "=", 0], ["disabled", "=", 0]]),
json!([
["company", "=", company],
["root_type", "=", "Income"],
["is_group", "=", 0],
["disabled", "=", 0]
]),
"name asc",
)
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "account_name", text(r, "account_type").to_string()))
.collect()
})
});
load!("taxAccounts", out.tax_accounts, async {
self.list_resource(
"Account",
&["name", "account_name", "account_type"],
json!([["company", "=", company], ["account_type", "=", "Tax"], ["is_group", "=", 0], ["disabled", "=", 0]]),
json!([
["company", "=", company],
["account_type", "=", "Tax"],
["is_group", "=", 0],
["disabled", "=", 0]
]),
"name asc",
)
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "account_name", text(r, "account_type").to_string())).collect())
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "account_name", text(r, "account_type").to_string()))
.collect()
})
});
load!("taxTemplates", out.tax_templates, async {
self.list_resource(
@@ -338,17 +497,29 @@ impl ErpClient {
"name asc",
)
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "title", String::new())).collect())
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "title", String::new()))
.collect()
})
});
load!("costCenters", out.cost_centers, async {
self.list_resource(
"Cost Center",
&["name", "cost_center_name"],
json!([["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]),
json!([
["company", "=", company],
["is_group", "=", 0],
["disabled", "=", 0]
]),
"name asc",
)
.await
.map(|rows| rows.iter().filter_map(|r| item(r, "cost_center_name", String::new())).collect())
.map(|rows| {
rows.iter()
.filter_map(|r| item(r, "cost_center_name", String::new()))
.collect()
})
});
Ok(out)
}
@@ -363,7 +534,10 @@ pub struct Warning {
}
fn warn(code: &str, message: impl Into<String>) -> Warning {
Warning { code: code.to_string(), message: message.into() }
Warning {
code: code.to_string(),
message: message.into(),
}
}
/// Facts from the local database that the warnings need.
@@ -390,7 +564,9 @@ pub struct RemoteFacts {
pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
let for_company: Vec<&Value> = rows
.iter()
.filter(|r| text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output"))
.filter(|r| {
text(r, "company") == cfg.company && matches!(text(r, "account_type"), "" | "Output")
})
.collect();
if for_company.is_empty() {
return vec![warn(
@@ -419,7 +595,11 @@ pub fn check_gst_accounts(rows: &[Value], cfg: &ErpnextConfig) -> Vec<Warning> {
}
/// Pure: turns the configuration and the facts gathered into the list shown after a connection test.
pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFacts) -> Vec<Warning> {
pub fn build_warnings(
cfg: &ErpnextConfig,
local: &LocalFacts,
remote: &RemoteFacts,
) -> Vec<Warning> {
let mut w = Vec::new();
let features = remote.versions.as_ref().map(feature_switches);
let ic = features.is_some_and(|f| f.india_compliance);
@@ -449,11 +629,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
}
}
} else if cfg.naming_series.is_empty() {
w.push(warn("naming_series_missing", "ERPNext series naming is selected but no naming series is chosen."));
w.push(warn(
"naming_series_missing",
"ERPNext series naming is selected but no naming series is chosen.",
));
}
if local.vendor_registered {
if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty() {
if cfg.cgst_account.is_empty() || cfg.sgst_account.is_empty() || cfg.igst_account.is_empty()
{
w.push(warn(
"tax_accounts_missing",
"The supplier is GST-registered but the CGST, SGST and IGST accounts are not all set.",
@@ -479,9 +663,15 @@ pub fn build_warnings(cfg: &ErpnextConfig, local: &LocalFacts, remote: &RemoteFa
}
}
if cfg.company_address.is_empty() {
w.push(warn("company_address_missing", "India Compliance needs a company address on every invoice; none is selected."));
w.push(warn(
"company_address_missing",
"India Compliance needs a company address on every invoice; none is selected.",
));
} else if remote.company_address_count == Some(0) {
w.push(warn("company_address_missing", "No address is linked to the company on the site."));
w.push(warn(
"company_address_missing",
"No address is linked to the company on the site.",
));
}
match cfg.naming_mode {
NamingMode::Mirror => {
@@ -533,14 +723,21 @@ pub struct ConnectionTest {
/// Runs the connection test: identity (a failure here fails the test), versions, then the checks that only
/// produce warnings.
pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &LocalFacts) -> Result<ConnectionTest, ErpError> {
pub async fn test_connection(
client: &ErpClient,
cfg: &ErpnextConfig,
local: &LocalFacts,
) -> Result<ConnectionTest, ErpError> {
let user = client.get_logged_user().await?;
let versions = match client.get_versions().await {
Ok(v) => Some(v),
Err(e) if e.kind == ErrorKind::Auth => return Err(e),
Err(_) => None,
};
let mut remote = RemoteFacts { versions, ..Default::default() };
let mut remote = RemoteFacts {
versions,
..Default::default()
};
let features = remote.versions.as_ref().map(feature_switches);
if features.is_some_and(|f| f.india_compliance) {
if !cfg.company.is_empty() {
@@ -548,7 +745,11 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
.list_resource(
"Address",
&["name"],
json!([["Dynamic Link", "link_doctype", "=", "Company"], ["Dynamic Link", "link_name", "=", cfg.company], ["disabled", "=", 0]]),
json!([
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", cfg.company],
["disabled", "=", 0]
]),
"name asc",
)
.await
@@ -560,7 +761,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
client
.get(&["api", "resource", "GST Settings", "GST Settings"], &[])
.await
.map(|v| v.get("data").and_then(|d| d.get("gst_accounts")).and_then(Value::as_array).cloned().unwrap_or_default())
.map(|v| {
v.get("data")
.and_then(|d| d.get("gst_accounts"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
})
.map_err(|e| e.to_string()),
);
}
@@ -568,7 +775,13 @@ pub async fn test_connection(client: &ErpClient, cfg: &ErpnextConfig, local: &Lo
let warnings = build_warnings(cfg, local, &remote);
let versions = remote.versions.unwrap_or_default();
let features = feature_switches(&versions);
Ok(ConnectionTest { user, india_compliance: features.india_compliance, features, versions, warnings })
Ok(ConnectionTest {
user,
india_compliance: features.india_compliance,
features,
versions,
warnings,
})
}
#[cfg(test)]
@@ -608,10 +821,38 @@ mod tests {
#[test]
fn versions_parse_leniently() {
assert_eq!(Version::parse("15.73.2"), Some(Version { major: 15, minor: 73, patch: 2 }));
assert_eq!(Version::parse("v16.0.0-dev"), Some(Version { major: 16, minor: 0, patch: 0 }));
assert_eq!(Version::parse("15.1"), Some(Version { major: 15, minor: 1, patch: 0 }));
assert_eq!(Version::parse("15.73.0-beta.2"), Some(Version { major: 15, minor: 73, patch: 0 }));
assert_eq!(
Version::parse("15.73.2"),
Some(Version {
major: 15,
minor: 73,
patch: 2
})
);
assert_eq!(
Version::parse("v16.0.0-dev"),
Some(Version {
major: 16,
minor: 0,
patch: 0
})
);
assert_eq!(
Version::parse("15.1"),
Some(Version {
major: 15,
minor: 1,
patch: 0
})
);
assert_eq!(
Version::parse("15.73.0-beta.2"),
Some(Version {
major: 15,
minor: 73,
patch: 0
})
);
assert_eq!(Version::parse("unknown"), None);
}
@@ -640,7 +881,11 @@ mod tests {
assert_eq!(v.frappe.as_deref(), Some("15.73.0"));
assert_eq!(v.erpnext.as_deref(), Some("15.121.6"));
assert_eq!(v.india_compliance.as_deref(), Some("15.9.0"));
assert!(parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } })).india_compliance.is_none());
assert!(
parse_versions(&json!({ "message": { "frappe": { "version": "15.0.0" } } }))
.india_compliance
.is_none()
);
}
#[test]
@@ -648,7 +893,16 @@ mod tests {
for ok in ["INV/2026-001", "A", "1234567890123456", "AP-24-25/0001"] {
assert!(ic_number_ok(ok), "{ok}");
}
for bad in ["", "12345678901234567", "_AB", "AB 01", "AB_01", "-AB", "/AB", "AB#1"] {
for bad in [
"",
"12345678901234567",
"_AB",
"AB 01",
"AB_01",
"-AB",
"/AB",
"AB#1",
] {
assert!(!ic_number_ok(bad), "{bad}");
}
}
@@ -656,9 +910,18 @@ mod tests {
#[test]
fn naming_series_is_expanded_before_measuring() {
assert_eq!(naming_series_expanded_len("SINV-.YY.-"), 5 + 2 + 1 + 5);
assert_eq!(naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"), 9 + 4 + 1 + 5);
assert_eq!(naming_series_expanded_len("INV-.MM.-.DD.-.####"), 4 + 2 + 1 + 2 + 1 + 4);
assert_eq!(naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"), 15 + 4 + 1 + 5);
assert_eq!(
naming_series_expanded_len("ACC-SINV-.YYYY.-.#####"),
9 + 4 + 1 + 5
);
assert_eq!(
naming_series_expanded_len("INV-.MM.-.DD.-.####"),
4 + 2 + 1 + 2 + 1 + 4
);
assert_eq!(
naming_series_expanded_len("LONGPREFIX-ABC-.YYYY.-.#####"),
15 + 4 + 1 + 5
);
}
#[test]
@@ -668,33 +931,54 @@ mod tests {
{ "fieldname": "naming_series", "options": "\nSINV-.YY.-\nACC-SINV-.YYYY.-" }
]}]});
let items = parse_naming_series(&resp);
assert_eq!(items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["SINV-.YY.-", "ACC-SINV-.YYYY.-"]);
assert_eq!(
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(),
["SINV-.YY.-", "ACC-SINV-.YYYY.-"]
);
assert!(parse_naming_series(&json!({})).is_empty());
}
#[test]
fn a_healthy_ic_setup_has_no_warnings() {
let local = LocalFacts { vendor_registered: true, invalid_numbers: vec![], next_number: Some("INV/2026-001".into()) };
assert_eq!(build_warnings(&cfg(), &local, &ic_versions("15.73.0")), vec![]);
let local = LocalFacts {
vendor_registered: true,
invalid_numbers: vec![],
next_number: Some("INV/2026-001".into()),
};
assert_eq!(
build_warnings(&cfg(), &local, &ic_versions("15.73.0")),
vec![]
);
}
#[test]
fn gst_accounts_not_configured_or_mismatched_are_flagged() {
let local = LocalFacts { vendor_registered: true, ..Default::default() };
let local = LocalFacts {
vendor_registered: true,
..Default::default()
};
let mut remote = ic_versions("15.73.0");
remote.gst_accounts = Some(Ok(vec![]));
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_accounts_not_configured"]);
assert_eq!(
codes(&build_warnings(&cfg(), &local, &remote)),
["gst_accounts_not_configured"]
);
let mut remote = ic_versions("15.73.0");
remote.gst_accounts = Some(Ok(vec![json!({ "company": "Test Co", "account_type": "Output",
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" })]));
remote.gst_accounts = Some(Ok(vec![
json!({ "company": "Test Co", "account_type": "Output",
"cgst_account": "Output CGST - AC", "sgst_account": "Other SGST - AC", "igst_account": "Output IGST - AC" }),
]));
let w = build_warnings(&cfg(), &local, &remote);
assert_eq!(codes(&w), ["gst_account_mismatch"]);
assert!(w[0].message.contains("SGST"));
let mut remote = ic_versions("15.73.0");
remote.gst_accounts = Some(Err("HTTP 403".into()));
assert_eq!(codes(&build_warnings(&cfg(), &local, &remote)), ["gst_settings_unreadable"]);
assert_eq!(
codes(&build_warnings(&cfg(), &local, &remote)),
["gst_settings_unreadable"]
);
}
#[test]
@@ -727,20 +1011,45 @@ mod tests {
let mut remote = ic_versions("15.73.0");
remote.company_address_count = Some(0);
assert_eq!(codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)), ["company_address_missing"]);
assert_eq!(
codes(&build_warnings(&cfg(), &LocalFacts::default(), &remote)),
["company_address_missing"]
);
}
#[test]
fn required_settings_and_unknown_versions_are_flagged() {
let w = build_warnings(&ErpnextConfig::default(), &LocalFacts { vendor_registered: true, ..Default::default() }, &RemoteFacts::default());
assert_eq!(codes(&w), ["versions_unknown", "company_missing", "income_account_missing", "tax_accounts_missing"]);
let w = build_warnings(
&ErpnextConfig::default(),
&LocalFacts {
vendor_registered: true,
..Default::default()
},
&RemoteFacts::default(),
);
assert_eq!(
codes(&w),
[
"versions_unknown",
"company_missing",
"income_account_missing",
"tax_accounts_missing"
]
);
}
#[test]
fn registered_vendor_without_ic_gets_a_notice() {
let mut remote = ic_versions("15.73.0");
remote.versions.as_mut().unwrap().india_compliance = None;
let w = build_warnings(&cfg(), &LocalFacts { vendor_registered: true, ..Default::default() }, &remote);
let w = build_warnings(
&cfg(),
&LocalFacts {
vendor_registered: true,
..Default::default()
},
&remote,
);
assert_eq!(codes(&w), ["ic_not_detected"]);
}
@@ -753,27 +1062,51 @@ mod tests {
if path.contains("get_logged_user") {
reply(200, json!({ "message": "integration@example.com" }))
} else if path.contains("get_versions") {
reply(200, json!({ "message": {
"frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" }
}}))
reply(
200,
json!({ "message": {
"frappe": { "version": "15.73.0" }, "erpnext": { "version": "15.121.6" }, "india_compliance": { "version": "15.9.0" }
}}),
)
} else if path.starts_with("/api/resource/GST%20Settings/GST%20Settings") {
reply(200, json!({ "data": { "gst_accounts": [] } }))
} else if path.starts_with("/api/resource/Address?") {
reply(200, json!({ "data": [
{ "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
]}))
reply(
200,
json!({ "data": [
{ "name": "Test Co-Billing", "address_title": "Test Co", "address_line1": "1 Main Rd", "city": "Mumbai", "state": "Maharashtra", "pincode": "400001", "gstin": "27AAPFU0939F1ZV" }
]}),
)
} else if path.starts_with("/api/resource/Company?") {
reply(200, json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }))
reply(
200,
json!({ "data": [{ "name": "Test Co", "company_name": "Test Co", "abbr": "AC", "default_currency": "INR" }] }),
)
} else if path.starts_with("/api/resource/Account?") && path.contains("Income") {
reply(200, json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] }))
reply(
200,
json!({ "data": [{ "name": "Sales - AC", "account_name": "Sales", "account_type": "" }] }),
)
} else if path.starts_with("/api/resource/Account?") && path.contains("Tax") {
reply(200, json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] }))
reply(
200,
json!({ "data": [{ "name": "Output CGST - AC", "account_name": "Output CGST", "account_type": "Tax" }] }),
)
} else if path.starts_with("/api/resource/Price%20List?") {
reply(403, json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" }))
reply(
403,
json!({ "exc_type": "PermissionError", "_error_message": "No permission for Price List" }),
)
} else if path.contains("getdoctype") {
reply(200, json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] }))
reply(
200,
json!({ "docs": [{ "fields": [{ "fieldname": "naming_series", "options": "\nSINV-.YY.-" }] }] }),
)
} else if path.starts_with("/api/resource/UOM?") {
reply(200, json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }))
reply(
200,
json!({ "data": [{ "name": "Nos" }, { "name": "Hour" }, { "name": "" }] }),
)
} else {
reply(200, json!({ "data": [] }))
}
@@ -782,8 +1115,14 @@ mod tests {
#[tokio::test]
async fn connection_test_reports_user_versions_ic_and_warnings() {
let mock = serve_fn(|_, line| site(line)).await;
let local = LocalFacts { vendor_registered: true, next_number: Some("INV/2026-001".into()), ..Default::default() };
let result = test_connection(&client(&mock), &cfg(), &local).await.unwrap();
let local = LocalFacts {
vendor_registered: true,
next_number: Some("INV/2026-001".into()),
..Default::default()
};
let result = test_connection(&client(&mock), &cfg(), &local)
.await
.unwrap();
assert_eq!(result.user, "integration@example.com");
assert_eq!(result.versions.frappe.as_deref(), Some("15.73.0"));
assert!(result.india_compliance && result.features.v2_naming);
@@ -810,17 +1149,25 @@ mod tests {
let options = client(&mock).load_options("Test Co").await.unwrap();
assert_eq!(options.company_addresses.len(), 1);
assert_eq!(options.company_addresses[0].name, "Test Co-Billing");
assert!(options.errors.iter().all(|e| e.list != "companyAddresses"), "{:?}", options.errors);
assert!(
options.errors.iter().all(|e| e.list != "companyAddresses"),
"{:?}",
options.errors
);
}
#[tokio::test]
async fn connection_test_fails_for_the_guest_user_and_bad_credentials() {
let mock = serve_fn(|_, _| reply(200, json!({ "message": "Guest" }))).await;
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err();
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default())
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Auth);
let mock = serve_fn(|_, _| reply(401, json!({ "message": "Invalid Authorization" }))).await;
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default()).await.unwrap_err();
let err = test_connection(&client(&mock), &cfg(), &LocalFacts::default())
.await
.unwrap_err();
assert_eq!(err.kind, ErrorKind::Auth);
assert!(!err.to_string().contains("thesecret"));
}
@@ -837,17 +1184,29 @@ mod tests {
assert_eq!(options.income_accounts[0].name, "Sales - AC");
assert_eq!(options.tax_accounts[0].name, "Output CGST - AC");
assert_eq!(options.naming_series[0].name, "SINV-.YY.-");
assert_eq!(options.uoms.iter().map(|u| u.name.as_str()).collect::<Vec<_>>(), ["Nos", "Hour"]);
assert_eq!(
options
.uoms
.iter()
.map(|u| u.name.as_str())
.collect::<Vec<_>>(),
["Nos", "Hour"]
);
assert!(options.price_lists.is_empty());
assert_eq!(options.errors.len(), 1);
assert_eq!(options.errors[0].list, "priceLists");
assert!(options.errors[0].message.contains("No permission for Price List"));
assert!(options.errors[0]
.message
.contains("No permission for Price List"));
// Company-scoped lists are skipped until a company is chosen.
let none = client(&mock).load_options(" ").await.unwrap();
assert!(none.income_accounts.is_empty() && none.company_addresses.is_empty());
let requests = mock.requests.lock().unwrap().join("\n");
assert!(requests.contains("root_type"), "income accounts are filtered by root type");
assert!(
requests.contains("root_type"),
"income accounts are filtered by root type"
);
assert!(requests.contains("limit_page_length=0"));
}
@@ -856,6 +1215,10 @@ mod tests {
let mock = serve_fn(|_, _| reply(401, json!({ "message": "bad" }))).await;
let err = client(&mock).load_options("Test Co").await.unwrap_err();
assert_eq!(err.kind, ErrorKind::Auth);
assert_eq!(mock.hits.load(std::sync::atomic::Ordering::SeqCst), 1, "stops at the first auth failure");
assert_eq!(
mock.hits.load(std::sync::atomic::Ordering::SeqCst),
1,
"stops at the first auth failure"
);
}
}
+132 -31
View File
@@ -81,7 +81,12 @@ impl From<ErpError> for String {
impl ErpError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
ErpError { kind, status: None, message: message.into(), exc_type: None }
ErpError {
kind,
status: None,
message: message.into(),
exc_type: None,
}
}
pub fn config(message: impl Into<String>) -> Self {
@@ -99,8 +104,15 @@ impl ErpError {
if parsed.exc_type.as_deref() == Some("DuplicateEntryError") {
kind = ErrorKind::Duplicate;
}
let message = parsed.message.unwrap_or_else(|| default_status_text(status));
ErpError { kind, status: Some(status), message, exc_type: parsed.exc_type }
let message = parsed
.message
.unwrap_or_else(|| default_status_text(status));
ErpError {
kind,
status: Some(status),
message,
exc_type: parsed.exc_type,
}
}
/// Removes every occurrence of the given secrets from the message.
@@ -150,14 +162,24 @@ const MAX_MESSAGE_CHARS: usize = 600;
pub fn parse_error_body(body: &str) -> ParsedError {
let Ok(json) = serde_json::from_str::<Value>(body) else {
let text = strip_html(body);
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
return ParsedError { message: non_empty(truncate(first)), exc_type: None };
let first = text
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
return ParsedError {
message: non_empty(truncate(first)),
exc_type: None,
};
};
let Some(obj) = json.as_object() else {
return ParsedError::default();
};
let mut exc_type = obj.get("exc_type").and_then(Value::as_str).map(str::to_string);
let mut exc_type = obj
.get("exc_type")
.and_then(Value::as_str)
.map(str::to_string);
let mut messages: Vec<String> = Vec::new();
if let Some(errors) = obj.get("errors").and_then(Value::as_array) {
@@ -165,8 +187,15 @@ pub fn parse_error_body(body: &str) -> ParsedError {
match e {
Value::String(s) => push_unique(&mut messages, strip_html(s)),
Value::Object(o) => {
let text = o.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty());
let exception = o.get("exception").and_then(Value::as_str).map(split_exception);
let text = o
.get("message")
.and_then(Value::as_str)
.map(strip_html)
.filter(|m| !m.is_empty());
let exception = o
.get("exception")
.and_then(Value::as_str)
.map(split_exception);
if exc_type.is_none() {
exc_type = o
.get("type")
@@ -174,7 +203,9 @@ pub fn parse_error_body(body: &str) -> ParsedError {
.map(str::to_string)
.or_else(|| exception.as_ref().and_then(|(t, _)| t.clone()));
}
if let Some(m) = text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty())) {
if let Some(m) =
text.or_else(|| exception.map(|(_, m)| m).filter(|m| !m.is_empty()))
{
push_unique(&mut messages, m);
}
}
@@ -191,7 +222,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
}
}
if messages.is_empty() {
if let Some(m) = obj.get("_error_message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) {
if let Some(m) = obj
.get("_error_message")
.and_then(Value::as_str)
.map(strip_html)
.filter(|m| !m.is_empty())
{
messages.push(m);
}
}
@@ -207,7 +243,12 @@ pub fn parse_error_body(body: &str) -> ParsedError {
}
}
if messages.is_empty() {
if let Some(m) = obj.get("message").and_then(Value::as_str).map(strip_html).filter(|m| !m.is_empty()) {
if let Some(m) = obj
.get("message")
.and_then(Value::as_str)
.map(strip_html)
.filter(|m| !m.is_empty())
{
messages.push(m);
}
}
@@ -218,10 +259,16 @@ pub fn parse_error_body(body: &str) -> ParsedError {
}
if exc_type.is_none() {
// v1 on Frappe 14 has no `exc_type`; the class is still named in `exception`.
exc_type = obj.get("exception").and_then(Value::as_str).and_then(|e| split_exception(e).0);
exc_type = obj
.get("exception")
.and_then(Value::as_str)
.and_then(|e| split_exception(e).0);
}
ParsedError { message: non_empty(truncate(&messages.join("; "))), exc_type }
ParsedError {
message: non_empty(truncate(&messages.join("; "))),
exc_type,
}
}
fn server_messages(raw: &str) -> Vec<String> {
@@ -233,10 +280,18 @@ fn server_messages(raw: &str) -> Vec<String> {
.filter_map(|item| {
let text = match item {
Value::String(s) => match serde_json::from_str::<Value>(s) {
Ok(Value::Object(o)) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
Ok(Value::Object(o)) => o
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
_ => s.clone(),
},
Value::Object(o) => o.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
Value::Object(o) => o
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
_ => String::new(),
};
let text = strip_html(&text);
@@ -250,18 +305,28 @@ fn server_messages(raw: &str) -> Vec<String> {
fn split_exception(raw: &str) -> (Option<String>, String) {
let first_line = raw.trim().lines().next().unwrap_or("").trim();
if let Some((prefix, rest)) = first_line.split_once(": ") {
let class_like = prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception");
if class_like && prefix.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') {
let class_like =
prefix.contains('.') || prefix.ends_with("Error") || prefix.ends_with("Exception");
if class_like
&& prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
{
let ty = prefix.rsplit('.').next().map(str::to_string);
return (ty, strip_html(rest));
}
}
if !first_line.is_empty()
&& first_line.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
&& first_line
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
&& first_line.contains('.')
{
// `frappe.exceptions.DoesNotExistError` with no message.
return (first_line.rsplit('.').next().map(str::to_string), String::new());
return (
first_line.rsplit('.').next().map(str::to_string),
String::new(),
);
}
(None, strip_html(first_line))
}
@@ -297,8 +362,16 @@ pub fn strip_html(input: &str) -> String {
}
'>' if in_tag => {
in_tag = false;
let name = tag.trim_start_matches('/').split_whitespace().next().unwrap_or("").to_ascii_lowercase();
if matches!(name.as_str(), "br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3") {
let name = tag
.trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or("")
.to_ascii_lowercase();
if matches!(
name.as_str(),
"br" | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3"
) {
out.push('\n');
}
}
@@ -339,7 +412,10 @@ mod tests {
})
.to_string();
let parsed = parse_error_body(&body);
assert_eq!(parsed.message.as_deref(), Some("Row 1: Account Sales & more; Second\nproblem"));
assert_eq!(
parsed.message.as_deref(),
Some("Row 1: Account Sales & more; Second\nproblem")
);
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
let err = ErpError::from_response(417, &body);
assert_eq!(err.kind, ErrorKind::Validation);
@@ -354,7 +430,10 @@ mod tests {
let b = parse_error_body(
&json!({ "exception": "frappe.exceptions.DuplicateEntryError: Sales Invoice INV/1 already exists" }).to_string(),
);
assert_eq!(b.message.as_deref(), Some("Sales Invoice INV/1 already exists"));
assert_eq!(
b.message.as_deref(),
Some("Sales Invoice INV/1 already exists")
);
assert_eq!(b.exc_type.as_deref(), Some("DuplicateEntryError"));
let c = parse_error_body(&json!({ "exc_type": "PermissionError" }).to_string());
@@ -363,8 +442,13 @@ mod tests {
#[test]
fn exception_prefix_is_only_dropped_when_it_looks_like_a_class_path() {
let parsed = parse_error_body(&json!({ "exception": "Something: with a colon but no class" }).to_string());
assert_eq!(parsed.message.as_deref(), Some("Something: with a colon but no class"));
let parsed = parse_error_body(
&json!({ "exception": "Something: with a colon but no class" }).to_string(),
);
assert_eq!(
parsed.message.as_deref(),
Some("Something: with a colon but no class")
);
assert_eq!(parsed.exc_type, None);
}
@@ -378,7 +462,10 @@ mod tests {
})
.to_string();
let parsed = parse_error_body(&body);
assert_eq!(parsed.message.as_deref(), Some("Customer X is disabled; Row 2: rate missing"));
assert_eq!(
parsed.message.as_deref(),
Some("Customer X is disabled; Row 2: rate missing")
);
assert_eq!(parsed.exc_type.as_deref(), Some("ValidationError"));
}
@@ -406,22 +493,36 @@ mod tests {
(502, ErrorKind::Server),
(418, ErrorKind::Other),
] {
assert_eq!(ErpError::from_response(status, "").kind, kind, "status {status}");
assert_eq!(
ErpError::from_response(status, "").kind,
kind,
"status {status}"
);
}
// The exception class wins when the status is generic.
let dup = ErpError::from_response(417, &json!({ "exc_type": "DuplicateEntryError" }).to_string());
let dup = ErpError::from_response(
417,
&json!({ "exc_type": "DuplicateEntryError" }).to_string(),
);
assert_eq!(dup.kind, ErrorKind::Duplicate);
}
#[test]
fn secrets_are_redacted_from_messages() {
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header").redacted(&["topsecret", "abc123"]);
let err = ErpError::new(ErrorKind::Other, "bad token abc123:topsecret in header")
.redacted(&["topsecret", "abc123"]);
assert_eq!(err.message, "bad token [redacted]:[redacted] in header");
}
#[test]
fn display_carries_the_status_and_hint() {
let err = ErpError::from_response(401, &json!({ "message": "Invalid credentials" }).to_string());
assert_eq!(err.to_string(), "Authentication failed (HTTP 401): Invalid credentials");
let err = ErpError::from_response(
401,
&json!({ "message": "Invalid credentials" }).to_string(),
);
assert_eq!(
err.to_string(),
"Authentication failed (HTTP 401): Invalid credentials"
);
}
}
+460 -88
View File
@@ -11,8 +11,8 @@
use super::client::{ClientOptions, ErpClient};
use super::config::{self, ErpnextConfig, NamingMode, Secret};
use super::errors::ErrorKind;
use super::discovery::{test_connection, ConnectionTest, LocalFacts};
use super::errors::ErrorKind;
use super::push::{push_invoice, push_payment, PushResult};
use crate::commands::invoice::{get_invoice_impl, issue_invoice_impl};
use crate::commands::payments::{record_payment_impl, PaymentInput};
@@ -78,7 +78,14 @@ impl Live {
..Default::default()
};
edit(&mut cfg);
let http = ErpClient::new(&cfg.base_url, &cfg.api_key, &cfg.api_secret, "", ClientOptions::default()).unwrap();
let http = ErpClient::new(
&cfg.base_url,
&cfg.api_key,
&cfg.api_secret,
"",
ClientOptions::default(),
)
.unwrap();
let mut conn = crate::db::open_in_memory().unwrap();
conn.execute(
@@ -88,8 +95,24 @@ impl Live {
)
.unwrap();
for (name, gstin, state, line1, city, pin, category) in [
("Voiced Live Intra Co", "", "27", "5 FC Road", "Pune", "411004", "unregistered"),
("Voiced Live Inter Co", KA_GSTIN, "29", "12 MG Road", "Bengaluru", "560001", "registered_regular"),
(
"Voiced Live Intra Co",
"",
"27",
"5 FC Road",
"Pune",
"411004",
"unregistered",
),
(
"Voiced Live Inter Co",
KA_GSTIN,
"29",
"12 MG Road",
"Bengaluru",
"560001",
"registered_regular",
),
] {
conn.execute(
"INSERT INTO clients (name, address, gstin, state_code, address_line1, city, pincode, gst_category, created_at)
@@ -98,12 +121,26 @@ impl Live {
)
.unwrap();
}
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() as u64;
let prefix = format!("L{}{}/", base36(nanos / 1_000_000, 4), base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1));
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let prefix = format!(
"L{}{}/",
base36(nanos / 1_000_000, 4),
base36(COUNTER.fetch_add(1, Ordering::SeqCst) as u64, 1)
);
start_new_series_impl(&mut conn, &prefix, 3).unwrap();
config::save(&conn, &cfg).unwrap();
let live = Live { db: Mutex::new(conn), dir: tempfile::tempdir().unwrap(), http, abbr, ic, prefix };
let live = Live {
db: Mutex::new(conn),
dir: tempfile::tempdir().unwrap(),
http,
abbr,
ic,
prefix,
};
// The same step the UI performs before the first push.
let ct = live.connection_test(&cfg).await;
let mut cfg = cfg;
@@ -113,8 +150,13 @@ impl Live {
}
async fn connection_test(&self, cfg: &ErpnextConfig) -> ConnectionTest {
let local = LocalFacts { vendor_registered: true, ..Default::default() };
test_connection(&self.http, cfg, &local).await.expect("connection test")
let local = LocalFacts {
vendor_registered: true,
..Default::default()
};
test_connection(&self.http, cfg, &local)
.await
.expect("connection test")
}
fn cfg(&self) -> ErpnextConfig {
@@ -176,7 +218,10 @@ impl Live {
self.db
.lock()
.unwrap()
.execute("UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2", params![sha, inv.id])
.execute(
"UPDATE invoices SET archived_pdf_sha256 = ?1 WHERE id = ?2",
params![sha, inv.id],
)
.unwrap();
}
@@ -185,13 +230,22 @@ impl Live {
}
async fn doc(&self, doctype: &str, name: &str) -> Value {
let v = self.http.get(&["api", "resource", doctype, name], &[]).await.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
let v = self
.http
.get(&["api", "resource", doctype, name], &[])
.await
.unwrap_or_else(|e| panic!("read {doctype} {name}: {e}"));
v["data"].clone()
}
async fn invoices_named(&self, number: &str) -> Vec<Value> {
self.http
.list_resource("Sales Invoice", &["name", "docstatus", "grand_total", "remarks"], json!([["remarks", "like", format!("%{number}%")]]), "creation asc")
.list_resource(
"Sales Invoice",
&["name", "docstatus", "grand_total", "remarks"],
json!([["remarks", "like", format!("%{number}%")]]),
"creation asc",
)
.await
.unwrap()
}
@@ -201,7 +255,10 @@ impl Live {
.list_resource(
"File",
&["name", "file_name", "is_private", "file_url"],
json!([["attached_to_doctype", "=", doctype], ["attached_to_name", "=", name]]),
json!([
["attached_to_doctype", "=", doctype],
["attached_to_name", "=", name]
]),
"creation asc",
)
.await
@@ -248,7 +305,13 @@ fn minimal_pdf(label: &str) -> Vec<u8> {
for o in offsets {
out.extend(format!("{o:010} 00000 n \n").into_bytes());
}
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes());
out.extend(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
objects.len() + 1
)
.into_bytes(),
);
out
}
@@ -263,8 +326,17 @@ fn assert_pushed(r: &PushResult) {
/// The invoice as ERPNext holds it: total equals Voiced's, tax rows are what Voiced computed.
async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
assert_eq!(paise(&doc["grand_total"]), crate::gst::rupees_to_paise(inv.total), "grand_total of {}", inv.number);
assert_eq!(doc["rounded_total"].as_f64().unwrap_or(0.0), 0.0, "rounding is disabled");
assert_eq!(
paise(&doc["grand_total"]),
crate::gst::rupees_to_paise(inv.total),
"grand_total of {}",
inv.number
);
assert_eq!(
doc["rounded_total"].as_f64().unwrap_or(0.0),
0.0,
"rounding is disabled"
);
assert_eq!(doc["posting_date"], inv.invoice_date.as_str());
let taxes = doc["taxes"].as_array().unwrap();
let by_head = |needle: &str| -> Option<i64> {
@@ -273,10 +345,25 @@ async fn assert_matches(live: &Live, inv: &Invoice, doc: &Value) {
.find(|t| t["account_head"].as_str().unwrap_or("").contains(needle))
.map(|t| paise(&t["tax_amount"]))
};
assert_eq!(by_head("CGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.cgst_amount).max(0), "CGST of {}", inv.number);
assert_eq!(
by_head("CGST").unwrap_or(0),
crate::gst::rupees_to_paise(inv.cgst_amount).max(0),
"CGST of {}",
inv.number
);
// IGST rows contain "IGST"; "CGST" and "SGST" rows do not.
assert_eq!(by_head("SGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.sgst_amount), "SGST of {}", inv.number);
assert_eq!(by_head("IGST").unwrap_or(0), crate::gst::rupees_to_paise(inv.igst_amount), "IGST of {}", inv.number);
assert_eq!(
by_head("SGST").unwrap_or(0),
crate::gst::rupees_to_paise(inv.sgst_amount),
"SGST of {}",
inv.number
);
assert_eq!(
by_head("IGST").unwrap_or(0),
crate::gst::rupees_to_paise(inv.igst_amount),
"IGST of {}",
inv.number
);
let _ = live;
}
@@ -290,7 +377,10 @@ async fn live_connection_and_options() {
let ct = live.connection_test(&cfg).await;
println!("{}", serde_json::to_string_pretty(&ct).unwrap());
assert!(ct.user.contains('@'));
assert_eq!(ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")), Some(true));
assert_eq!(
ct.versions.frappe.as_deref().map(|v| v.starts_with("15.")),
Some(true)
);
assert!(ct.versions.erpnext.is_some());
assert_eq!(ct.india_compliance, live.ic);
assert!(ct.features.v2_naming, "frappe >= 15.73 supports v2 naming");
@@ -298,14 +388,25 @@ async fn live_connection_and_options() {
let o = live.http.load_options(&cfg.company).await.unwrap();
println!("option errors: {:?}", o.errors);
assert!(o.errors.is_empty(), "{:?}", o.errors);
let has = |list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
let has =
|list: &[super::discovery::OptionItem], name: &str| list.iter().any(|i| i.name == name);
assert!(has(&o.companies, &cfg.company));
assert!(has(&o.income_accounts, &cfg.income_account), "{:?}", o.income_accounts);
assert!(o.tax_accounts.iter().any(|a| a.name.contains("CGST")), "{:?}", o.tax_accounts);
assert!(
has(&o.income_accounts, &cfg.income_account),
"{:?}",
o.income_accounts
);
assert!(
o.tax_accounts.iter().any(|a| a.name.contains("CGST")),
"{:?}",
o.tax_accounts
);
assert!(has(&o.uoms, "Hour") && has(&o.uoms, "Nos"));
assert!(!o.company_addresses.is_empty());
assert!(!o.naming_series.is_empty());
assert!(!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty());
assert!(
!o.cost_centers.is_empty() && !o.customer_groups.is_empty() && !o.territories.is_empty()
);
}
// ---- Sales Invoice mapping ----
@@ -361,10 +462,16 @@ async fn live_push_matrix() {
for (label, inv) in &cases {
live.archive(inv);
let r = live.push(inv, None).await;
println!("{label}: {} -> {} (warnings {:?})", inv.number, r.remote_name, r.warnings);
println!(
"{label}: {} -> {} (warnings {:?})",
inv.number, r.remote_name, r.warnings
);
assert_pushed(&r);
assert!(r.created && r.attached, "{label}: {r:?}");
assert_eq!(r.remote_name, inv.number, "mirror naming keeps the Voiced number");
assert_eq!(
r.remote_name, inv.number,
"mirror naming keeps the Voiced number"
);
let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert_eq!(doc["docstatus"], 0);
assert_matches(&live, inv, &doc).await;
@@ -376,11 +483,18 @@ async fn live_push_matrix() {
// Customer and address shape, as ERPNext stored them.
let inter = &cases[1].1;
let doc = live.doc("Sales Invoice", &inter.number).await;
let customer = live.doc("Customer", doc["customer"].as_str().unwrap()).await;
let customer = live
.doc("Customer", doc["customer"].as_str().unwrap())
.await;
assert_eq!(customer["customer_name"], "Voiced Live Inter Co");
assert_eq!(customer["customer_type"], "Company");
let address = live.doc("Address", doc["customer_address"].as_str().unwrap()).await;
println!("address: {}", json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]}));
let address = live
.doc("Address", doc["customer_address"].as_str().unwrap())
.await;
println!(
"address: {}",
json!({"state": address["state"], "country": address["country"], "pincode": address["pincode"], "city": address["city"], "type": address["address_type"], "links": address["links"], "gst_category": address["gst_category"], "gstin": address["gstin"]})
);
assert_eq!(address["state"], "Karnataka");
assert_eq!(address["country"], "India");
assert_eq!(address["address_type"], "Billing");
@@ -393,10 +507,17 @@ async fn live_push_matrix() {
}
// A fresh local database (lost link) reuses the customer's address instead of creating another.
let again = Live::new(|_| {}).await;
let inv = again.issue(false, json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
let inv = again.issue(
false,
json!([{"description": "Again", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&again.push(&inv, None).await);
let d2 = again.doc("Sales Invoice", &inv.number).await;
assert_eq!(d2["customer_address"], doc["customer_address"], "address reused");
assert_eq!(
d2["customer_address"], doc["customer_address"],
"address reused"
);
assert_eq!(d2["customer"], doc["customer"]);
}
@@ -408,7 +529,13 @@ async fn live_half_paise_rounding() {
// (taxable amount, discount): 9% of 10.50 is 0.945, 18% of 0.25 is 0.045 and so on. Voiced rounds half up.
// Half-even and half-up agree when the digit before the tie is odd (3.50 -> 0.315 -> 0.32), so only the
// first two are certain to differ under Banker's Rounding.
let ties = [(10.5, 0.0, true), (7310.5, 0.0, true), (3.5, 0.0, false), (0.25, 0.0, false), (99.99, 0.15, false)];
let ties = [
(10.5, 0.0, true),
(7310.5, 0.0, true),
(3.5, 0.0, false),
(0.25, 0.0, false),
(99.99, 0.15, false),
];
for (amount, discount, tie) in ties {
let inv = live.issue(
true,
@@ -416,16 +543,31 @@ async fn live_half_paise_rounding() {
json!({"discount": discount}),
);
let r = live.push(&inv, Some(true)).await;
println!("{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}", r.ok, r.error);
println!(
"{amount}/{discount} tie={tie} banker={banker}: ok={} {:?}",
r.ok, r.error
);
if tie && banker {
// ERPNext's Banker's Rounding disagrees by a paise: the draft is reported, kept, and never submitted.
assert!(!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict), "{r:?}");
assert!(
!r.ok && r.status == "conflict" && r.error_kind == Some(ErrorKind::Conflict),
"{r:?}"
);
let msg = r.error.as_deref().unwrap();
assert!(msg.contains("Commercial Rounding") && msg.contains("does not submit"), "{msg}");
assert!(
msg.contains("Commercial Rounding") && msg.contains("does not submit"),
"{msg}"
);
assert_eq!(r.remote_name, inv.number);
let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert_eq!(doc["docstatus"], 0, "a mismatching document is never submitted");
assert!(live.files_of("Sales Invoice", &r.remote_name).await.is_empty());
assert_eq!(
doc["docstatus"], 0,
"a mismatching document is never submitted"
);
assert!(live
.files_of("Sales Invoice", &r.remote_name)
.await
.is_empty());
// Pushing again finds the same draft and reports the same conflict instead of creating another.
let again = live.push(&inv, Some(true)).await;
assert!(!again.ok && again.status == "conflict", "{again:?}");
@@ -442,7 +584,11 @@ async fn live_half_paise_rounding() {
#[ignore = "needs the live ERPNext stack"]
async fn live_repush_is_idempotent() {
let live = Live::new(|_| {}).await;
let inv = live.issue(true, json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]), json!({}));
let inv = live.issue(
true,
json!([{"description": "Design", "mode": "fixed", "amount": 500.0, "hsnSac": "998314"}]),
json!({}),
);
live.archive(&inv);
let first = live.push(&inv, None).await;
assert_pushed(&first);
@@ -452,7 +598,11 @@ async fn live_repush_is_idempotent() {
assert_pushed(&again);
assert!(again.no_op && !again.created, "{again:?}");
// Lose the local sync state: the POST gets a 409 and the document is adopted, not duplicated.
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
live.db
.lock()
.unwrap()
.execute("DELETE FROM erpnext_sync", [])
.unwrap();
let adopted = live.push(&inv, None).await;
assert_pushed(&adopted);
assert!(adopted.attached, "{adopted:?}");
@@ -460,7 +610,12 @@ async fn live_repush_is_idempotent() {
assert_eq!(adopted.remote_name, first.remote_name);
let all = live
.http
.list_resource("Sales Invoice", &["name"], json!([["name", "=", inv.number.clone()]]), "name asc")
.list_resource(
"Sales Invoice",
&["name"],
json!([["name", "=", inv.number.clone()]]),
"name asc",
)
.await
.unwrap();
assert_eq!(all.len(), 1);
@@ -475,13 +630,24 @@ async fn live_repush_is_idempotent() {
async fn live_submit_paths() {
let live = Live::new(|_| {}).await;
// Submit while creating.
let a = live.issue(true, json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]), json!({}));
let a = live.issue(
true,
json!([{"description": "A", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}]),
json!({}),
);
let r = live.push(&a, Some(true)).await;
assert_pushed(&r);
assert_eq!(r.remote_docstatus, 1);
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
assert_eq!(
live.doc("Sales Invoice", &r.remote_name).await["docstatus"],
1
);
// Submit an existing draft in a later push (mirror: v2 method route).
let b = live.issue(true, json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]), json!({}));
let b = live.issue(
true,
json!([{"description": "B", "mode": "fixed", "amount": 200.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&live.push(&b, Some(false)).await);
let r = live.push(&b, Some(true)).await;
assert_pushed(&r);
@@ -494,20 +660,43 @@ async fn live_submit_paths() {
async fn live_series_naming_mode() {
let live = Live::new(|c| c.naming_mode = NamingMode::Series).await;
let o = live.http.load_options(&live.cfg().company).await.unwrap();
let series = o.naming_series.first().expect("a naming series").name.clone();
let series = o
.naming_series
.first()
.expect("a naming series")
.name
.clone();
println!("series options: {:?}", o.naming_series);
live.save_cfg(|c| c.naming_series = series.clone());
let inv = live.issue(true, json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]), json!({"notes": "note"}));
let inv = live.issue(
true,
json!([{"description": "Series", "mode": "fixed", "amount": 700.0, "hsnSac": "998314"}]),
json!({"notes": "note"}),
);
live.archive(&inv);
let r = live.push(&inv, Some(false)).await;
assert_pushed(&r);
assert!(r.created);
assert_ne!(r.remote_name, inv.number, "series mode lets ERPNext name it");
assert_ne!(
r.remote_name, inv.number,
"series mode lets ERPNext name it"
);
let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert!(doc["remarks"].as_str().unwrap().starts_with(&format!("Voiced invoice {}", inv.number)), "{}", doc["remarks"]);
assert!(
doc["remarks"]
.as_str()
.unwrap()
.starts_with(&format!("Voiced invoice {}", inv.number)),
"{}",
doc["remarks"]
);
assert_matches(&live, &inv, &doc).await;
// Lose the sync row: the remarks lookup finds the document instead of creating another.
live.db.lock().unwrap().execute("DELETE FROM erpnext_sync", []).unwrap();
live.db
.lock()
.unwrap()
.execute("DELETE FROM erpnext_sync", [])
.unwrap();
let again = live.push(&inv, Some(false)).await;
assert_pushed(&again);
assert!(!again.created, "{again:?}");
@@ -517,7 +706,10 @@ async fn live_series_naming_mode() {
let s = live.push(&inv, Some(true)).await;
assert_pushed(&s);
assert_eq!(s.remote_docstatus, 1);
assert_eq!(live.doc("Sales Invoice", &r.remote_name).await["docstatus"], 1);
assert_eq!(
live.doc("Sales Invoice", &r.remote_name).await["docstatus"],
1
);
}
#[tokio::test]
@@ -529,18 +721,62 @@ async fn live_uom_and_quantities() {
let row = |desc: &str, unit: &str, qty: f64| json!([{"description": desc, "mode": "rate", "rate": 100.0, "unit": unit, "quantity": qty, "hsnSac": "998314"}]);
let mut outcomes = Vec::new();
for (label, desc, unit, qty) in [
("Hour row on a Nos item, whole qty", "Nos preset", "hour", 2.0),
("Hour row on a Nos item, fractional qty", "Nos preset", "hour", 1.5),
("Hour row on an Hour item, fractional qty", "Hourly preset", "hour", 1.5),
("Nos row on an Hour item, whole qty", "Hourly preset", "unit", 3.0),
("code-less hours, fractional qty", "Code-less hours", "hour", 1.5),
("code-less minutes, fractional qty", "Code-less minutes", "minute", 90.5),
("code-less Nos, fractional qty", "Code-less nos", "unit", 1.5),
("code-less Nos, whole qty", "Code-less nos whole", "unit", 4.0),
(
"Hour row on a Nos item, whole qty",
"Nos preset",
"hour",
2.0,
),
(
"Hour row on a Nos item, fractional qty",
"Nos preset",
"hour",
1.5,
),
(
"Hour row on an Hour item, fractional qty",
"Hourly preset",
"hour",
1.5,
),
(
"Nos row on an Hour item, whole qty",
"Hourly preset",
"unit",
3.0,
),
(
"code-less hours, fractional qty",
"Code-less hours",
"hour",
1.5,
),
(
"code-less minutes, fractional qty",
"Code-less minutes",
"minute",
90.5,
),
(
"code-less Nos, fractional qty",
"Code-less nos",
"unit",
1.5,
),
(
"code-less Nos, whole qty",
"Code-less nos whole",
"unit",
4.0,
),
] {
let inv = live.issue(true, row(desc, unit, qty), json!({}));
let r = live.push(&inv, None).await;
println!("{label}: ok={} {}", r.ok, r.error.clone().unwrap_or_default());
println!(
"{label}: ok={} {}",
r.ok,
r.error.clone().unwrap_or_default()
);
outcomes.push((label, r.ok));
}
println!("{outcomes:?}");
@@ -548,7 +784,10 @@ async fn live_uom_and_quantities() {
assert!(ok("Hour row on an Hour item, fractional qty"));
assert!(ok("code-less hours, fractional qty") && ok("code-less minutes, fractional qty"));
assert!(ok("code-less Nos, whole qty"));
assert!(!ok("code-less Nos, fractional qty"), "ERPNext refuses a fraction of a whole-number UOM");
assert!(
!ok("code-less Nos, fractional qty"),
"ERPNext refuses a fraction of a whole-number UOM"
);
}
// ---- payments ----
@@ -569,8 +808,32 @@ async fn live_payment_entry() {
assert!(r.ok, "{:?}", r.error);
let entry = r.entry_name.clone().unwrap();
let pe = live.doc("Payment Entry", &entry).await;
let keys = ["payment_type", "party_type", "paid_from", "paid_to", "paid_amount", "received_amount", "base_paid_amount", "base_received_amount", "total_allocated_amount", "unallocated_amount", "difference_amount", "reference_no", "reference_date", "posting_date", "bank_account", "mode_of_payment", "status"];
println!("PE {}", json!(keys.iter().map(|k| (k.to_string(), pe[*k].clone())).collect::<serde_json::Map<_, _>>()));
let keys = [
"payment_type",
"party_type",
"paid_from",
"paid_to",
"paid_amount",
"received_amount",
"base_paid_amount",
"base_received_amount",
"total_allocated_amount",
"unallocated_amount",
"difference_amount",
"reference_no",
"reference_date",
"posting_date",
"bank_account",
"mode_of_payment",
"status",
];
println!(
"PE {}",
json!(keys
.iter()
.map(|k| (k.to_string(), pe[*k].clone()))
.collect::<serde_json::Map<_, _>>())
);
println!("PE references {}", pe["references"].as_array().unwrap().iter().map(|r| json!({"ref": r["reference_name"], "allocated": r["allocated_amount"], "total": r["total_amount"], "outstanding": r["outstanding_amount"]}).to_string()).collect::<Vec<_>>().join(","));
println!("PE deductions {}", pe["deductions"].as_array().unwrap().iter().map(|r| json!({"account": r["account"], "cost_center": r["cost_center"], "amount": r["amount"], "description": r["description"]}).to_string()).collect::<Vec<_>>().join(","));
assert_eq!(paise(&pe["paid_amount"]), cash);
@@ -580,14 +843,29 @@ async fn live_payment_entry() {
let ded = pe["deductions"].as_array().unwrap();
assert_eq!(ded.len(), 1);
assert_eq!(ded[0]["account"], live.cfg().tds_account.as_str());
assert_eq!(paise(&ded[0]["amount"]), tds, "a TDS deduction row is a positive amount");
assert_eq!(
paise(&ded[0]["amount"]),
tds,
"a TDS deduction row is a positive amount"
);
assert_eq!(pe["reference_no"], "UTR-LIVE-1");
assert_eq!(pe["posting_date"], "2026-06-20");
assert_eq!(pe["paid_to"], live.cfg().payment_bank_account.as_str(), "money lands in the configured bank account");
assert_eq!(
pe["paid_to"],
live.cfg().payment_bank_account.as_str(),
"money lands in the configured bank account"
);
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
println!("outstanding after payment: {}", inv_doc["outstanding_amount"]);
println!(
"outstanding after payment: {}",
inv_doc["outstanding_amount"]
);
assert_eq!(pe["docstatus"], 1);
assert_eq!(paise(&inv_doc["outstanding_amount"]), 0, "cash plus TDS settles the invoice");
assert_eq!(
paise(&inv_doc["outstanding_amount"]),
0,
"cash plus TDS settles the invoice"
);
// Idempotent locally.
let again = push_payment(&live.db, &live.http, pid).await;
assert!(again.ok && again.already_synced);
@@ -597,14 +875,22 @@ async fn live_payment_entry() {
#[ignore = "needs the live ERPNext stack"]
async fn live_payment_without_tds() {
let live = Live::new(|_| {}).await;
let inv = live.issue(true, json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
let inv = live.issue(
true,
json!([{"description": "Plain", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&live.push(&inv, Some(true)).await);
let total = crate::gst::rupees_to_paise(inv.total);
let pid = live.payment(&inv, total / 2, 0);
let r = push_payment(&live.db, &live.http, pid).await;
assert!(r.ok, "{:?}", r.error);
let inv_doc = live.doc("Sales Invoice", &inv.number).await;
assert_eq!(paise(&inv_doc["outstanding_amount"]), total - total / 2, "partial payment leaves the rest outstanding");
assert_eq!(
paise(&inv_doc["outstanding_amount"]),
total - total / 2,
"partial payment leaves the rest outstanding"
);
}
// ---- India Compliance only (ERPNEXT_IC=1) ----
@@ -625,10 +911,18 @@ async fn live_ic_hsn_enforcement() {
}
let live = Live::new(|_| {}).await;
let mut outcomes = Vec::new();
for (label, hsn) in [("no HSN", ""), ("4-digit HSN", "9983"), ("6-digit HSN", "998314"), ("8-digit HSN", "99831400")] {
for (label, hsn) in [
("no HSN", ""),
("4-digit HSN", "9983"),
("6-digit HSN", "998314"),
("8-digit HSN", "99831400"),
] {
let inv = live.issue(true, json!([{"description": format!("HSN {label}"), "mode": "fixed", "amount": 1000.0, "hsnSac": hsn}]), json!({}));
let r = live.push(&inv, Some(true)).await;
println!("{label}: ok={} docstatus={} err={:?}", r.ok, r.remote_docstatus, r.error);
println!(
"{label}: ok={} docstatus={} err={:?}",
r.ok, r.remote_docstatus, r.error
);
outcomes.push((label, r));
}
let get = |l: &str| &outcomes.iter().find(|(k, _)| *k == l).unwrap().1;
@@ -653,7 +947,14 @@ async fn live_ic_number_longer_than_16_characters() {
// Voiced never issues such a number; this is a number imported or migrated from elsewhere.
let long = format!("{}-EXTRA-LONG-PART", inv.number);
assert!(long.len() > 16);
live.db.lock().unwrap().execute("UPDATE invoices SET number = ?1 WHERE id = ?2", params![long, inv.id]).unwrap();
live.db
.lock()
.unwrap()
.execute(
"UPDATE invoices SET number = ?1 WHERE id = ?2",
params![long, inv.id],
)
.unwrap();
let r = live.push(&inv, None).await;
println!("long number: ok={} err={:?}", r.ok, r.error);
assert!(!r.ok);
@@ -668,26 +969,55 @@ async fn live_ic_gst_account_warnings() {
return;
}
let live = Live::new(|_| {}).await;
let local = LocalFacts { vendor_registered: true, ..Default::default() };
let codes = |ct: &ConnectionTest| ct.warnings.iter().map(|w| w.code.clone()).collect::<Vec<_>>();
let local = LocalFacts {
vendor_registered: true,
..Default::default()
};
let codes = |ct: &ConnectionTest| {
ct.warnings
.iter()
.map(|w| w.code.clone())
.collect::<Vec<_>>()
};
let clean = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
let clean = test_connection(&live.http, &live.cfg(), &local)
.await
.unwrap();
assert!(clean.warnings.is_empty(), "{:?}", clean.warnings);
let mut cfg = live.cfg();
cfg.cgst_account = format!("Input Tax CGST - {}", live.abbr);
let wrong = test_connection(&live.http, &cfg, &local).await.unwrap();
assert_eq!(codes(&wrong), ["gst_account_mismatch"], "{:?}", wrong.warnings);
assert_eq!(
codes(&wrong),
["gst_account_mismatch"],
"{:?}",
wrong.warnings
);
let mut cfg = live.cfg();
cfg.company = "No Such Co".into();
let other = test_connection(&live.http, &cfg, &local).await.unwrap();
assert!(codes(&other).contains(&"gst_accounts_not_configured".to_string()), "{:?}", other.warnings);
assert!(
codes(&other).contains(&"gst_accounts_not_configured".to_string()),
"{:?}",
other.warnings
);
// Mirror mode with a number the site would refuse.
let local = LocalFacts { vendor_registered: true, next_number: Some("TOOLONG-INVOICE-0001".into()), ..Default::default() };
let ct = test_connection(&live.http, &live.cfg(), &local).await.unwrap();
assert!(codes(&ct).contains(&"number_invalid".to_string()), "{:?}", ct.warnings);
let local = LocalFacts {
vendor_registered: true,
next_number: Some("TOOLONG-INVOICE-0001".into()),
..Default::default()
};
let ct = test_connection(&live.http, &live.cfg(), &local)
.await
.unwrap();
assert!(
codes(&ct).contains(&"number_invalid".to_string()),
"{:?}",
ct.warnings
);
}
#[tokio::test]
@@ -702,7 +1032,11 @@ async fn live_ic_reverse_charge_and_gst_fields() {
// India Compliance refuses is_reverse_charge=1 without negative RCM tax rows ("Booked reverse charge is not
// equal to applied tax amount"), whatever GST Settings say, so it is sent as 0 with a warning.
assert_pushed(&r);
assert!(r.warnings.iter().any(|w| w.contains("reverse charge")), "{:?}", r.warnings);
assert!(
r.warnings.iter().any(|w| w.contains("reverse charge")),
"{:?}",
r.warnings
);
let doc = live.doc("Sales Invoice", &r.remote_name).await;
assert_eq!(doc["is_reverse_charge"], 0);
assert_matches(&live, &inv, &doc).await;
@@ -718,13 +1052,34 @@ async fn live_ic_state_names_match_voiced() {
return;
}
let live = Live::new(|_| {}).await;
let v = live.http.get(&["api", "method", "frappe.desk.form.load.getdoctype"], &[("doctype", "Address".to_string())]).await.unwrap();
let v = live
.http
.get(
&["api", "method", "frappe.desk.form.load.getdoctype"],
&[("doctype", "Address".to_string())],
)
.await
.unwrap();
let docs = v["docs"].as_array().unwrap();
let address = docs.iter().find(|d| d["name"] == "Address").unwrap();
let options = address["fields"].as_array().unwrap().iter().find(|f| f["fieldname"] == "gst_state").expect("IC adds gst_state");
let ic: Vec<&str> = options["options"].as_str().unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).collect();
let options = address["fields"]
.as_array()
.unwrap()
.iter()
.find(|f| f["fieldname"] == "gst_state")
.expect("IC adds gst_state");
let ic: Vec<&str> = options["options"]
.as_str()
.unwrap()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
for (code, name) in crate::gst::STATES {
assert!(ic.contains(name), "India Compliance has no state named {name:?} ({code}); it has {ic:?}");
assert!(
ic.contains(name),
"India Compliance has no state named {name:?} ({code}); it has {ic:?}"
);
}
assert_eq!(ic.len(), crate::gst::STATES.len(), "{ic:?}");
}
@@ -733,23 +1088,40 @@ async fn live_ic_state_names_match_voiced() {
#[ignore = "needs the live ERPNext stack"]
async fn live_payment_edge_cases() {
let live = Live::new(|_| {}).await;
let inv = live.issue(true, json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
let inv = live.issue(
true,
json!([{"description": "Edge", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
json!({}),
);
// Not submitted yet: refused locally, nothing sent.
assert_pushed(&live.push(&inv, Some(false)).await);
let early = live.payment(&inv, 100_00, 0);
let r = push_payment(&live.db, &live.http, early).await;
assert!(!r.ok && r.error.as_deref().unwrap().contains("not submitted"), "{r:?}");
assert!(
!r.ok && r.error.as_deref().unwrap().contains("not submitted"),
"{r:?}"
);
assert_pushed(&live.push(&inv, Some(true)).await);
let total = crate::gst::rupees_to_paise(inv.total);
// TDS only (no cash): settled by deduction alone.
let tds_only = live.payment(&inv, 0, total - 100_00);
let r = push_payment(&live.db, &live.http, tds_only).await;
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS only"), "{r:?}");
assert!(
!r.ok && r.error.as_deref().unwrap().contains("TDS only"),
"{r:?}"
);
// Without a configured TDS account a TDS payment is refused locally.
live.save_cfg(|c| c.tds_account = String::new());
let inv2 = live.issue(true, json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]), json!({}));
let inv2 = live.issue(
true,
json!([{"description": "Edge 2", "mode": "fixed", "amount": 1000.0, "hsnSac": "998314"}]),
json!({}),
);
assert_pushed(&live.push(&inv2, Some(true)).await);
let t = live.payment(&inv2, 100_00, 50_00);
let r = push_payment(&live.db, &live.http, t).await;
assert!(!r.ok && r.error.as_deref().unwrap().contains("TDS account"), "{r:?}");
assert!(
!r.ok && r.error.as_deref().unwrap().contains("TDS account"),
"{r:?}"
);
}
+186 -42
View File
@@ -150,7 +150,12 @@ pub fn remarks_marker(number: &str) -> String {
fn verified_totals(inv: &Invoice) -> Result<(TaxType, i64, Totals), String> {
let tax_type = parse_tax_type(&inv.tax_type)?;
let rate_bp = (inv.tax_rate * 100.0).round() as i64;
let totals = gst::compute_totals(&inv.items, gst::rupees_to_paise(inv.discount), tax_type, rate_bp);
let totals = gst::compute_totals(
&inv.items,
gst::rupees_to_paise(inv.discount),
tax_type,
rate_bp,
);
let stored = [
(totals.cgst, inv.cgst_amount, "CGST"),
(totals.sgst, inv.sgst_amount, "SGST"),
@@ -199,13 +204,21 @@ fn distinct_descriptions(items: &[InvoiceItem]) -> Vec<String> {
.enumerate()
.map(|(i, item)| {
let trimmed = item.description.trim();
let full = if trimmed.is_empty() { format!("Item {}", i + 1) } else { trimmed.to_string() };
let full = if trimmed.is_empty() {
format!("Item {}", i + 1)
} else {
trimmed.to_string()
};
let (head, tail) = first_line_and_rest(&full);
let mut candidate = full.clone();
let mut n = 2;
while used.contains(&candidate.to_lowercase()) {
let head = format!("{head} ({n})");
candidate = if tail.is_empty() { head } else { format!("{head}\n{tail}") };
candidate = if tail.is_empty() {
head
} else {
format!("{head}\n{tail}")
};
n += 1;
}
used.insert(candidate.to_lowercase());
@@ -271,7 +284,10 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
));
}
let (head, _) = first_line_and_rest(&descriptions[i]);
row.insert("item_name".into(), json!(truncate_chars(head, ITEM_NAME_MAX)));
row.insert(
"item_name".into(),
json!(truncate_chars(head, ITEM_NAME_MAX)),
);
}
}
row.insert("description".into(), json!(descriptions[i]));
@@ -296,7 +312,9 @@ fn item_rows(ctx: &InvoiceContext, apply_gst: bool) -> Result<Vec<Value>, String
fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
if account.trim().is_empty() {
return Err(format!("The {head} account is not set in the ERPNext settings."));
return Err(format!(
"The {head} account is not set in the ERPNext settings."
));
}
Ok(json!({
"charge_type": "On Net Total",
@@ -306,10 +324,19 @@ fn tax_row(account: &str, head: &str, rate: &str) -> Result<Value, String> {
}))
}
fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state: &str) -> Result<Vec<Value>, String> {
fn tax_rows(
cfg: &ErpnextConfig,
tax_type: TaxType,
rate_bp: i64,
supplier_state: &str,
) -> Result<Vec<Value>, String> {
match tax_type {
TaxType::None => Ok(Vec::new()),
TaxType::Igst => Ok(vec![tax_row(&cfg.igst_account, "IGST", &scaled_decimal(rate_bp, 2))?]),
TaxType::Igst => Ok(vec![tax_row(
&cfg.igst_account,
"IGST",
&scaled_decimal(rate_bp, 2),
)?]),
TaxType::CgstSgst => {
// rate_bp is in hundredths of a percent; half of it in thousandths of a percent is rate_bp * 5.
let half = scaled_decimal(rate_bp * 5, 3);
@@ -319,7 +346,10 @@ fn tax_rows(cfg: &ErpnextConfig, tax_type: TaxType, rate_bp: i64, supplier_state
} else {
&cfg.sgst_account
};
Ok(vec![tax_row(&cfg.cgst_account, "CGST", &half)?, tax_row(second_account, second, &half)?])
Ok(vec![
tax_row(&cfg.cgst_account, "CGST", &half)?,
tax_row(second_account, second, &half)?,
])
}
}
}
@@ -328,7 +358,10 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
let inv = ctx.invoice;
let cfg = ctx.config;
if inv.status != "issued" {
return Err(format!("Invoice {} is {}; only issued invoices are sent to ERPNext.", inv.number, inv.status));
return Err(format!(
"Invoice {} is {}; only issued invoices are sent to ERPNext.",
inv.number, inv.status
));
}
if ctx.customer.trim().is_empty() {
return Err("The ERPNext customer is not resolved.".into());
@@ -346,7 +379,9 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
}
NamingMode::Series => {
if cfg.naming_series.trim().is_empty() {
return Err("ERPNext series naming is selected but no naming series is set.".into());
return Err(
"ERPNext series naming is selected but no naming series is set.".into(),
);
}
body.insert("naming_series".into(), json!(cfg.naming_series.trim()));
}
@@ -396,14 +431,21 @@ pub fn build_sales_invoice(ctx: &InvoiceContext) -> Result<BuiltRequest, String>
body.insert("discount_amount".into(), money(totals.discount));
}
body.insert("items".into(), Value::Array(item_rows(ctx, gst_fields)?));
body.insert("taxes".into(), Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?));
body.insert(
"taxes".into(),
Value::Array(tax_rows(cfg, tax_type, rate_bp, &ctx.vendor.state_code)?),
);
if ctx.submit {
body.insert("docstatus".into(), json!(1));
}
let mirror = cfg.naming_mode == NamingMode::Mirror;
Ok(BuiltRequest {
path: if mirror { SALES_INVOICE_V2 } else { SALES_INVOICE_V1 },
path: if mirror {
SALES_INVOICE_V2
} else {
SALES_INVOICE_V1
},
body: Value::Object(body),
idempotent: mirror,
})
@@ -416,7 +458,11 @@ fn has_gstin(client: &Client) -> bool {
!g.is_empty() && !g.eq_ignore_ascii_case("NA")
}
pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bool) -> Result<BuiltRequest, String> {
pub fn build_customer(
client: &Client,
cfg: &ErpnextConfig,
india_compliance: bool,
) -> Result<BuiltRequest, String> {
let name = client.name.trim();
if name.is_empty() {
return Err("The client has no name.".into());
@@ -427,19 +473,33 @@ pub fn build_customer(client: &Client, cfg: &ErpnextConfig, india_compliance: bo
set_if(&mut body, "customer_group", &cfg.customer_group);
set_if(&mut body, "territory", &cfg.territory);
if india_compliance {
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?));
let taxable_registration = matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez");
body.insert(
"gst_category".into(),
json!(gst_category_label(&client.gst_category)?),
);
let taxable_registration = matches!(
client.gst_category.as_str(),
"registered_regular" | "composition" | "sez"
);
if taxable_registration && has_gstin(client) {
let gstin = client.gstin.trim().to_ascii_uppercase();
gst::validate_gstin(&gstin).map_err(|e| format!("Client GSTIN: {e}"))?;
body.insert("gstin".into(), json!(gstin));
}
}
Ok(BuiltRequest { path: CUSTOMER, body: Value::Object(body), idempotent: false })
Ok(BuiltRequest {
path: CUSTOMER,
body: Value::Object(body),
idempotent: false,
})
}
/// `customer` is the name ERPNext returned when the Customer was created (a duplicate becomes "X - 1").
pub fn build_address(client: &Client, customer: &str, india_compliance: bool) -> Result<BuiltRequest, String> {
pub fn build_address(
client: &Client,
customer: &str,
india_compliance: bool,
) -> Result<BuiltRequest, String> {
if customer.trim().is_empty() {
return Err("The ERPNext customer is not resolved.".into());
}
@@ -472,16 +532,31 @@ pub fn build_address(client: &Client, customer: &str, india_compliance: bool) ->
set_if(&mut body, "pincode", &client.pincode);
body.insert("country".into(), json!("India"));
if india_compliance {
body.insert("gst_category".into(), json!(gst_category_label(&client.gst_category)?));
if has_gstin(client) && matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez") {
body.insert("gstin".into(), json!(client.gstin.trim().to_ascii_uppercase()));
body.insert(
"gst_category".into(),
json!(gst_category_label(&client.gst_category)?),
);
if has_gstin(client)
&& matches!(
client.gst_category.as_str(),
"registered_regular" | "composition" | "sez"
)
{
body.insert(
"gstin".into(),
json!(client.gstin.trim().to_ascii_uppercase()),
);
}
}
body.insert(
"links".into(),
json!([{ "link_doctype": "Customer", "link_name": customer.trim() }]),
);
Ok(BuiltRequest { path: ADDRESS, body: Value::Object(body), idempotent: false })
Ok(BuiltRequest {
path: ADDRESS,
body: Value::Object(body),
idempotent: false,
})
}
#[cfg(test)]
@@ -539,7 +614,13 @@ mod tests {
Vendor::from_snapshot(&inv.vendor_snapshot).unwrap()
}
fn build(inv: &Invoice, cfg: &ErpnextConfig, ic: bool, codes: &[Option<String>], submit: bool) -> Result<BuiltRequest, String> {
fn build(
inv: &Invoice,
cfg: &ErpnextConfig,
ic: bool,
codes: &[Option<String>],
submit: bool,
) -> Result<BuiltRequest, String> {
let v = vendor(inv);
build_sales_invoice(&InvoiceContext {
invoice: inv,
@@ -597,7 +678,10 @@ mod tests {
assert_eq!(scaled_decimal(1500, 3), "1.5");
assert_eq!(scaled_decimal(2625, 3), "2.625");
assert_eq!(money(731_005).to_string(), "7310.05");
assert_eq!(money(i64::from(u32::MAX) * 100 + 7).to_string(), "4294967295.07");
assert_eq!(
money(i64::from(u32::MAX) * 100 + 7).to_string(),
"4294967295.07"
);
}
#[test]
@@ -607,7 +691,10 @@ mod tests {
assert_eq!(place_of_supply_label("97").unwrap(), "97-Other Territory");
assert_eq!(place_of_supply_label("96").unwrap(), "96-Other Countries");
for (code, name) in gst::STATES {
assert_eq!(place_of_supply_label(code).unwrap(), format!("{code}-{name}"));
assert_eq!(
place_of_supply_label(code).unwrap(),
format!("{code}-{name}")
);
}
assert!(place_of_supply_label("28").is_err());
}
@@ -750,10 +837,16 @@ mod tests {
#[test]
fn plain_erpnext_registered_vendor_gets_tax_rows_but_no_india_compliance_fields() {
let mut conn = registered();
let inv = issue(&mut conn, json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }));
let inv = issue(
&mut conn,
json!({ "items": [{"description": "Design", "mode": "fixed", "amount": 100.0, "hsnSac": "998314"}] }),
);
let req = build(&inv, &cfg(), false, &[None], false).unwrap();
assert_eq!(req.body["taxes"].as_array().unwrap().len(), 2);
assert!(req.body.get("place_of_supply").is_none() && req.body.get("is_reverse_charge").is_none());
assert!(
req.body.get("place_of_supply").is_none()
&& req.body.get("is_reverse_charge").is_none()
);
assert!(req.body["items"][0].get("gst_hsn_code").is_none());
}
@@ -795,8 +888,17 @@ mod tests {
let req = build(&inv, &cfg(), false, &codes, false).unwrap();
let rows = req.body["items"].as_array().unwrap();
assert!(rows[0].get("stock_uom").is_none());
assert_eq!((rows[0]["uom"].as_str(), rows[0]["conversion_factor"].as_i64()), (Some("Hour"), Some(1)));
assert_eq!((rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()), (Some("Minute"), Some("Minute")));
assert_eq!(
(
rows[0]["uom"].as_str(),
rows[0]["conversion_factor"].as_i64()
),
(Some("Hour"), Some(1))
);
assert_eq!(
(rows[1]["uom"].as_str(), rows[1]["stock_uom"].as_str()),
(Some("Minute"), Some("Minute"))
);
}
#[test]
@@ -849,7 +951,10 @@ mod tests {
assert!(!req.idempotent);
assert!(req.body.get("name").is_none());
assert_eq!(req.body["naming_series"], "SINV-.YY.-");
assert_eq!(req.body["remarks"], format!("Voiced invoice {}\nNet 30", inv.number));
assert_eq!(
req.body["remarks"],
format!("Voiced invoice {}\nNet 30", inv.number)
);
c.naming_series.clear();
assert!(build(&inv, &c, true, &[None], false).is_err());
@@ -859,8 +964,15 @@ mod tests {
fn submit_adds_docstatus_and_drafts_do_not() {
let mut conn = registered();
let inv = issue(&mut conn, json!({}));
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("docstatus").is_none());
assert_eq!(build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"], 1);
assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap()
.body
.get("docstatus")
.is_none());
assert_eq!(
build(&inv, &cfg(), true, &[None], true).unwrap().body["docstatus"],
1
);
}
#[test]
@@ -874,7 +986,9 @@ mod tests {
// A supplier in a union territory without a legislature charges UTGST, not SGST.
let mut ut = inv.clone();
ut.vendor_snapshot = ut.vendor_snapshot.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\"");
ut.vendor_snapshot = ut
.vendor_snapshot
.replace("\"vendorStateCode\":\"27\"", "\"vendorStateCode\":\"04\"");
let req = build(&ut, &cfg(), true, &[None], false).unwrap();
assert_eq!(req.body["taxes"][1]["account_head"], "Output UTGST - AC");
assert_eq!(req.body["taxes"][1]["description"], "UTGST @ 2.625%");
@@ -901,11 +1015,17 @@ mod tests {
let mut inv = issue(&mut conn, json!({}));
let mut c = cfg();
c.cgst_account.clear();
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("CGST account"));
assert!(build(&inv, &c, true, &[None], false)
.unwrap_err()
.contains("CGST account"));
c.company.clear();
assert!(build(&inv, &c, true, &[None], false).unwrap_err().contains("company"));
assert!(build(&inv, &c, true, &[None], false)
.unwrap_err()
.contains("company"));
inv.status = "cancelled".into();
assert!(build(&inv, &cfg(), true, &[None], false).unwrap_err().contains("only issued"));
assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap_err()
.contains("only issued"));
}
#[test]
@@ -913,9 +1033,17 @@ mod tests {
let mut conn = registered();
let mut inv = issue(&mut conn, json!({}));
inv.due_date = "2026-03-01".into();
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none());
assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap()
.body
.get("due_date")
.is_none());
inv.due_date.clear();
assert!(build(&inv, &cfg(), true, &[None], false).unwrap().body.get("due_date").is_none());
assert!(build(&inv, &cfg(), true, &[None], false)
.unwrap()
.body
.get("due_date")
.is_none());
}
#[test]
@@ -960,11 +1088,17 @@ mod tests {
}
let mut unreg = client();
unreg.gst_category = "unregistered".into();
assert!(build_customer(&unreg, &cfg(), true).unwrap().body.get("gstin").is_none());
assert!(build_customer(&unreg, &cfg(), true)
.unwrap()
.body
.get("gstin")
.is_none());
let mut bad = client();
bad.gstin = "29AABCU9603R1Z0".into();
assert!(build_customer(&bad, &cfg(), true).unwrap_err().contains("GSTIN"));
assert!(build_customer(&bad, &cfg(), true)
.unwrap_err()
.contains("GSTIN"));
}
#[test]
@@ -989,7 +1123,10 @@ mod tests {
);
let mut jk = client();
jk.state_code = "01".into();
assert_eq!(build_address(&jk, "X", true).unwrap().body["state"], "Jammu and Kashmir");
assert_eq!(
build_address(&jk, "X", true).unwrap().body["state"],
"Jammu and Kashmir"
);
}
#[test]
@@ -997,7 +1134,10 @@ mod tests {
let mut c = client();
c.address_line1.clear();
c.address = "5 Park Street\nKolkata, West Bengal - 700016".into();
assert_eq!(build_address(&c, "X", false).unwrap().body["address_line1"], "5 Park Street");
assert_eq!(
build_address(&c, "X", false).unwrap().body["address_line1"],
"5 Park Street"
);
let mut no_city = client();
no_city.city.clear();
@@ -1005,7 +1145,11 @@ mod tests {
let mut no_state = client();
no_state.state_code.clear();
assert!(build_address(&no_state, "X", true).is_err());
assert!(build_address(&no_state, "X", false).unwrap().body.get("state").is_none());
assert!(build_address(&no_state, "X", false)
.unwrap()
.body
.get("state")
.is_none());
assert!(build_address(&client(), " ", true).is_err());
}
}
+314 -73
View File
@@ -9,8 +9,8 @@ use super::config::{self, ErpnextConfig, NamingMode};
use super::discovery::ic_number_ok;
use super::errors::{ErpError, ErrorKind};
use super::mapping::{
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker, InvoiceContext, Vendor,
SALES_INVOICE_V2,
self, build_address, build_customer, build_sales_invoice, paise_to_decimal, remarks_marker,
InvoiceContext, Vendor, SALES_INVOICE_V2,
};
use crate::commands::archive::read_archive_impl;
use crate::commands::invoice::get_invoice_impl;
@@ -31,14 +31,20 @@ use std::sync::Mutex;
type Db = Mutex<Connection>;
const DOCTYPE_INVOICE: &str = "Sales Invoice";
const GET_PAYMENT_ENTRY: &str = "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
const GET_PAYMENT_ENTRY: &str =
"erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry";
fn pre(message: impl Into<String>) -> ErpError {
ErpError::new(ErrorKind::Precondition, message)
}
fn with_db<T>(db: &Db, f: impl FnOnce(&mut Connection) -> Result<T, String>) -> Result<T, ErpError> {
let mut conn = db.lock().map_err(|e| pre(format!("The database is busy: {e}")))?;
fn with_db<T>(
db: &Db,
f: impl FnOnce(&mut Connection) -> Result<T, String>,
) -> Result<T, ErpError> {
let mut conn = db
.lock()
.map_err(|e| pre(format!("The database is busy: {e}")))?;
f(&mut conn).map_err(|e| pre(format!("Could not read or save the sync state: {e}")))
}
@@ -197,7 +203,11 @@ pub fn sync_statuses(conn: &Connection) -> Result<Vec<SyncStatus>, String> {
/// `<base>/app/sales-invoice/<name>`, with the name URL-encoded (a mirrored number contains a slash).
pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
const KEEP: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~');
const KEEP: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
let row = load_sync(conn, invoice_id)?;
let name = row.map(|r| r.remote_name).unwrap_or_default();
if name.is_empty() {
@@ -205,7 +215,10 @@ pub fn open_url(conn: &Connection, invoice_id: i64) -> Result<String, String> {
}
let cfg = config::load(conn)?;
let base = super::client::normalize_base_url(&cfg.base_url).map_err(|e| e.to_string())?;
Ok(format!("{base}/app/sales-invoice/{}", utf8_percent_encode(&name, KEEP)))
Ok(format!(
"{base}/app/sales-invoice/{}",
utf8_percent_encode(&name, KEEP)
))
}
// ---- loading ----
@@ -294,7 +307,12 @@ fn load_client(conn: &Connection, inv: &Invoice) -> Result<ClientRow, String> {
address_line2: String::new(),
city: String::new(),
pincode: String::new(),
gst_category: if has_gstin { "registered_regular" } else { "unregistered" }.into(),
gst_category: if has_gstin {
"registered_regular"
} else {
"unregistered"
}
.into(),
default_notes: String::new(),
payment_terms_days: None,
invoice_count: 0,
@@ -321,7 +339,11 @@ fn load_item_codes(conn: &Connection, inv: &Invoice) -> Result<Vec<Option<String
// ORDER BY id DESC: the oldest preset wins on a repeated description.
by_desc.insert(desc.trim().to_lowercase(), code.trim().to_string());
}
Ok(inv.items.iter().map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned()).collect())
Ok(inv
.items
.iter()
.map(|i| by_desc.get(&i.description.trim().to_lowercase()).cloned())
.collect())
}
/// True when a connection test result is stored (blank, `null` and `{}` count as none).
@@ -353,8 +375,11 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
}
}
let cfg = config::load(&conn).map_err(|e| fail(pre(e)))?;
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot)
.ok_or_else(|| fail(pre(format!("Invoice {number} has no supplier details recorded, so it cannot be sent."))))?;
let vendor = Vendor::from_snapshot(&invoice.vendor_snapshot).ok_or_else(|| {
fail(pre(format!(
"Invoice {number} has no supplier details recorded, so it cannot be sent."
)))
})?;
// A registered vendor's invoices go out with India Compliance fields, and those depend on what the
// connection test detected, so refuse until the test has run once.
if vendor.registered && !has_detect_result(&cfg.last_detect_result) {
@@ -382,7 +407,17 @@ fn load_for_push(db: &Db, local_dir: &Path, invoice_id: i64) -> Result<Loaded, (
.ok()
.and_then(|v| v.get("indiaCompliance").and_then(Value::as_bool))
.unwrap_or(false);
Ok(Loaded { cfg, invoice, vendor, client, item_codes, sync, pdf, pdf_warning, india_compliance })
Ok(Loaded {
cfg,
invoice,
vendor,
client,
item_codes,
sync,
pdf,
pdf_warning,
india_compliance,
})
}
// ---- address checks (local, before anything is sent) ----
@@ -430,7 +465,10 @@ fn gstin_applies(client: &Client) -> bool {
let g = client.gstin.trim();
!g.is_empty()
&& !g.eq_ignore_ascii_case("NA")
&& matches!(client.gst_category.as_str(), "registered_regular" | "composition" | "sez")
&& matches!(
client.gst_category.as_str(),
"registered_regular" | "composition" | "sez"
)
}
/// The checks India Compliance would make on an Address, done here so the error is readable and local.
@@ -440,7 +478,10 @@ pub fn validate_address(client: &Client) -> Result<(), String> {
let pin = client.pincode.trim();
if !pin.is_empty() {
if pin.len() != 6 || !pin.bytes().all(|b| b.is_ascii_digit()) || pin.starts_with('0') {
return Err(format!("{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.", client.name.trim()));
return Err(format!(
"{}: the PIN code \"{pin}\" is not a valid 6-digit PIN.",
client.name.trim()
));
}
if let Some(allowed) = pin_prefixes(state_code) {
if !allowed.iter().any(|p| pin.starts_with(p)) {
@@ -482,7 +523,9 @@ fn doc_docstatus(doc: &Value) -> i64 {
fn doc_total_paise(doc: &Value) -> Option<i64> {
let v = doc.get("grand_total")?;
let n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?;
let n = v
.as_f64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))?;
Some(gst::rupees_to_paise(n))
}
@@ -502,13 +545,26 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
"creation asc",
)
.await?;
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string);
found = rows
.first()
.and_then(|r| r.get("name"))
.and_then(Value::as_str)
.map(str::to_string);
}
if found.is_none() {
let rows = http
.list_resource("Customer", &["name"], json!([["customer_name", "=", name]]), "creation asc")
.list_resource(
"Customer",
&["name"],
json!([["customer_name", "=", name]]),
"creation asc",
)
.await?;
found = rows.first().and_then(|r| r.get("name")).and_then(Value::as_str).map(str::to_string);
found = rows
.first()
.and_then(|r| r.get("name"))
.and_then(Value::as_str)
.map(str::to_string);
}
let customer = match found {
Some(c) => c,
@@ -521,14 +577,19 @@ async fn ensure_customer(db: &Db, http: &ErpClient, l: &Loaded) -> Result<String
let req = build_customer(client, &l.cfg, l.india_compliance).map_err(pre)?;
let resp = http.post(req.path, &req.body, req.idempotent).await?;
// A duplicate name comes back as "X - 1": always use what the server returned.
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new customer's name."))?
doc_name(&resp).ok_or_else(|| {
ErpError::protocol("ERPNext did not return the new customer's name.")
})?
}
};
if let Some(id) = l.client.id {
with_db(db, |c| {
c.execute("UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2", params![customer, id])
.map(|_| ())
.map_err(|e| e.to_string())
c.execute(
"UPDATE clients SET erpnext_customer = ?1 WHERE id = ?2",
params![customer, id],
)
.map(|_| ())
.map_err(|e| e.to_string())
})?;
}
Ok(customer)
@@ -548,16 +609,27 @@ async fn ensure_address(
let Some(client_id) = l.client.id else {
return Ok((
None,
Some("The client is not saved, so the invoice was sent without a customer address.".into()),
Some(
"The client is not saved, so the invoice was sent without a customer address."
.into(),
),
));
};
let has_address = [&client.address_line1, &client.address, &client.city, &client.pincode]
.iter()
.any(|s| !s.trim().is_empty());
let has_address = [
&client.address_line1,
&client.address,
&client.city,
&client.pincode,
]
.iter()
.any(|s| !s.trim().is_empty());
if !has_address {
return Ok((
None,
Some(format!("{} has no address saved, so the invoice was sent without a customer address.", client.name.trim())),
Some(format!(
"{} has no address saved, so the invoice was sent without a customer address.",
client.name.trim()
)),
));
}
validate_address(client).map_err(pre)?;
@@ -568,13 +640,18 @@ async fn ensure_address(
Some(existing) => existing,
None => {
let resp = http.post(req.path, &req.body, req.idempotent).await?;
doc_name(&resp).ok_or_else(|| ErpError::protocol("ERPNext did not return the new address's name."))?
doc_name(&resp).ok_or_else(|| {
ErpError::protocol("ERPNext did not return the new address's name.")
})?
}
};
with_db(db, |c| {
c.execute("UPDATE clients SET erpnext_address = ?1 WHERE id = ?2", params![name, client_id])
.map(|_| ())
.map_err(|e| e.to_string())
c.execute(
"UPDATE clients SET erpnext_address = ?1 WHERE id = ?2",
params![name, client_id],
)
.map(|_| ())
.map_err(|e| e.to_string())
})?;
Ok((Some(name), None))
}
@@ -593,7 +670,15 @@ async fn find_address(http: &ErpClient, customer: &str, body: &Value) -> Option<
if let Some(pin) = body.get("pincode").and_then(Value::as_str) {
filters.push(json!(["pincode", "=", pin]));
}
let rows = http.list_resource("Address", &["name"], Value::Array(filters), "`tabAddress`.creation asc").await.ok()?;
let rows = http
.list_resource(
"Address",
&["name"],
Value::Array(filters),
"`tabAddress`.creation asc",
)
.await
.ok()?;
rows.first()?.get("name")?.as_str().map(str::to_string)
}
@@ -656,7 +741,10 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
.list_resource(
DOCTYPE_INVOICE,
&["name", "docstatus", "grand_total", "remarks"],
json!([["remarks", "like", format!("{marker}%")], ["docstatus", "!=", 2]]),
json!([
["remarks", "like", format!("{marker}%")],
["docstatus", "!=", 2]
]),
"creation asc",
)
.await?;
@@ -674,7 +762,11 @@ async fn find_by_remarks(http: &ErpClient, l: &Loaded) -> Result<Option<RemoteDo
}
}
async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltRequest) -> Result<RemoteDoc, ErpError> {
async fn create_or_find(
http: &ErpClient,
l: &Loaded,
body: &mapping::BuiltRequest,
) -> Result<RemoteDoc, ErpError> {
let inv = &l.invoice;
if l.cfg.naming_mode == NamingMode::Series {
if let Some(found) = find_by_remarks(http, l).await? {
@@ -688,10 +780,17 @@ async fn create_or_find(http: &ErpClient, l: &Loaded, body: &mapping::BuiltReque
(Some(n), _) => n,
(None, NamingMode::Mirror) => inv.number.clone(),
(None, NamingMode::Series) => {
return Err(ErpError::protocol("ERPNext did not return the new Sales Invoice's name."))
return Err(ErpError::protocol(
"ERPNext did not return the new Sales Invoice's name.",
))
}
};
Ok(RemoteDoc { name, docstatus: doc_docstatus(&doc), created: true, total_paise: doc_total_paise(&doc) })
Ok(RemoteDoc {
name,
docstatus: doc_docstatus(&doc),
created: true,
total_paise: doc_total_paise(&doc),
})
}
Err(e) if e.kind == ErrorKind::Duplicate && l.cfg.naming_mode == NamingMode::Mirror => {
// The mirrored name is taken: either a repeat of an earlier push or someone else's document.
@@ -719,11 +818,20 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
http.post(&path, &json!({}), false).await?
}
NamingMode::Series => {
http.post(&["api", "resource", DOCTYPE_INVOICE, name], &json!({ "run_method": "submit" }), false).await?
http.post(
&["api", "resource", DOCTYPE_INVOICE, name],
&json!({ "run_method": "submit" }),
false,
)
.await?
}
};
// A 2xx without a docstatus is taken as submitted; one that says otherwise is not.
match resp.get("data").and_then(|d| d.get("docstatus")).and_then(Value::as_i64) {
match resp
.get("data")
.and_then(|d| d.get("docstatus"))
.and_then(Value::as_i64)
{
Some(1) | None => Ok(1),
Some(other) => Err(ErpError::protocol(format!(
"ERPNext accepted the submit request but the document is still at docstatus {other}."
@@ -734,15 +842,32 @@ async fn submit_remote(http: &ErpClient, cfg: &ErpnextConfig, name: &str) -> Res
fn attachment_file_name(number: &str) -> String {
let cleaned: String = number
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'-'
}
})
.collect();
let cleaned = cleaned.trim_matches('-');
format!("{}.pdf", if cleaned.is_empty() { "invoice" } else { cleaned })
format!(
"{}.pdf",
if cleaned.is_empty() {
"invoice"
} else {
cleaned
}
)
}
/// True when the document already carries a file of this name. Used when the local row lost its attachment
/// hash (a re-adopted document), so the same PDF is not attached twice.
async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) -> Result<bool, ErpError> {
async fn has_attachment(
http: &ErpClient,
remote_name: &str,
file_name: &str,
) -> Result<bool, ErpError> {
let rows = http
.list_resource(
"File",
@@ -759,9 +884,19 @@ async fn has_attachment(http: &ErpClient, remote_name: &str, file_name: &str) ->
}
/// `adopted`: the document was found, not created by this push, and nothing is recorded as attached.
async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf, adopted: bool) -> Result<(), ErpError> {
async fn attach_pdf(
http: &ErpClient,
l: &Loaded,
remote_name: &str,
pdf: &Pdf,
adopted: bool,
) -> Result<(), ErpError> {
let file_name = attachment_file_name(&l.invoice.number);
if adopted && has_attachment(http, remote_name, &file_name).await.unwrap_or(false) {
if adopted
&& has_attachment(http, remote_name, &file_name)
.await
.unwrap_or(false)
{
return Ok(());
}
let fields = [
@@ -772,14 +907,24 @@ async fn attach_pdf(http: &ErpClient, l: &Loaded, remote_name: &str, pdf: &Pdf,
let resp = http
.post_file(
&["api", "method", "upload_file"],
&Upload { file_name: &file_name, mime: "application/pdf", bytes: &pdf.bytes, fields: &fields },
&Upload {
file_name: &file_name,
mime: "application/pdf",
bytes: &pdf.bytes,
fields: &fields,
},
)
.await?;
let ok = resp.get("message").map(|m| m.get("name").is_some() || m.get("file_url").is_some()).unwrap_or(false);
let ok = resp
.get("message")
.map(|m| m.get("name").is_some() || m.get("file_url").is_some())
.unwrap_or(false);
if ok {
Ok(())
} else {
Err(ErpError::protocol("ERPNext did not confirm the PDF upload."))
Err(ErpError::protocol(
"ERPNext did not confirm the PDF upload.",
))
}
}
@@ -797,7 +942,14 @@ struct Progress {
warnings: Vec<String>,
}
fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, status: &str, error: &str) -> Result<(), ErpError> {
fn persist(
db: &Db,
invoice_id: i64,
prev: Option<&SyncRow>,
st: &Progress,
status: &str,
error: &str,
) -> Result<(), ErpError> {
let synced_at = if status == "synced" {
Some(chrono::Utc::now().to_rfc3339())
} else {
@@ -818,7 +970,12 @@ fn persist(db: &Db, invoice_id: i64, prev: Option<&SyncRow>, st: &Progress, stat
/// The message shown to the user: which step failed, then the error's own readable text. Local refusals and
/// conflicts already say what is wrong and get no prefix.
fn failure_text(step: &str, e: &ErpError) -> String {
if step.is_empty() || matches!(e.kind, ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict) {
if step.is_empty()
|| matches!(
e.kind,
ErrorKind::Config | ErrorKind::Precondition | ErrorKind::Conflict
)
{
e.to_string()
} else if e.kind == ErrorKind::Validation && e.message.contains("cannot be a fraction") {
// ERPNext names the row and the UOM already; say what to change in Voiced's settings.
@@ -828,7 +985,13 @@ fn failure_text(step: &str, e: &ErpError) -> String {
}
}
async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st: &mut Progress) -> Result<(), ErpError> {
async fn run_push(
db: &Db,
http: &ErpClient,
l: &Loaded,
want_submit: bool,
st: &mut Progress,
) -> Result<(), ErpError> {
let inv = &l.invoice;
let cfg = &l.cfg;
let prev = l.sync.as_ref();
@@ -882,7 +1045,10 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
}
None => {
st.payload_hash = hash;
if cfg.naming_mode == NamingMode::Mirror && l.india_compliance && !ic_number_ok(&inv.number) {
if cfg.naming_mode == NamingMode::Mirror
&& l.india_compliance
&& !ic_number_ok(&inv.number)
{
return Err(pre(format!(
"Invoice number {} is longer than 16 characters or has characters India Compliance refuses (letters, digits, - and / only), so ERPNext would reject it. Use the ERPNext series naming mode, or start a new Voiced series with a shorter prefix.",
inv.number
@@ -911,7 +1077,11 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
if let (Some(w), None) = (&l.pdf_warning, &l.pdf) {
st.warnings.push(w.clone());
}
if existing.is_some() && prev.is_some_and(|p| p.status == "synced") && !need_submit && pdf.is_none() {
if existing.is_some()
&& prev.is_some_and(|p| p.status == "synced")
&& !need_submit
&& pdf.is_none()
{
st.no_op = true;
return Ok(());
}
@@ -933,7 +1103,13 @@ async fn run_push(db: &Db, http: &ErpClient, l: &Loaded, want_submit: bool, st:
Ok(())
}
pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_id: i64, submit: Option<bool>) -> PushResult {
pub async fn push_invoice(
db: &Db,
local_dir: &Path,
http: &ErpClient,
invoice_id: i64,
submit: Option<bool>,
) -> PushResult {
let loaded = match load_for_push(db, local_dir, invoice_id) {
Ok(l) => l,
Err((number, e)) => return PushResult::refused(invoice_id, &number, e),
@@ -946,7 +1122,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
let (error, status): (Option<(ErpError, String)>, &str) = match outcome {
Ok(()) => {
let write = if st.no_op { Ok(()) } else { persist(db, invoice_id, prev, &st, "synced", "") };
let write = if st.no_op {
Ok(())
} else {
persist(db, invoice_id, prev, &st, "synced", "")
};
match write {
Ok(()) => (None, "synced"),
Err(e) => {
@@ -957,7 +1137,11 @@ pub async fn push_invoice(db: &Db, local_dir: &Path, http: &ErpClient, invoice_i
}
Err(e) => {
let text = failure_text(st.step, &e);
let status = if e.kind == ErrorKind::Conflict { "conflict" } else { "error" };
let status = if e.kind == ErrorKind::Conflict {
"conflict"
} else {
"error"
};
// Best effort: the original error is what the caller needs to see.
let _ = persist(db, invoice_id, prev, &st, status, &text);
(Some((e, text)), status)
@@ -1022,23 +1206,44 @@ pub struct PaymentEntryInput<'a> {
/// `difference_amount` is 0, `total_allocated_amount = cash + TDS` and the invoice's outstanding drops by
/// cash + TDS. A partial payment without TDS leaves the rest outstanding.
pub fn build_payment_entry(draft: &Value, p: &PaymentEntryInput) -> Result<Value, String> {
let mut doc: Map<String, Value> = draft.as_object().cloned().ok_or("ERPNext returned an unexpected payment draft.")?;
let mut doc: Map<String, Value> = draft
.as_object()
.cloned()
.ok_or("ERPNext returned an unexpected payment draft.")?;
doc.retain(|k, _| !k.starts_with("__"));
doc.insert("doctype".into(), json!("Payment Entry"));
doc.insert("posting_date".into(), json!(p.paid_on));
let reference = if p.reference.trim().is_empty() { format!("Voiced payment {}", p.payment_id) } else { p.reference.trim().to_string() };
let reference = if p.reference.trim().is_empty() {
format!("Voiced payment {}", p.payment_id)
} else {
p.reference.trim().to_string()
};
doc.insert("reference_no".into(), json!(reference));
doc.insert("reference_date".into(), json!(p.paid_on));
doc.insert("paid_amount".into(), mapping::money(p.cash_paise));
doc.insert("received_amount".into(), mapping::money(p.cash_paise));
doc.insert("remarks".into(), json!(format!("Voiced payment {} for invoice {}", p.payment_id, p.invoice_number)));
doc.insert(
"remarks".into(),
json!(format!(
"Voiced payment {} for invoice {}",
p.payment_id, p.invoice_number
)),
);
let allocated = mapping::money(p.cash_paise + p.tds_paise);
let refs = doc.get_mut("references").and_then(Value::as_array_mut).ok_or("ERPNext returned no invoice reference for this payment.")?;
let refs = doc
.get_mut("references")
.and_then(Value::as_array_mut)
.ok_or("ERPNext returned no invoice reference for this payment.")?;
let target = refs
.iter_mut()
.find(|r| r.get("reference_name").and_then(Value::as_str) == Some(p.remote_invoice))
.ok_or_else(|| format!("ERPNext's payment draft does not reference {}.", p.remote_invoice))?;
.ok_or_else(|| {
format!(
"ERPNext's payment draft does not reference {}.",
p.remote_invoice
)
})?;
target["allocated_amount"] = allocated;
let deductions = if p.tds_paise > 0 {
@@ -1100,7 +1305,11 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
.map_err(|e| e.to_string())?
.ok_or_else(|| "Payment not found".to_string())?;
let number: String = conn
.query_row("SELECT number FROM invoices WHERE id = ?1", params![row.invoice_id], |r| r.get(0))
.query_row(
"SELECT number FROM invoices WHERE id = ?1",
params![row.invoice_id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
let sync = load_sync(conn, row.invoice_id)?;
let cfg = config::load(conn)?;
@@ -1144,21 +1353,33 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
));
}
if cfg.payment_bank_account.trim().is_empty() {
return fail(pre("Set the payment bank account in the ERPNext settings first."));
return fail(pre(
"Set the payment bank account in the ERPNext settings first.",
));
}
if row.tds_paise > 0 && cfg.tds_account.trim().is_empty() {
return fail(pre("This payment has TDS: set the TDS account in the ERPNext settings first."));
return fail(pre(
"This payment has TDS: set the TDS account in the ERPNext settings first.",
));
}
let query = [
("dt", DOCTYPE_INVOICE.to_string()),
("dn", remote_invoice.clone()),
("bank_account", cfg.payment_bank_account.trim().to_string()),
("party_amount", paise_to_decimal(row.amount_paise + row.tds_paise)),
(
"party_amount",
paise_to_decimal(row.amount_paise + row.tds_paise),
),
];
let draft = match http.get(&["api", "method", GET_PAYMENT_ENTRY], &query).await {
let draft = match http
.get(&["api", "method", GET_PAYMENT_ENTRY], &query)
.await
{
Ok(v) => v.get("message").cloned().unwrap_or(Value::Null),
Err(e) => return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e),
Err(e) => {
return payment_failure(payment_id, row.invoice_id, "prepare the Payment Entry", e)
}
};
let body = match build_payment_entry(
&draft,
@@ -1178,17 +1399,27 @@ pub async fn push_payment(db: &Db, http: &ErpClient, payment_id: i64) -> Payment
Err(e) => return fail(ErpError::protocol(e)),
};
// Not retried after a 5xx or timeout: it may have been created, and a second entry would double-count.
let resp = match http.post(&["api", "resource", "Payment Entry"], &body, false).await {
let resp = match http
.post(&["api", "resource", "Payment Entry"], &body, false)
.await
{
Ok(v) => v,
Err(e) => return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e),
Err(e) => {
return payment_failure(payment_id, row.invoice_id, "create the Payment Entry", e)
}
};
let Some(name) = doc_name(&resp) else {
return fail(ErpError::protocol("ERPNext did not return the new Payment Entry's name."));
return fail(ErpError::protocol(
"ERPNext did not return the new Payment Entry's name.",
));
};
if let Err(e) = with_db(db, |c| {
c.execute("UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2", params![name, payment_id])
.map(|_| ())
.map_err(|e| e.to_string())
c.execute(
"UPDATE payments SET erpnext_payment_entry = ?1 WHERE id = ?2",
params![name, payment_id],
)
.map(|_| ())
.map_err(|e| e.to_string())
}) {
return fail(e);
}
@@ -1223,7 +1454,14 @@ impl InvoiceSink for ErpnextSink<'_> {
request: PushRequest,
) -> impl std::future::Future<Output = Result<PushedInvoice, String>> + Send + 'a {
async move {
let r = push_invoice(self.db, self.local_dir, self.http, request.invoice_id, request.submit).await;
let r = push_invoice(
self.db,
self.local_dir,
self.http,
request.invoice_id,
request.submit,
)
.await;
if r.ok {
Ok(PushedInvoice {
remote_name: r.remote_name,
@@ -1244,7 +1482,10 @@ impl InvoiceSink for ErpnextSink<'_> {
async move {
let r = push_payment(self.db, self.http, request.payment_id).await;
match (r.ok, r.entry_name) {
(true, Some(name)) => Ok(PushedPayment { remote_name: name, created: !r.already_synced }),
(true, Some(name)) => Ok(PushedPayment {
remote_name: name,
created: !r.already_synced,
}),
_ => Err(r.error.unwrap_or_else(|| "The payment push failed.".into())),
}
}
File diff suppressed because it is too large Load Diff
+28 -5
View File
@@ -18,7 +18,12 @@ pub struct Reply {
}
pub fn reply(status: u16, body: Value) -> Reply {
Reply { status, headers: vec![], body: body.to_string(), delay_ms: 0 }
Reply {
status,
headers: vec![],
body: body.to_string(),
delay_ms: 0,
}
}
pub struct Mock {
@@ -33,7 +38,12 @@ pub async fn serve(script: Vec<Reply>) -> Mock {
let script = Arc::new(script);
serve_fn(move |i, _| {
let r = &script[i.min(script.len() - 1)];
Reply { status: r.status, headers: r.headers.clone(), body: r.body.clone(), delay_ms: r.delay_ms }
Reply {
status: r.status,
headers: r.headers.clone(),
body: r.body.clone(),
delay_ms: r.delay_ms,
}
})
.await
}
@@ -48,7 +58,9 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
let handler = Arc::new(handler);
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let (h, r, handler) = (h.clone(), r.clone(), handler.clone());
tokio::spawn(async move {
let mut buf = Vec::new();
@@ -97,7 +109,11 @@ pub async fn serve_fn(handler: impl Fn(usize, &str) -> Reply + Send + Sync + 'st
});
}
});
Mock { base: format!("http://127.0.0.1:{port}"), hits, requests }
Mock {
base: format!("http://127.0.0.1:{port}"),
hits,
requests,
}
}
/// Short timeouts and near-zero backoff so retry tests run in milliseconds.
@@ -113,5 +129,12 @@ pub fn fast_opts() -> ClientOptions {
}
pub fn client(mock: &Mock) -> ErpClient {
ErpClient::new(&mock.base, "thekey", &Secret::new("thesecret"), "", fast_opts()).unwrap()
ErpClient::new(
&mock.base,
"thekey",
&Secret::new("thesecret"),
"",
fast_opts(),
)
.unwrap()
}
+48 -9
View File
@@ -5,6 +5,7 @@ mod integrations;
mod logo;
mod models;
mod selftest;
mod units;
use rusqlite::Connection;
use std::path::{Path, PathBuf};
@@ -38,43 +39,80 @@ 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);
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));
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));
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));
show_startup_error(
app,
&format!("Could not create the local data directory: {e}"),
Some(&local_data_dir),
);
return Err(e.into());
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
// A reset staged by the Data tab is carried out first (it also discards any staged restore).
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
match commands::reset::apply_pending_wipe(&dirs) {
commands::reset::WipeOutcome::NothingPending => {}
commands::reset::WipeOutcome::Wiped => eprintln!("All data was reset."),
commands::reset::WipeOutcome::Failed { message } => {
eprintln!("Reset failed, it will be retried on the next start: {message}")
}
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
commands::backup::ApplyOutcome::NothingPending => {}
commands::backup::ApplyOutcome::Applied { safety_dir } => {
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
eprintln!(
"Backup restored; the previous data is in {}",
safety_dir.display()
);
}
commands::backup::ApplyOutcome::Failed { message } => {
eprintln!("Backup restore failed: {message}")
}
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
}
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));
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));
show_startup_error(
app,
&format!("Could not update stored image paths: {e}"),
Some(&data_dir),
);
return Err(e.into());
}
// Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries.
@@ -156,6 +194,7 @@ pub fn run() {
commands::backup::get_backup_status,
commands::backup::set_auto_backup,
commands::backup::restart_app,
commands::reset::reset_all_data,
commands::invoice::list_invoice_ledger,
commands::assets::import_asset,
commands::assets::save_asset_bytes,
+45 -14
View File
@@ -213,7 +213,7 @@ fn clean_alpha(img: &mut RgbaImage) {
}
/// Keep 64 levels per channel (steps of about 4/255, invisible in print). Anti-aliasing and encoder
/// noise of +-1 otherwise makes flat colours incompressible: the Test Vendor logo drops from 149 KB to 75 KB.
/// noise of +-1 otherwise makes flat colours incompressible: a sample wordmark logo drops from 149 KB to 75 KB.
fn quantize_colour(img: &mut RgbaImage) {
for p in img.pixels_mut() {
for c in 0..3 {
@@ -224,7 +224,8 @@ fn quantize_colour(img: &mut RgbaImage) {
fn encode_png(img: &DynamicImage) -> Result<Vec<u8>, LogoError> {
let mut buf = Vec::new();
let encoder = PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive);
let encoder =
PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive);
img.write_with_encoder(encoder)
.map_err(|e| LogoError::Encode(e.to_string()))?;
Ok(buf)
@@ -360,7 +361,9 @@ mod tests {
}
fn decode_png(bytes: &[u8]) -> RgbaImage {
image::load_from_memory_with_format(bytes, ImageFormat::Png).unwrap().to_rgba8()
image::load_from_memory_with_format(bytes, ImageFormat::Png)
.unwrap()
.to_rgba8()
}
#[test]
@@ -427,7 +430,8 @@ mod tests {
assert!((38..=42).contains(&m.height), "{}", m.height);
assert!(out.knockout_png.is_none());
// The input pixels are not edited: the printed corners are still the logo colour, not transparent.
let printed = image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap();
let printed =
image::load_from_memory_with_format(&out.print_png, ImageFormat::Png).unwrap();
assert!(!printed.color().has_alpha());
}
@@ -459,7 +463,10 @@ mod tests {
// Right magic, broken body.
let mut truncated = png_bytes(&alpha_logo(20, 20, (2, 2, 5, 5), [0; 3]));
truncated.truncate(30);
assert!(matches!(process_logo(&truncated), Err(LogoError::Decode(_))));
assert!(matches!(
process_logo(&truncated),
Err(LogoError::Decode(_))
));
}
#[test]
@@ -470,14 +477,23 @@ mod tests {
// A blank image 8193 px wide compresses to a few KB, so it passes the byte cap.
let wide = RgbaImage::new(8193, 2);
assert!(matches!(process_logo(&png_bytes(&wide)), Err(LogoError::Dimensions)));
assert!(matches!(
process_logo(&png_bytes(&wide)),
Err(LogoError::Dimensions)
));
}
#[test]
fn blank_images_have_no_content() {
assert!(matches!(process_logo(&png_bytes(&RgbaImage::new(40, 40))), Err(LogoError::Empty)));
assert!(matches!(
process_logo(&png_bytes(&RgbaImage::new(40, 40))),
Err(LogoError::Empty)
));
let white = RgbaImage::from_pixel(40, 40, Rgba([255, 255, 255, 255]));
assert!(matches!(process_logo(&png_bytes(&white)), Err(LogoError::Empty)));
assert!(matches!(
process_logo(&png_bytes(&white)),
Err(LogoError::Empty)
));
}
#[test]
@@ -508,9 +524,15 @@ mod tests {
assert!((m.ink_density - 0.52).abs() < 1e-9, "{}", m.ink_density);
let square = alpha_logo(50, 50, (10, 10, 30, 30), [0; 3]);
assert_eq!(process_logo(&png_bytes(&square)).unwrap().metrics.kind, LogoKind::Mark);
assert_eq!(
process_logo(&png_bytes(&square)).unwrap().metrics.kind,
LogoKind::Mark
);
let tall = alpha_logo(50, 100, (10, 10, 12, 40), [0; 3]);
assert_eq!(process_logo(&png_bytes(&tall)).unwrap().metrics.kind, LogoKind::Tall);
assert_eq!(
process_logo(&png_bytes(&tall)).unwrap().metrics.kind,
LogoKind::Tall
);
assert_eq!(kind_for(2.49), LogoKind::Mark);
assert_eq!(kind_for(0.6), LogoKind::Tall);
}
@@ -535,15 +557,24 @@ mod tests {
#[test]
fn sample_logo_is_trimmed_and_small() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../public/brand/sample-wordmark-logo.png");
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../src/pdf/testing/fixtures/sample-wordmark-logo.png");
let bytes = std::fs::read(path).unwrap();
let out = process_logo(&bytes).unwrap();
let m = &out.metrics;
assert_eq!((m.source_width, m.source_height), (2172, 724));
assert!((4.5..=4.7).contains(&m.aspect), "aspect {}", m.aspect);
assert!((0.40..=0.46).contains(&m.ink_density), "density {}", m.ink_density);
assert!((5.3..=5.6).contains(&m.aspect), "aspect {}", m.aspect);
assert!(
(0.50..=0.60).contains(&m.ink_density),
"density {}",
m.ink_density
);
assert_eq!(m.kind, LogoKind::Wordmark);
assert!(out.print_png.len() <= 120 * 1024, "print is {} bytes", out.print_png.len());
assert!(
out.print_png.len() <= 120 * 1024,
"print is {} bytes",
out.print_png.len()
);
assert!(out.knockout_png.is_some());
}
}
+7 -3
View File
@@ -7,14 +7,18 @@ 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)
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()))?;
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}"))?;
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}"))
+129
View File
@@ -0,0 +1,129 @@
//! Units of measure for rate × quantity lines. Mirrors src/lib/units.ts: the ids below are stored in
//! invoices and presets, and any other text that passes `validate` is a custom unit stored as typed.
/// Built-in unit ids with the ERPNext UOM each one maps to by default. Whole-number UOMs ("Nos", "Set")
/// reject fractional quantities, so units that are usually fractional map to UOMs that allow them.
pub const KNOWN: &[(&str, &str)] = &[
("unit", "Nos"),
("piece", "Nos"),
("set", "Set"),
("session", "Nos"),
("second", "Second"),
("minute", "Minute"),
("hour", "Hour"),
("day", "Day"),
("week", "Week"),
("month", "Month"),
("km", "Kilometer"),
("m", "Meter"),
("sqft", "Square Foot"),
("sqm", "Square Meter"),
("kg", "Kg"),
("litre", "Litre"),
];
/// Longest custom unit; it lands in a measured PDF column.
pub const MAX_CUSTOM_LEN: usize = 12;
pub fn is_known(unit: &str) -> bool {
KNOWN.iter().any(|(id, _)| *id == unit)
}
/// The ERPNext UOM that suits a built-in unit.
pub fn default_uom(unit: &str) -> Option<&'static str> {
KNOWN
.iter()
.find(|(id, _)| *id == unit)
.map(|(_, uom)| *uom)
}
fn custom_problem(text: &str) -> Option<String> {
let t = text.trim();
if t.is_empty() {
return Some("a unit is required".into());
}
if t.chars().count() > MAX_CUSTOM_LEN {
return Some(format!(
"a custom unit has at most {MAX_CUSTOM_LEN} characters"
));
}
let mut chars = t.chars();
let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphanumeric());
if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '/' | '-'))
{
return Some("a unit may use letters, digits, spaces, . / and - only".into());
}
None
}
/// The unit as it is stored: trimmed, and a built-in id in lower case. An empty value is the generic unit.
pub fn normalize(unit: &str) -> String {
let t = unit.trim();
if t.is_empty() {
return "unit".to_string();
}
let lower = t.to_ascii_lowercase();
if is_known(&lower) {
lower
} else {
t.to_string()
}
}
/// Checks a unit that is about to be stored. Errors read as `Unit "x": <problem>`.
pub fn validate(unit: &str) -> Result<(), String> {
if unit.trim().is_empty() {
return Err("A unit is required".to_string());
}
if is_known(&normalize(unit)) {
return Ok(());
}
match custom_problem(unit) {
None => Ok(()),
Some(p) => Err(format!("Unit \"{}\": {p}", unit.trim())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_units_are_accepted_in_any_case() {
for (id, _) in KNOWN {
assert!(validate(id).is_ok(), "{id}");
}
assert_eq!(normalize(" HOUR "), "hour");
assert_eq!(normalize(""), "unit");
}
#[test]
fn custom_units_follow_the_same_rules_as_the_ui() {
assert!(validate("bundle").is_ok());
assert!(validate("sq.ft/day").is_ok());
assert_eq!(normalize(" bundle "), "bundle");
assert!(validate("a".repeat(13).as_str()).is_err());
assert!(validate("₹").is_err());
assert!(validate("-x").is_err());
assert!(validate("").is_err());
assert!(validate(" ").is_err());
assert!(validate("bad;drop").is_err());
}
#[test]
fn the_original_five_ids_are_unchanged() {
for id in ["second", "minute", "hour", "session", "unit"] {
assert!(is_known(id), "{id}");
}
}
#[test]
fn fractional_units_do_not_map_to_whole_number_uoms() {
for id in [
"second", "minute", "hour", "day", "week", "month", "km", "m", "sqft", "sqm", "kg",
"litre",
] {
assert_ne!(default_uom(id), Some("Nos"), "{id}");
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Voiced",
"version": "1.0.0",
"version": "1.2.0",
"identifier": "com.bongbetic.voiced",
"build": {
"beforeDevCommand": "npm run dev",
+110 -5
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
import { open, save } from "@tauri-apps/plugin-dialog";
import { Button, InlineLoading, InlineNotification, Modal, TextInput, Tile, Toggle } from "@carbon/react";
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import type { Settings } from "../lib/types";
import {
backupFileName,
ensureZipExtension,
@@ -10,6 +11,7 @@ import {
type BackupStatus,
type PendingRestore,
} from "../lib/backup";
import { clearVoicedLocalState, isResetConfirmed, RESET_WORD } from "../lib/reset";
import { useReturnFocus } from "../hooks/useReturnFocus";
import { useToast } from "./ToastProvider";
@@ -20,8 +22,15 @@ type RestoreStep = "confirm" | "staging" | "ready";
const joinPath = (dir: string, name: string) =>
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
export default function DataBackupPanel() {
interface DataBackupPanelProps {
/** The saved settings; "Run setup again" writes them back with the wizard flag cleared. */
settings: Settings;
/** Called after the flag is cleared, so the app reloads settings and shows the wizard. */
onRerunSetup: () => void;
}
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup, re-running setup and a full reset. */
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null);
const [backingUp, setBackingUp] = useState(false);
@@ -30,7 +39,49 @@ export default function DataBackupPanel() {
const [staged, setStaged] = useState<PendingRestore | null>(null);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null);
const [resetOpen, setResetOpen] = useState(false);
const [resetTyped, setResetTyped] = useState("");
const [resetting, setResetting] = useState(false);
useReturnFocus(restorePath !== null || resetOpen);
const rerunSetup = async () => {
const ok = await confirm(
"Open the setup wizard again? Your invoices, clients and backups are kept, and the wizard starts with your current details.",
{ title: "Run setup again", kind: "warning" },
);
if (!ok) return;
try {
await api.saveSettings({ ...settings, onboarded: false });
onRerunSetup();
} catch (e) {
toast.error("Could not start setup", String(e));
}
};
const closeReset = () => {
if (resetting) return;
setResetOpen(false);
setResetTyped("");
};
// Stage the wipe, clear this webview's own saved state, then restart; the next start deletes the data.
const resetAll = async () => {
setResetting(true);
try {
await api.resetAllData();
} catch (e) {
setResetting(false);
toast.error("Could not reset", String(e));
return;
}
clearVoicedLocalState();
try {
await api.restartApp();
} catch (e) {
setResetting(false);
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the reset.`);
}
};
const refresh = useCallback(async () => {
try {
@@ -229,6 +280,60 @@ export default function DataBackupPanel() {
</div>
</Tile>
<Tile>
<h2 className="voiced-section-title">Setup wizard</h2>
<p className="voiced-muted">
Run the first-time setup again to change your business details, logo, bank account and numbering in one go. Existing
invoices, clients and backups are not touched, and invoice numbering carries on unless you change the prefix.
</p>
<div className="voiced-actions">
<Button size="md" kind="tertiary" onClick={() => void rerunSetup()}>
Run setup again
</Button>
</div>
</Tile>
<Tile>
<h2 className="voiced-section-title">Reset all data</h2>
<p className="voiced-muted">
Permanently deletes every invoice, client, payment, your business details, archived PDFs and all backups, then starts Voiced
as if it were new.
</p>
<div className="voiced-actions">
<Button size="md" kind="danger--tertiary" onClick={() => setResetOpen(true)} disabled={resetOpen}>
Reset all data…
</Button>
</div>
</Tile>
<Modal
open={resetOpen}
danger
modalHeading="Reset all data?"
primaryButtonText={resetting ? "Resetting…" : "Delete everything and restart"}
secondaryButtonText="Cancel"
primaryButtonDisabled={!isResetConfirmed(resetTyped) || resetting}
onRequestClose={closeReset}
onRequestSubmit={() => void (isResetConfirmed(resetTyped) ? resetAll() : undefined)}
onSecondarySubmit={closeReset}
>
<div className="voiced-stack">
<p>
All invoices, clients, payments, vendor details, settings, logo and signature images, imported fonts, archived PDFs and
every backup (automatic, manual copies kept in Voiced&rsquo;s folder, and safety copies) will be permanently deleted. This
cannot be undone.
</p>
<p>Backup files you saved elsewhere are not touched. Voiced restarts and shows the setup wizard.</p>
<TextInput
id="resetConfirm"
labelText={`Type ${RESET_WORD} to confirm`}
value={resetTyped}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResetTyped(e.target.value)}
autoComplete="off"
/>
</div>
</Modal>
<Modal
open={restorePath !== null}
danger={step === "confirm"}
+3 -2
View File
@@ -35,6 +35,7 @@ import {
type FormProblems,
} from "../lib/erpnextUi";
import { useToast } from "./ToastProvider";
import { unitLabel } from "../lib/units";
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
@@ -496,12 +497,12 @@ export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
/>
<fieldset className="voiced-erp__uoms">
<legend>Units of measure</legend>
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p>
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities. A custom unit typed on an invoice is sent as Nos. Month is not a stock ERPNext unit: create it there or pick another.</p>
{UOM_UNITS.map((unit) => (
<PickField
key={unit}
id={`erp-uom-${unit}`}
label={`Voiced ${unit}`}
label={`Voiced ${unitLabel(unit).toLowerCase()}`}
value={form.uomMap[unit] ?? ""}
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
items={opts.uoms}
+51
View File
@@ -0,0 +1,51 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
/** What to show instead of the children. Without it, a full-window notice with a Reload button. */
fallback?: (error: Error, reset: () => void) => ReactNode;
}
interface State {
error: Error | null;
}
/**
* React unmounts the whole tree when a component throws and nothing catches it, which leaves a blank window that
* looks like a hang. This keeps the failure to the part that threw (or, at the top, shows what happened and a way back).
* Plain elements on purpose: the component library may be what threw.
*/
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Voiced UI error:", error, info.componentStack);
}
reset = () => this.setState({ error: null });
render() {
const { error } = this.state;
if (!error) return this.props.children;
if (this.props.fallback) return this.props.fallback(error, this.reset);
return (
<div
role="alert"
style={{ padding: "2rem", maxWidth: "40rem", margin: "0 auto", color: "var(--cds-text-primary, #161616)", background: "var(--cds-background, #f4f4f4)", minHeight: "100vh" }}
>
<h1 style={{ fontSize: "1.5rem", marginBottom: "0.75rem" }}>Something went wrong</h1>
<p style={{ marginBottom: "1rem" }}>
Voiced hit an unexpected error. Your invoices and drafts are saved. Reload to carry on.
</p>
<pre style={{ whiteSpace: "pre-wrap", fontSize: "0.8125rem", marginBottom: "1.5rem" }}>{error.message}</pre>
<button type="button" className="cds--btn cds--btn--primary" onClick={() => window.location.reload()}>
Reload Voiced
</button>
</div>
);
}
}
+23 -18
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification } from "@carbon/react";
import { ActionableNotification, Button, InlineLoading, InlineNotification } from "@carbon/react";
import { api } from "../lib/api";
import type { InvoiceExport } from "../hooks/useInvoiceExport";
import FlattenProgressModal from "./FlattenProgressModal";
@@ -37,13 +37,18 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
{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>
// Carbon throws if a notification has buttons inside it, and an uncaught throw blanks the whole window.
// A single action goes through ActionableNotification's own button instead.
<ActionableNotification
inline
kind="error"
lowContrast
title="The PDF was not exported"
subtitle={error.message}
actionButtonLabel="Retry"
onActionButtonClick={exp.retry}
onCloseButtonClick={exp.dismiss}
/>
) : null}
{result ? (
@@ -54,16 +59,16 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
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>
/>
{/* Beside the notification, not inside it: Carbon throws on interactive children. */}
<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>
{result.identicalToIssued === false ? (
<InlineNotification
kind="warning"
+9 -13
View File
@@ -18,7 +18,9 @@ import {
} from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api";
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
import { blankPreset, type ItemPreset, type LineMode } from "../lib/types";
import { isValidUnit, ratePerText } from "../lib/units";
import UnitPicker from "./UnitPicker";
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
import { formatAmount } from "../lib/format";
import { useToast } from "./ToastProvider";
@@ -61,9 +63,12 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
if (!editing) return;
setTouched(true);
if (!editing.description.trim()) return;
if (editing.mode === "rate" && !isValidUnit(editing.unit)) return;
setBusy(true);
try {
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
// A fixed-amount preset has no unit of its own; whatever the picker last held must not block the save.
const unit = isValidUnit(editing.unit) ? editing.unit : "unit";
await api.saveItemPreset({ ...editing, unit, description: editing.description.trim() });
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
setEditing(null);
await load();
@@ -123,7 +128,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
<TableRow key={p.id ?? p.description}>
<TableCell>{p.description}</TableCell>
<TableCell>{p.hsnSac || "—"}</TableCell>
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell>
<TableCell>{p.mode === "rate" ? ratePerText(p.unit) : "Fixed amount"}</TableCell>
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
<TableCell>
<div className="voiced-header-action">
@@ -199,16 +204,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
</Column>
{editing.mode === "rate" ? (
<Column sm={4} md={4} lg={8}>
<Select
id="ip-unit"
labelText="Unit"
value={editing.unit}
onChange={(e) => set("unit", e.target.value as LineUnit)}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
<UnitPicker id="ip-unit" value={editing.unit} onChange={(unit) => set("unit", unit)} />
</Column>
) : null}
<Column sm={4} md={4} lg={8}>
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from "react";
import { TextInput } from "@carbon/react";
import { formatQuantity, knownUnit, parseQuantity } from "../lib/units";
export interface QuantityInputProps {
id: string;
value: number;
unit: string;
onChange: (quantity: number) => void;
disabled?: boolean;
/** Reports whether the text is a quantity, so the form can refuse to issue with a bad one. */
onValidity?: (valid: boolean) => void;
}
/**
* Quantity of a rate line. Takes decimals for any unit, and for time units also `4:30` (4 h 30 min),
* showing the decimal it counts as. The text is the user's own while they type.
*/
export default function QuantityInput({ id, value, unit, onChange, disabled, onValidity }: QuantityInputProps) {
const [text, setText] = useState(() => formatQuantity(value, unit));
// What the box last reported, so a change that came from outside (a preset, a draft) can tell itself apart.
const reported = useRef({ value, unit });
useEffect(() => {
if (reported.current.value === value && reported.current.unit === unit) return;
reported.current = { value, unit };
const next = formatQuantity(value, unit);
setText(next);
// What is stored must be what the box says: a time kept as 4:20 has no exact decimal form in another unit.
const again = parseQuantity(next, unit);
if (again.ok && Math.abs(again.value - value) > 1e-9) {
reported.current = { value: again.value, unit };
onChange(again.value);
}
// onChange is the parent's setter; only a new value or unit should re-sync the text.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, unit]);
const parsed = parseQuantity(text, unit);
const clock = knownUnit(unit)?.clock;
const validity = useRef(onValidity);
useEffect(() => {
validity.current = onValidity;
});
// Reported while this box exists; a box that goes away (its line was removed) stops counting as invalid.
useEffect(() => {
const report = validity.current;
report?.(parsed.ok);
return () => report?.(true);
}, [parsed.ok]);
const helper = parsed.ok && text.includes(":") ? `= ${formatQuantity(parsed.value, "unit")} ${knownUnit(unit)?.short ?? ""}`.trim() : undefined;
return (
<TextInput
id={id}
disabled={disabled}
labelText="Quantity"
inputMode="decimal"
autoComplete="off"
placeholder={clock === "hours" ? "e.g. 4:30 or 4.5" : undefined}
value={text}
invalid={!parsed.ok}
invalidText={parsed.ok ? "" : parsed.error}
helperText={helper}
onChange={(e) => {
const next = e.target.value;
setText(next);
const r = parseQuantity(next, unit);
if (r.ok) {
reported.current = { value: r.value, unit };
onChange(r.value);
}
}}
/>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from "react";
import { Select, SelectItem, SelectItemGroup, TextInput } from "@carbon/react";
import { MAX_CUSTOM_UNIT_LENGTH, UNITS, UNIT_GROUPS, isKnownUnit, validateCustomUnit } from "../lib/units";
const OTHER = "__other__";
export interface UnitPickerProps {
id: string;
value: string;
onChange: (unit: string) => void;
disabled?: boolean;
labelText?: string;
}
/** The unit of a rate line: a grouped list of built-in units plus "Other…", which takes free text. */
export default function UnitPicker({ id, value, onChange, disabled, labelText = "Unit" }: UnitPickerProps) {
// "Other…" can be chosen before any text is typed, so the mode is kept apart from the stored value.
const [other, setOther] = useState(() => !isKnownUnit(value));
const custom = other || !isKnownUnit(value);
const problem = custom ? validateCustomUnit(value) : null;
// A unit that arrives from outside (another preset or draft in the same row) decides the mode again; one this picker
// just reported does not, so typing "m" on the way to "minute" does not flip the box back to the list.
const reported = useRef(value);
useEffect(() => {
if (value === reported.current) return;
reported.current = value;
setOther(!isKnownUnit(value));
}, [value]);
const emit = (unit: string) => {
reported.current = unit;
onChange(unit);
};
return (
<>
<Select
id={id}
disabled={disabled}
labelText={labelText}
value={custom ? OTHER : value}
onChange={(e) => {
if (e.target.value === OTHER) {
setOther(true);
emit("");
} else {
setOther(false);
emit(e.target.value);
}
}}
>
{UNIT_GROUPS.map((group) => (
<SelectItemGroup key={group} label={group}>
{UNITS.filter((u) => u.group === group).map((u) => (
<SelectItem key={u.id} value={u.id} text={u.label} />
))}
</SelectItemGroup>
))}
<SelectItem value={OTHER} text="Other…" />
</Select>
{custom ? (
<TextInput
id={`${id}-custom`}
disabled={disabled}
labelText="Custom unit"
hideLabel
placeholder="e.g. bundle"
maxLength={MAX_CUSTOM_UNIT_LENGTH}
value={value}
invalid={Boolean(problem)}
invalidText={problem ?? ""}
onChange={(e) => emit(e.target.value)}
/>
) : null}
</>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
/**
* Carbon's notifications throw "component should have no interactive child nodes" when a button or link sits
* inside them, and a throw in an effect unmounts the whole app: the window goes blank (this made every successful
* export look like a freeze). There is no DOM test setup here, so this reads the sources instead. Put the
* buttons beside the notification, or use ActionableNotification's own action.
*/
const SRC = path.resolve(process.cwd(), "src");
const NOTIFICATIONS = ["InlineNotification", "ActionableNotification", "ToastNotification", "Callout"];
const INTERACTIVE = /<(Button|IconButton|button|a|Link|TextInput|Select|Checkbox|Toggle|OverflowMenu|ComboBox|Dropdown)\b/;
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return name === "pdf" ? [] : sourceFiles(full);
return /\.tsx$/.test(name) && !/\.test\.tsx$/.test(name) ? [full] : [];
});
}
/** Bodies of the notifications that have children, with the line they start on. */
function notificationBodies(source: string): Array<{ tag: string; line: number; body: string }> {
const out: Array<{ tag: string; line: number; body: string }> = [];
for (const tag of NOTIFICATIONS) {
const open = new RegExp(`<${tag}\\b`, "g");
for (let m = open.exec(source); m; m = open.exec(source)) {
let i = m.index + m[0].length;
let braces = 0;
for (; i < source.length; i++) {
const c = source[i];
if (c === "{") braces++;
else if (c === "}") braces--;
else if (c === ">" && braces === 0) break;
}
if (source[i - 1] === "/") continue; // self-closing: no children
const end = source.indexOf(`</${tag}>`, i);
out.push({ tag, line: source.slice(0, m.index).split("\n").length, body: source.slice(i + 1, end < 0 ? undefined : end) });
}
}
return out;
}
describe("Carbon notifications", () => {
it("never wrap an interactive element", () => {
const offenders: string[] = [];
for (const file of sourceFiles(SRC)) {
for (const { tag, line, body } of notificationBodies(readFileSync(file, "utf8"))) {
if (INTERACTIVE.test(body)) offenders.push(`${path.relative(SRC, file)}:${line} <${tag}>`);
}
}
expect(offenders).toEqual([]);
});
it("the scan finds an offender when there is one", () => {
const bad = `<InlineNotification kind="success" title="x">\n <Button>Open</Button>\n</InlineNotification>`;
const fine = `<InlineNotification kind="success" title="x" />\n<Button>Open</Button>`;
expect(notificationBodies(bad).some((n) => INTERACTIVE.test(n.body))).toBe(true);
expect(notificationBodies(fine)).toEqual([]);
});
});
+2
View File
@@ -122,6 +122,8 @@ export const api = {
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
restartApp: () => invoke<void>("restart_app"),
// Stages a wipe of all data; it is carried out by the next start (see commands/reset.rs), so restart afterwards.
resetAllData: () => invoke<void>("reset_all_data"),
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
+1 -1
View File
@@ -23,7 +23,7 @@ export interface ErpnextConfig {
paymentBankAccount: string;
tdsAccount: string;
defaultItemCode: string;
/** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */
/** Voiced unit id (see lib/units.ts) to ERPNext UOM. */
uomMap: Record<string, string>;
customerGroup: string;
territory: string;
+1 -1
View File
@@ -232,7 +232,7 @@ describe("settings form", () => {
expect(input).not.toHaveProperty("apiSecretSet");
expect(input).not.toHaveProperty("lastDetectResult");
expect(input.apiSecret).toBe("");
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" });
expect(input.uomMap).toMatchObject({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos", km: "Kilometer", sqft: "Square Foot" });
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
});
+3 -8
View File
@@ -1,4 +1,5 @@
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
import { UNITS } from "./units";
import type {
ErpnextConfig,
ErpnextConfigInput,
@@ -259,14 +260,8 @@ export function versionsLine(test: ErpnextConnectionTest): string {
// ---- settings form ----
export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const;
export const DEFAULT_UOM_MAP: Record<string, string> = {
second: "Second",
minute: "Minute",
hour: "Hour",
session: "Nos",
unit: "Nos",
};
export const UOM_UNITS: readonly string[] = UNITS.map((u) => u.id);
export const DEFAULT_UOM_MAP: Record<string, string> = Object.fromEntries(UNITS.map((u) => [u.id, u.uom]));
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
+14
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { computeLineAmount, computeTotals } from "./invoice";
import { EMPTY_ITEM, type InvoiceItem } from "./types";
import { parseQuantity } from "./units";
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
// and the stored totals stay identical.
@@ -40,6 +41,19 @@ describe("computeTotals", () => {
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
});
it("counts a typed time as its decimal and rounds once, like Rust (units test: lines_can_use_any_unit...)", () => {
const q = (text: string, unit: string) => {
const r = parseQuantity(text, unit);
if (!r.ok) throw new Error(r.error);
return r.value;
};
expect(computeLineAmount(rate(1200, q("4:30", "hour")))).toBe(5400);
// 4 h 20 min is 4.333...; 1200 x that is 5200.00 only if the exact value is kept, not 4.33.
expect(computeLineAmount(rate(1200, q("4:20", "hour")))).toBe(5200);
expect(computeLineAmount({ ...rate(18, q("12.75", "km")), unit: "km" })).toBe(229.5);
expect(computeLineAmount(rate(99.5, 3))).toBe(298.5);
});
it("clamps the discount to the subtotal", () => {
const items = [fixed(100), fixed(50.5)];
const over = computeTotals(items, 999.99, "igst", 18);
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearVoicedLocalState, isResetConfirmed } from "./reset";
describe("isResetConfirmed", () => {
it("accepts the word in any case with surrounding spaces", () => {
expect(isResetConfirmed("RESET")).toBe(true);
expect(isResetConfirmed(" reset ")).toBe(true);
});
it("rejects anything else", () => {
expect(isResetConfirmed("")).toBe(false);
expect(isResetConfirmed("rese")).toBe(false);
expect(isResetConfirmed("reset all")).toBe(false);
});
});
describe("clearVoicedLocalState", () => {
// The node test environment has no localStorage, so stub a minimal one.
beforeEach(() => {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
get length() {
return data.size;
},
key: (i: number) => [...data.keys()][i] ?? null,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
});
});
afterEach(() => vi.unstubAllGlobals());
it("removes only the app's own keys", () => {
localStorage.setItem("voiced.theme", "g100");
localStorage.setItem("voiced.settingsTab", "data");
localStorage.setItem("other.key", "keep");
clearVoicedLocalState();
expect(localStorage.getItem("voiced.theme")).toBeNull();
expect(localStorage.getItem("voiced.settingsTab")).toBeNull();
expect(localStorage.getItem("other.key")).toBe("keep");
});
});
+18
View File
@@ -0,0 +1,18 @@
/** The word typed to enable "Reset all data". */
export const RESET_WORD = "RESET";
export const isResetConfirmed = (typed: string): boolean => typed.trim().toUpperCase() === RESET_WORD;
/** Removes every `voiced.*` key this app keeps in the webview's localStorage (theme, last tab, export mode). */
export function clearVoicedLocalState(): void {
try {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (key && key.startsWith("voiced.")) keys.push(key);
}
keys.forEach((key) => localStorage.removeItem(key));
} catch {
// Storage can be blocked; there is then nothing to clear.
}
}
+40 -6
View File
@@ -7,7 +7,8 @@ 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).
* launches with. The fresh database is neutral (no vendor, no bank), so the first step provisions a
* fake vendor and bank the way the setup wizard would.
*/
const today = () => {
@@ -28,11 +29,32 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
let bank!: BankAccount;
let invoice: Invoice | null = null;
const loaded = await log.run("e2e: get settings and default bank", async () => {
const loaded = await log.run("e2e: provision vendor and bank, get settings", async () => {
s = await api.getSettings();
const banks = await api.listBanks();
let banks = await api.listBanks();
if (banks.length === 0) {
const saved = await api.saveBank({
id: null,
label: "Primary",
bankName: "Self-test Bank",
accountName: "Self-test Vendor",
accountNo: "000000000000",
branch: "000001",
ifsc: "TEST0000001",
isDefault: true,
});
s = await api.saveSettings({
...s,
vendorName: s.vendorName || "Self-test Vendor",
vendorAddress: s.vendorAddress || "1 Test Road, Bengaluru - 560001",
vendorStateCode: s.vendorStateCode || "29",
defaultBankId: saved.id,
onboarded: true,
});
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");
if (!found) throw new Error("no bank account after provisioning");
bank = found;
return `${s.vendorName}, ${s.gstRegistration}, state ${s.vendorStateCode}, bank "${bank.label}"`;
});
@@ -80,7 +102,6 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
},
);
let inv!: Invoice;
const issued = await log.run("e2e: issue and archive", async () => {
const outcome = await issueAndArchive(input, prefs, deps);
invoice = outcome.invoice;
@@ -90,7 +111,7 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
};
});
if (!issued.ok || !invoice) return;
inv = invoice as Invoice;
const inv = invoice as Invoice;
await log.run("e2e: archive status", async () => {
const st = await api.archiveStatus(inv.id);
@@ -103,6 +124,8 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` };
});
await log.run("e2e: export notices render without crashing the window", async () => (await import("./selfTestUi")).checkExportFeedbackRenders());
await log.run("e2e: export flattened original", async () => {
const r = await exportWith("flattened", "original", flattenedPath);
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` };
@@ -136,6 +159,17 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
};
});
await log.run("e2e: a new invoice can be issued and exported after the first", async () => {
const first = inv.number;
const outcome = await issueAndArchive({ ...input, notes: "Self-test invoice 2" }, prefs, deps);
const next = `${outDir}/e2e-second.pdf`;
const r = await exportInvoice({ invoice: outcome.invoice, mode: "searchable", source: "original" }, { ...deps, save: async () => next });
return {
ok: outcome.archived && outcome.invoice.number !== first && r?.path === next,
detail: `${first}, then ${outcome.invoice.number} (archived=${outcome.archived}), exported ${r?.path}`,
};
});
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);
+59
View File
@@ -0,0 +1,59 @@
import { createRoot } from "react-dom/client";
import { Theme } from "@carbon/react";
import ExportFeedback from "../components/ExportFeedback";
import type { InvoiceExport } from "../hooks/useInvoiceExport";
import type { Invoice } from "./types";
/**
* Part of the real-webview self-test: mounts the export notices the way the app does and fails if React reports an
* uncaught error. Carbon throws when a notification has a button inside it, and that took the whole window
* down after every successful export; nothing that runs without a real DOM can see it.
*/
const noop = () => {};
const idle: InvoiceExport = {
busy: false,
progress: null,
request: null,
error: null,
result: null,
start: async () => null,
cancel: noop,
retry: noop,
exportSearchableInstead: noop,
dismiss: noop,
};
const CASES: Array<{ name: string; expect: string; exp: InvoiceExport }> = [
{
name: "saved",
expect: "Saved to",
exp: { ...idle, result: { path: "/tmp/voiced-selftest.pdf", mode: "searchable", invoice: {} as Invoice, identicalToIssued: false, archiveError: "not archived" } },
},
{ name: "failed", expect: "was not exported", exp: { ...idle, error: { message: "disk full", flatten: false } } },
];
export async function checkExportFeedbackRenders(): Promise<{ ok: boolean; detail: string }> {
const problems: string[] = [];
for (const c of CASES) {
const host = document.createElement("div");
document.body.appendChild(host);
const errors: string[] = [];
const root = createRoot(host, {
onUncaughtError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
onRecoverableError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
});
root.render(
<Theme theme="g10">
<ExportFeedback exp={c.exp} />
</Theme>,
);
// Effects (where Carbon checks its children) run after the first paint.
await new Promise((r) => setTimeout(r, 400));
if (errors.length > 0) problems.push(`${c.name}: ${errors.join("; ").slice(0, 300)}`);
else if (!(host.textContent ?? "").includes(c.expect)) problems.push(`${c.name}: the notice did not render`);
root.unmount();
host.remove();
}
return { ok: problems.length === 0, detail: problems.join(" | ") || "the saved and failed notices render without throwing" };
}
+3 -9
View File
@@ -1,6 +1,7 @@
export type TaxType = "none" | "cgst_sgst" | "igst";
export type LineMode = "fixed" | "rate";
export type LineUnit = "second" | "minute" | "hour" | "session" | "unit";
/** A built-in unit id (see lib/units.ts) or, for a custom unit, the text the vendor typed. */
export type LineUnit = string;
export interface Settings {
vendorName: string;
@@ -273,7 +274,7 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "09", name: "Uttar Pradesh" },
{ code: "10", name: "Bihar" },
{ code: "11", name: "Sikkim" },
{ code: "12", name: "Sampleachal Pradesh" },
{ code: "12", name: "Arunachal Pradesh" },
{ code: "13", name: "Nagaland" },
{ code: "14", name: "Manipur" },
{ code: "15", name: "Mizoram" },
@@ -302,10 +303,3 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "97", name: "Other Territory" },
];
export const UNIT_LABELS: Record<LineUnit, string> = {
second: "per second",
minute: "per minute",
hour: "per hour",
session: "per session",
unit: "per unit",
};
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import {
UNITS,
formatQuantity,
normalizeUnit,
parseQuantity,
quantityText,
ratePerText,
unitShort,
validateCustomUnit,
} from "./units";
const value = (text: string, unit: string) => {
const r = parseQuantity(text, unit);
if (!r.ok) throw new Error(r.error);
return r.value;
};
describe("parseQuantity", () => {
it("reads h:mm as hours for hour units", () => {
expect(value("4:30", "hour")).toBe(4.5);
expect(value("0:15", "hour")).toBe(0.25);
expect(value("4:20", "hour")).toBeCloseTo(4 + 1 / 3, 12);
expect(value("1:00:30", "hour")).toBeCloseTo(1 + 30 / 3600, 12);
});
it("reads m:ss as minutes for minute units", () => {
expect(value("4:30", "minute")).toBe(4.5);
expect(parseQuantity("1:00:30", "minute").ok).toBe(false);
});
it("refuses a clock quantity for other units and out-of-range parts", () => {
expect(parseQuantity("4:30", "km").ok).toBe(false);
expect(parseQuantity("4:75", "hour").ok).toBe(false);
expect(parseQuantity("4:", "hour").ok).toBe(false);
});
it("reads plain decimals for every unit", () => {
expect(value("12.75", "km")).toBe(12.75);
expect(value("120.5", "sqft")).toBe(120.5);
expect(value("3", "unit")).toBe(3);
expect(value(".5", "hour")).toBe(0.5);
expect(value("2.", "kg")).toBe(2);
expect(value("", "hour")).toBe(0);
});
it("rejects text, negatives, more than 3 decimals and huge values", () => {
for (const bad of ["abc", "-1", "1,5", "1.2345", ".", "1e3", "99999999"]) {
expect(parseQuantity(bad, "hour").ok, bad).toBe(false);
}
});
});
describe("formatQuantity", () => {
it("trims zeros", () => {
expect(formatQuantity(2, "unit")).toBe("2");
expect(formatQuantity(1.5, "km")).toBe("1.5");
expect(formatQuantity(12.75, "km")).toBe("12.75");
expect(formatQuantity(0.125, "kg")).toBe("0.125");
});
it("writes a repeating time as h:mm", () => {
expect(formatQuantity(value("4:20", "hour"), "hour")).toBe("4:20");
expect(formatQuantity(value("0:20", "hour"), "hour")).toBe("0:20");
expect(formatQuantity(value("1:00:30", "hour"), "hour")).toBe("1:00:30");
expect(formatQuantity(value("4:30", "hour"), "hour")).toBe("4.5");
});
it("round-trips whatever parseQuantity accepts", () => {
for (const [text, unit] of [["4:20", "hour"], ["7:45", "hour"], ["12.75", "km"], ["0:05", "hour"], ["3:10", "minute"]] as const) {
const v = value(text, unit);
expect(value(formatQuantity(v, unit), unit)).toBeCloseTo(v, 9);
}
});
});
describe("unit text", () => {
it("keeps the old rate wording for the original five units", () => {
expect(["second", "minute", "hour", "session", "unit"].map(ratePerText)).toEqual([
"per second",
"per minute",
"per hour",
"per session",
"per unit",
]);
});
it("shows the unit beside the quantity, but not for the generic unit", () => {
expect(quantityText(4.5, "hour")).toBe("4.5 hr");
expect(quantityText(12.75, "km")).toBe("12.75 km");
expect(quantityText(3, "unit")).toBe("3");
expect(quantityText(2, "bundle")).toBe("2 bundle");
expect(unitShort("sqft")).toBe("sq ft");
expect(ratePerText("bundle")).toBe("per bundle");
});
it("validates custom units", () => {
expect(validateCustomUnit("bundle")).toBeNull();
expect(validateCustomUnit("sq.ft/day")).toBeNull();
expect(validateCustomUnit("")).not.toBeNull();
expect(validateCustomUnit("a".repeat(13))).not.toBeNull();
expect(validateCustomUnit("₹")).not.toBeNull();
});
it("normalises stored values", () => {
expect(normalizeUnit("HOUR")).toBe("hour");
expect(normalizeUnit(" bundle ")).toBe("bundle");
expect(normalizeUnit("")).toBe("unit");
expect(normalizeUnit(undefined)).toBe("unit");
});
it("has unique ids and a short for every unit except the generic one", () => {
expect(new Set(UNITS.map((u) => u.id)).size).toBe(UNITS.length);
for (const u of UNITS) if (u.id !== "unit") expect(u.short).not.toBe("");
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Units of measure for rate × quantity lines. The ids of the first five are stored in saved invoices
* and must never change. Any other text is a custom unit and is stored as typed (see `validateCustomUnit`).
* The Rust side keeps the same rules in src-tauri/src/units.rs.
*/
export type UnitGroup = "Count" | "Time" | "Distance" | "Area" | "Weight" | "Volume";
/** How `h:mm` style quantities are read for a unit: "4:30" is 4 h 30 min for hours, 4 min 30 s for minutes. */
export type ClockKind = "hours" | "minutes";
export interface UnitDef {
id: string;
/** Shown in pickers: "Hour". */
label: string;
/** Lower-case word used after "per": "per hour". */
noun: string;
/** Short form beside a quantity: "4.5 hr". Empty for the generic "unit", which stays a bare number. */
short: string;
group: UnitGroup;
clock?: ClockKind;
/** ERPNext UOM used when the integration has no mapping of its own for this unit. */
uom: string;
}
export const UNITS: readonly UnitDef[] = [
{ id: "unit", label: "Unit", noun: "unit", short: "", group: "Count", uom: "Nos" },
{ id: "piece", label: "Piece", noun: "piece", short: "pc", group: "Count", uom: "Nos" },
{ id: "set", label: "Set", noun: "set", short: "set", group: "Count", uom: "Set" },
{ id: "session", label: "Session", noun: "session", short: "session", group: "Time", uom: "Nos" },
{ id: "second", label: "Second", noun: "second", short: "sec", group: "Time", uom: "Second" },
{ id: "minute", label: "Minute", noun: "minute", short: "min", group: "Time", clock: "minutes", uom: "Minute" },
{ id: "hour", label: "Hour", noun: "hour", short: "hr", group: "Time", clock: "hours", uom: "Hour" },
{ id: "day", label: "Day", noun: "day", short: "day", group: "Time", uom: "Day" },
{ id: "week", label: "Week", noun: "week", short: "wk", group: "Time", uom: "Week" },
{ id: "month", label: "Month", noun: "month", short: "mo", group: "Time", uom: "Month" },
{ id: "km", label: "Kilometre", noun: "km", short: "km", group: "Distance", uom: "Kilometer" },
{ id: "m", label: "Metre", noun: "m", short: "m", group: "Distance", uom: "Meter" },
{ id: "sqft", label: "Square foot", noun: "sq ft", short: "sq ft", group: "Area", uom: "Square Foot" },
{ id: "sqm", label: "Square metre", noun: "sq m", short: "sq m", group: "Area", uom: "Square Meter" },
{ id: "kg", label: "Kilogram", noun: "kg", short: "kg", group: "Weight", uom: "Kg" },
{ id: "litre", label: "Litre", noun: "litre", short: "L", group: "Volume", uom: "Litre" },
];
export const UNIT_GROUPS: readonly UnitGroup[] = ["Count", "Time", "Distance", "Area", "Weight", "Volume"];
const BY_ID = new Map(UNITS.map((u) => [u.id, u]));
export const DEFAULT_UNIT = "unit";
export const MAX_CUSTOM_UNIT_LENGTH = 12;
/** The built-in unit with this id, or undefined for a custom unit. */
export const knownUnit = (unit: string): UnitDef | undefined => BY_ID.get(unit.trim().toLowerCase());
export const isKnownUnit = (unit: string): boolean => BY_ID.has(unit.trim().toLowerCase());
/** Problem with a custom unit text, or null when it is fine. */
export function validateCustomUnit(text: string): string | null {
const t = text.trim();
if (!t) return "Enter a unit";
if (t.length > MAX_CUSTOM_UNIT_LENGTH) return `Use at most ${MAX_CUSTOM_UNIT_LENGTH} characters`;
if (!/^[A-Za-z0-9][A-Za-z0-9 ./-]*$/.test(t)) return "Use letters, digits, spaces, . / or -";
return null;
}
/** Whether a stored unit can be issued: a built-in id or a valid custom text. */
export const isValidUnit = (unit: string): boolean => isKnownUnit(unit) || validateCustomUnit(unit) === null;
/** A unit value as stored: the id for a built-in unit, the trimmed text otherwise. Falls back to the default. */
export function normalizeUnit(unit: string | null | undefined): string {
const t = (unit ?? "").trim();
if (!t) return DEFAULT_UNIT;
const lower = t.toLowerCase();
if (BY_ID.has(lower)) return lower;
return t;
}
export const unitLabel = (unit: string): string => knownUnit(unit)?.label ?? unit.trim();
/** "hr" for hours; the text itself for a custom unit; "" for the generic unit. */
export const unitShort = (unit: string): string => {
const def = knownUnit(unit);
return def ? def.short : unit.trim();
};
/** "per hour", "per sq ft", "per bundle". */
export const ratePerText = (unit: string): string => `per ${knownUnit(unit)?.noun ?? unit.trim()}`;
/** The ERPNext UOM that suits a built-in unit; undefined for a custom unit. */
export const defaultUom = (unit: string): string | undefined => knownUnit(unit)?.uom;
export type QuantityParse = { ok: true; value: number } | { ok: false; error: string };
const MAX_QUANTITY = 10_000_000;
/**
* Reads what the user typed into a quantity box. Plain decimals (up to 3 places) work for every unit.
* Hour units also take `h:mm` and `h:mm:ss`, minute units take `m:ss`, so "4:30" hours is 4.5.
* An empty box is 0, the same as before this existed.
*/
export function parseQuantity(text: string, unit: string): QuantityParse {
const t = text.trim();
if (!t) return { ok: true, value: 0 };
const clock = knownUnit(unit)?.clock;
if (t.includes(":")) {
if (!clock) return { ok: false, error: "Only time units take h:mm" };
const m = /^(\d{1,7}):(\d{1,2})(?::(\d{1,2}))?$/.exec(t);
if (!m) return { ok: false, error: clock === "hours" ? "Use h:mm, for example 4:30" : "Use m:ss, for example 4:30" };
const first = Number(m[1]);
const second = Number(m[2]);
const third = m[3] === undefined ? null : Number(m[3]);
if (second > 59 || (third !== null && third > 59)) return { ok: false, error: "Minutes and seconds go up to 59" };
if (clock === "minutes" && third !== null) return { ok: false, error: "Use m:ss, for example 4:30" };
const value = clock === "hours" ? first + second / 60 + (third ?? 0) / 3600 : first + second / 60;
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
}
const m = /^(\d{0,8})(?:\.(\d{0,3}))?$/.exec(t);
if (!m || (m[1] === "" && !m[2])) {
return { ok: false, error: /^\d*\.\d{4,}$/.test(t) ? "Use at most 3 decimal places" : "Enter a number" };
}
const value = Number(t);
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
}
const EPS = 1e-9;
function trimmed(value: number, places: number): string {
return value.toFixed(places).replace(/\.?0+$/, "");
}
/**
* A quantity as text, without the unit. Exact for anything `parseQuantity` accepts: a time that is not a
* whole number of hundredths (4 h 20 min) is written as h:mm so that quantity × rate can be checked by eye.
*/
export function formatQuantity(value: number, unit: string): string {
if (!Number.isFinite(value)) return "0";
const clock = knownUnit(unit)?.clock;
if (!clock) return trimmed(value, 3);
if (Math.abs(value * 100 - Math.round(value * 100)) < EPS * 100) return trimmed(value, 2);
const whole = Math.floor(value + EPS);
const rest = (value - whole) * 60;
const min = Math.floor(rest + EPS);
const sec = Math.round((rest - min) * 60);
const head = clock === "hours" ? `${whole}:${String(min).padStart(2, "0")}` : null;
if (clock === "hours" && sec < 60 && Math.abs(whole + min / 60 + sec / 3600 - value) < EPS) {
return sec === 0 ? head! : `${head}:${String(sec).padStart(2, "0")}`;
}
if (clock === "minutes") {
const s = Math.round((value - whole) * 60);
if (s < 60 && Math.abs(whole + s / 60 - value) < EPS) return `${whole}:${String(s).padStart(2, "0")}`;
}
return trimmed(value, 4);
}
/** The quantity as printed on an invoice: "4:20 hr", "12.75 km", or a bare "3" for the generic unit. */
export function quantityText(value: number, unit: string): string {
const q = formatQuantity(value, unit);
const short = unitShort(unit);
return short ? `${q} ${short}` : q;
}
+4 -1
View File
@@ -1,12 +1,15 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import "./styles/carbon.scss";
import { maybeRunSelfTest } from "./selftestBoot";
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);
+2 -2
View File
@@ -210,10 +210,10 @@ describe("Row", () => {
});
describe("FitText and IdText", () => {
const EMAIL60 = "Sample.longsurname.voiceover.studio.department@examplemedia.co.in".slice(0, 60);
const EMAIL60 = "anon.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";
const email = "anon.longsurname.voiceover@examplestudios.co.in";
let info!: FitInfo;
const pdf = await renderPdfText(
frame(
+9 -9
View File
@@ -15,7 +15,7 @@ const logo = (over: Partial<SlotLogoInput>): SlotLogoInput => ({
hasKnockout: true,
...over,
});
const Sample_P = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true });
const SAMPLE_LOGO = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true });
/** The box a logo is fitted to: the allowance for a text-only slot, else the lockup (or mark) box. */
const lockupTarget = (s: TemplateSlot): Box => (s.textOnly ? s.allowance : s.lockup!);
@@ -98,9 +98,9 @@ describe("computeLogoPlacement identity: the placeholder itself comes back as it
});
describe("computeLogoPlacement text-only allowances", () => {
it("Test Vendor in Monolith is 28 pt tall, about 129 wide, and keeps the right edge", () => {
it("The sample logo in Monolith is 28 pt tall, about 129 wide, and keeps the right edge", () => {
const s = SLOTS.monolith!;
const p = computeLogoPlacement({ logo: Sample_P, slot: s });
const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s });
expect(p.h).toBeCloseTo(28, 6);
expect(p.w).toBeGreaterThanOrEqual(128);
expect(p.w).toBeLessThanOrEqual(130);
@@ -117,9 +117,9 @@ describe("computeLogoPlacement text-only allowances", () => {
expect(p.x + p.w).toBeCloseTo(560, 6);
});
it("Test Vendor in Tangerine fills 36 pt of height (about 166 wide) at the left edge", () => {
it("The sample logo in Tangerine fills 36 pt of height (about 166 wide) at the left edge", () => {
const s = SLOTS["tangerine-ledger"]!;
const p = computeLogoPlacement({ logo: Sample_P, slot: s });
const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s });
expect(p.h).toBeCloseTo(36, 6);
expect(p.w).toBeCloseTo(36 * 4.62, 6);
expect(p.x).toBeCloseTo(60.5, 6);
@@ -142,13 +142,13 @@ describe("computeLogoPlacement text-only allowances", () => {
describe("computeLogoPlacement on every slot", () => {
const all: [TemplateId, TemplateSlot][] = [...measured, ...optIn];
it("Test Vendor never exceeds its box and is never heavier than the placeholder ink", () => {
it("The sample logo never exceeds its box and is never heavier than the placeholder ink", () => {
for (const [id, s] of all) {
const p = computeLogoPlacement({ logo: Sample_P, slot: s });
const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s });
expect(p.slotKind).toBe("lockup");
expect(inside(p.box, lockupTarget(s)), id).toBe(true);
if (!s.optIn && !s.textOnly) {
const ink = p.w * p.h * Sample_P.density;
const ink = p.w * p.h * SAMPLE_LOGO.density;
const placeholderInk = s.lockup!.w * s.lockup!.h * s.lockupDensity;
expect(ink, `${id}: ${ink.toFixed(1)} vs ${placeholderInk.toFixed(1)}`).toBeLessThanOrEqual(placeholderInk * 1.05);
}
@@ -265,7 +265,7 @@ describe("computeLogoPlacement page scale and available room", () => {
it("shrinks to the room the layout has", () => {
const s = SLOTS["teal-swoosh"]!;
const p = computeLogoPlacement({ logo: Sample_P, slot: s, available: { w: 80, h: 100 } });
const p = computeLogoPlacement({ logo: SAMPLE_LOGO, slot: s, available: { w: 80, h: 100 } });
expect(p.w).toBeLessThanOrEqual(80 + 1e-9);
expect(p.x).toBeCloseTo(s.lockup!.x, 6);
});
+1 -1
View File
@@ -5,7 +5,7 @@ const BOX = { w: 170, h: 54 };
const base = { box: BOX, availableWidth: 299, minTextWidth: 160 };
describe("computeClassicLogoPlacement", () => {
it("the Test Vendor trimmed 4.6:1 logo is width-limited and stacks above the text", () => {
it("the sample trimmed 4.6:1 logo is width-limited and stacks above the text", () => {
const p = computeClassicLogoPlacement({ ...base, aspect: 4.6 });
expect(p.w).toBeCloseTo(170, 6);
expect(p.h).toBeCloseTo(170 / 4.6, 6);
+2 -2
View File
@@ -12,7 +12,7 @@ const SAMPLES: string[] = [
"Voice-over session 12 - narration for module 3",
"Total ₹1,23,456.78",
"AVATAR WAVE To Ty Yo",
"vendor.name@examplestudios.co.in",
"anon.longsurname.voiceover@examplestudios.co.in",
"29ABCDE1234F1Z5",
"office fi fl ffi final",
"BILLED TO",
@@ -130,7 +130,7 @@ describe("measurer helpers", () => {
});
it("round-trips chunkIdentifier output", () => {
const id = "vendor.name@examplestudios.co.in";
const id = "anon.longsurname.voiceover@examplestudios.co.in";
expect(chunkIdentifier(id, 12).join("")).toBe(id);
});
});
+17
View File
@@ -181,6 +181,23 @@ export function getMeasurer(): Measurer {
/** The text as it will be drawn: caps applied. */
export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text);
/** A quantity with its unit ("1250.5 sq ft") is allowed this much width before the cell wraps at its spaces. */
export const QTY_WRAP_CAP = 72;
/**
* The texts a quantity column is sized from. A quantity cell can wrap between its words ("120 / wooden crate")
* but never inside one, so a text wider than `cap` counts as its widest word; the column then stays modest
* and the description keeps its room, instead of one long custom unit squeezing the table.
*/
export function wrapAwareTexts(texts: string[], role: RoleName | TypeToken, cap = QTY_WRAP_CAP): string[] {
const m = getMeasurer();
return texts.map((text) => {
if (measureToken(m, role, text) <= cap) return text;
const words = text.split(/\s+/).filter(Boolean);
return words.reduce((widest, w) => (measureToken(m, role, w) > measureToken(m, role, widest) ? w : widest), words[0] ?? text);
});
}
/** Width of `text` set in a role/token at an optional size override, tracking and caps included. */
export function measureToken(
m: Measurer,
+16 -1
View File
@@ -133,7 +133,10 @@ describe("unregistered invoice", () => {
descriptionLines: ["Voice-over", "session A"],
hsnSac: "998397",
rateText: "₹1,200.00 per hour",
qtyText: "2.5",
priceText: "₹1,200.00",
perText: "per hour",
rateShort: "₹1,200.00/hr",
qtyText: "2.5 hr",
amountText: "₹3,000.00",
});
expect(m.items[1]).toEqual({
@@ -141,10 +144,22 @@ describe("unregistered invoice", () => {
descriptionLines: ["—"],
hsnSac: null,
rateText: null,
priceText: null,
perText: null,
rateShort: null,
qtyText: "1",
amountText: "₹2,500.50",
});
});
it("words the unit and the quantity for every kind of unit", () => {
const line = (unit: string, quantity: number, rate = 18.5) =>
make({ items: [{ ...base.items[0], mode: "rate", unit, quantity, rate }] }).items[0];
expect(line("km", 12.75)).toMatchObject({ qtyText: "12.75 km", rateText: "₹18.50 per km", rateShort: "₹18.50/km", perText: "per km" });
expect(line("sqft", 1250.5)).toMatchObject({ qtyText: "1250.5 sq ft", rateShort: "₹18.50/sq ft" });
expect(line("hour", 4 + 1 / 3)).toMatchObject({ qtyText: "4:20 hr" });
expect(line("unit", 3)).toMatchObject({ qtyText: "3", rateText: "₹18.50 per unit", rateShort: "₹18.50/unit" });
expect(line(" bundle ", 2)).toMatchObject({ qtyText: "2 bundle", rateText: "₹18.50 per bundle", rateShort: "₹18.50/bundle" });
});
it("labels the bank lines and defaults the signature to the vendor name", () => {
expect(m.bank).toEqual({
accountName: "Test Vendor",
+20 -3
View File
@@ -1,7 +1,7 @@
import { computeLineAmount } from "../../lib/invoice";
import { quantityText, ratePerText, unitShort, knownUnit } from "../../lib/units";
import {
INDIAN_STATES,
UNIT_LABELS,
type BankAccount,
type Invoice,
type Settings,
@@ -48,7 +48,15 @@ export interface RenderItem {
sno: number;
descriptionLines: string[];
hsnSac: string | null;
/** "₹1,200.00 per hour"; null for a fixed-amount line. */
rateText: string | null;
/** The amount alone: "₹1,200.00". */
priceText: string | null;
/** The unit wording alone: "per hour". */
perText: string | null;
/** The one-line form: "₹1,200.00/hr". */
rateShort: string | null;
/** "4.5 hr", "12.75 km", or a bare "3" for the generic unit; "1" for a fixed-amount line. */
qtyText: string;
amountText: string;
}
@@ -140,6 +148,15 @@ function taxRowsFor(invoice: Invoice, supplierStateCode: string): TaxRow[] {
}
/** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */
function rateFields(item: Invoice["items"][number]): Pick<RenderItem, "rateText" | "priceText" | "perText" | "rateShort"> {
if (item.mode !== "rate") return { rateText: null, priceText: null, perText: null, rateShort: null };
const priceText = formatRupees(item.rate);
const perText = ratePerText(item.unit);
// The generic "unit" has no short form beside a quantity, but its rate still reads "/unit".
const suffix = unitShort(item.unit) || knownUnit(item.unit)?.noun || item.unit.trim();
return { rateText: `${priceText} ${perText}`, priceText, perText, rateShort: `${priceText}/${suffix}` };
}
export function buildRenderModel(input: BuildInput): RenderModel {
const { invoice, logo, signatureDataUri, appVersion } = input;
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor);
@@ -164,8 +181,8 @@ export function buildRenderModel(input: BuildInput): RenderModel {
return lines.length ? lines : ["—"];
})(),
hsnSac: orNull(item.hsnSac),
rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null,
qtyText: item.mode === "rate" ? String(item.quantity) : "1",
...rateFields(item),
qtyText: item.mode === "rate" ? quantityText(item.quantity, item.unit) : "1",
amountText: formatRupees(computeLineAmount(item)),
}));
+1
View File
@@ -22,6 +22,7 @@ export function sanitize(value: string | null | undefined): string {
.replace(/\r\n?/g, "\n")
.replace(/[\t\n\v\f]/g, " ")
.replace(/ /g, " ")
// eslint-disable-next-line no-control-regex -- stripping control characters is the point
.replace(/[\u0000-\u001F\u007F-\u009F​]/g, "")
.replace(/ {2,}/g, " ")
.trim();
+1 -1
View File
@@ -21,7 +21,7 @@ import { RenderTxt } from "./blocks/RenderTxt";
import { TableGuard, guardPresence } from "./blocks/TableGuard";
import { renderCore } from "./render/core";
import { loadCanvas, inkPixels } from "./testing/rasterize";
import { byId, doc, FAMILY, find, frameFor, PAD, PAGE_W, rawLayout, txt } from "./testing/docs";
import { byId, doc, FAMILY, find, frameFor, PAD, rawLayout, txt } from "./testing/docs";
import { FONTS_DIR, setupPdfTest } from "./testing/setup";
import { stack } from "./fonts/register";

Some files were not shown because too many files have changed in this diff Show More